init
This commit is contained in:
commit
94308d81a0
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/rain_monitor.iml" filepath="$PROJECT_DIR$/.idea/rain_monitor.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/rain_monitor.iml
generated
Normal file
9
.idea/rain_monitor.iml
generated
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
199
api/api.go
Normal file
199
api/api.go
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"rain_monitor/db"
|
||||||
|
"rain_monitor/modbus"
|
||||||
|
"rain_monitor/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartWebServer 启动Web服务器
|
||||||
|
func StartWebServer() {
|
||||||
|
http.HandleFunc("/api/status", handleStatus)
|
||||||
|
http.HandleFunc("/api/raw/latest", handleLatestRawData)
|
||||||
|
http.HandleFunc("/api/trigger-query", handleTriggerQuery)
|
||||||
|
http.HandleFunc("/api/data", handleQueryData)
|
||||||
|
http.HandleFunc("/api/latest", handleLatestData)
|
||||||
|
|
||||||
|
// 静态文件服务
|
||||||
|
http.Handle("/", http.FileServer(http.Dir("static")))
|
||||||
|
|
||||||
|
log.Println("Web服务器已启动,监听端口 10003")
|
||||||
|
err := http.ListenAndServe(":10003", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Web服务器启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus 处理连接状态请求
|
||||||
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := modbus.GetConnectionStatus()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLatestRawData 获取最新原始数据
|
||||||
|
func handleLatestRawData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 从数据库获取最新数据,而不是从modbus内存中获取
|
||||||
|
weatherData, err1 := db.GetLatestWeatherData()
|
||||||
|
rainData, err2 := db.GetLatestRainGaugeData()
|
||||||
|
|
||||||
|
if (weatherData == nil && rainData == nil) || (err1 != nil && err2 != nil) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "没有可用的数据",
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"weather_error": err1,
|
||||||
|
"rain_error": err2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
"formatted_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if weatherData != nil {
|
||||||
|
result["temperature"] = weatherData.Temperature
|
||||||
|
result["humidity"] = weatherData.Humidity
|
||||||
|
result["wind_speed"] = weatherData.WindSpeed
|
||||||
|
result["wind_direction_8"] = weatherData.WindDirection8
|
||||||
|
result["wind_direction_360"] = weatherData.WindDirection360
|
||||||
|
result["atm_pressure"] = weatherData.AtmPressure
|
||||||
|
result["solar_radiation"] = weatherData.SolarRadiation
|
||||||
|
result["rainfall"] = weatherData.Rainfall
|
||||||
|
}
|
||||||
|
|
||||||
|
if rainData != nil {
|
||||||
|
result["rainfall"] = rainData.TotalRainfall
|
||||||
|
result["daily_rainfall"] = rainData.DailyRainfall
|
||||||
|
result["instant_rainfall"] = rainData.InstantRainfall
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTriggerQuery 触发设备查询(保留手动采集功能)
|
||||||
|
func handleTriggerQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err1 := modbus.QueryDevice(modbus.DeviceWeatherStation)
|
||||||
|
err2 := modbus.QueryDevice(modbus.DeviceRainGauge)
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"success": err1 == nil || err2 == nil,
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err1 != nil {
|
||||||
|
result["weather_error"] = err1.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 != nil {
|
||||||
|
result["rain_error"] = err2.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQueryData 查询历史数据
|
||||||
|
func handleQueryData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startStr := r.URL.Query().Get("start")
|
||||||
|
endStr := r.URL.Query().Get("end")
|
||||||
|
interval := r.URL.Query().Get("interval")
|
||||||
|
|
||||||
|
log.Printf("handleQueryData - 请求参数: start=%s, end=%s, interval=%s", startStr, endStr, interval)
|
||||||
|
|
||||||
|
if startStr == "" || endStr == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "缺少必要的时间参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := time.Parse(time.RFC3339, startStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("开始时间解析失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "开始时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
end, err := time.Parse(time.RFC3339, endStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("结束时间解析失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "结束时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := db.GetAggregatedData(start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("查询聚合数据失败: %v", err)
|
||||||
|
// 返回空数组而不是错误,避免前端报错
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]models.AggregatedData{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("查询成功,返回 %d 条记录", len(data))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLatestData 获取最新聚合数据
|
||||||
|
func handleLatestData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startStr := r.URL.Query().Get("start")
|
||||||
|
endStr := r.URL.Query().Get("end")
|
||||||
|
interval := r.URL.Query().Get("interval")
|
||||||
|
|
||||||
|
log.Printf("handleLatestData - 请求参数: start=%s, end=%s, interval=%s", startStr, endStr, interval)
|
||||||
|
|
||||||
|
if startStr == "" || endStr == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "缺少必要的时间参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := time.Parse(time.RFC3339, startStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("开始时间解析失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "开始时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
end, err := time.Parse(time.RFC3339, endStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("结束时间解析失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"error": "结束时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只查数据库,不主动采集
|
||||||
|
data, err := db.GetAggregatedData(start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("查询聚合数据失败: %v", err)
|
||||||
|
// 返回空数组而不是错误,避免前端报错
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]models.AggregatedData{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("查询成功,返回 %d 条记录", len(data))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
225
db/db.go
Normal file
225
db/db.go
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"rain_monitor/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
type DBConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DBName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDB(config DBConfig) error {
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||||
|
config.User, config.Password, config.Host, config.Port, config.DBName)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(20)
|
||||||
|
db.SetMaxIdleConns(10)
|
||||||
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("数据库Ping失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = createTables(); err != nil {
|
||||||
|
return fmt.Errorf("创建表失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("数据库连接成功")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseDB() {
|
||||||
|
if db != nil {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTables() error {
|
||||||
|
_, err := db.Exec(models.CreateWeatherDataTable)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建气象站数据表失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(models.CreateRainGaugeDataTable)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建雨量计数据表失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveWeatherData(data *models.WeatherData) (int64, error) {
|
||||||
|
result, err := db.Exec(models.InsertWeatherDataSQL,
|
||||||
|
data.Timestamp, data.WindSpeed, data.WindForce, data.WindDirection8, data.WindDirection360,
|
||||||
|
data.Humidity, data.Temperature, data.Noise, data.PM25, data.PM10, data.AtmPressure,
|
||||||
|
data.LuxHigh, data.LuxLow, data.LightIntensity, data.Rainfall, data.CompassAngle, data.SolarRadiation)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("保存气象站数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("获取插入ID失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveRainGaugeData(data *models.RainGaugeData) (int64, error) {
|
||||||
|
result, err := db.Exec(models.InsertRainGaugeDataSQL,
|
||||||
|
data.Timestamp, data.DailyRainfall, data.InstantRainfall, data.YesterdayRainfall,
|
||||||
|
data.TotalRainfall, data.HourlyRainfall, data.LastHourRainfall, data.Max24hRainfall,
|
||||||
|
data.Max24hRainfallPeriod, data.Min24hRainfall, data.Min24hRainfallPeriod)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("保存雨量计数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("获取插入ID失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWeatherDataByTimeRange(start, end time.Time) ([]models.WeatherData, error) {
|
||||||
|
rows, err := db.Query(models.QueryWeatherDataByTimeRangeSQL, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询气象站数据失败: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.WeatherData
|
||||||
|
for rows.Next() {
|
||||||
|
var data models.WeatherData
|
||||||
|
err := rows.Scan(
|
||||||
|
&data.ID, &data.Timestamp, &data.WindSpeed, &data.WindForce, &data.WindDirection8,
|
||||||
|
&data.WindDirection360, &data.Humidity, &data.Temperature, &data.Noise, &data.PM25,
|
||||||
|
&data.PM10, &data.AtmPressure, &data.LuxHigh, &data.LuxLow, &data.LightIntensity,
|
||||||
|
&data.Rainfall, &data.CompassAngle, &data.SolarRadiation,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描气象站数据失败: %v", err)
|
||||||
|
}
|
||||||
|
result = append(result, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRainGaugeDataByTimeRange(start, end time.Time) ([]models.RainGaugeData, error) {
|
||||||
|
rows, err := db.Query(models.QueryRainGaugeDataByTimeRangeSQL, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询雨量计数据失败: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.RainGaugeData
|
||||||
|
for rows.Next() {
|
||||||
|
var data models.RainGaugeData
|
||||||
|
err := rows.Scan(
|
||||||
|
&data.ID, &data.Timestamp, &data.DailyRainfall, &data.InstantRainfall,
|
||||||
|
&data.YesterdayRainfall, &data.TotalRainfall, &data.HourlyRainfall,
|
||||||
|
&data.LastHourRainfall, &data.Max24hRainfall, &data.Max24hRainfallPeriod,
|
||||||
|
&data.Min24hRainfall, &data.Min24hRainfallPeriod,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描雨量计数据失败: %v", err)
|
||||||
|
}
|
||||||
|
result = append(result, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLatestWeatherData() (*models.WeatherData, error) {
|
||||||
|
row := db.QueryRow(models.QueryLatestWeatherDataSQL)
|
||||||
|
if row == nil {
|
||||||
|
return nil, fmt.Errorf("没有气象站数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &models.WeatherData{}
|
||||||
|
err := row.Scan(
|
||||||
|
&data.ID, &data.Timestamp, &data.WindSpeed, &data.WindForce, &data.WindDirection8,
|
||||||
|
&data.WindDirection360, &data.Humidity, &data.Temperature, &data.Noise, &data.PM25,
|
||||||
|
&data.PM10, &data.AtmPressure, &data.LuxHigh, &data.LuxLow, &data.LightIntensity,
|
||||||
|
&data.Rainfall, &data.CompassAngle, &data.SolarRadiation,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描最新气象站数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLatestRainGaugeData() (*models.RainGaugeData, error) {
|
||||||
|
row := db.QueryRow(models.QueryLatestRainGaugeDataSQL)
|
||||||
|
if row == nil {
|
||||||
|
return nil, fmt.Errorf("没有雨量计数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &models.RainGaugeData{}
|
||||||
|
err := row.Scan(
|
||||||
|
&data.ID, &data.Timestamp, &data.DailyRainfall, &data.InstantRainfall,
|
||||||
|
&data.YesterdayRainfall, &data.TotalRainfall, &data.HourlyRainfall,
|
||||||
|
&data.LastHourRainfall, &data.Max24hRainfall, &data.Max24hRainfallPeriod,
|
||||||
|
&data.Min24hRainfall, &data.Min24hRainfallPeriod,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描最新雨量计数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAggregatedData(start, end time.Time) ([]models.AggregatedData, error) {
|
||||||
|
log.Printf("GetAggregatedData调用 - 时间范围: 开始=%v, 结束=%v", start, end)
|
||||||
|
|
||||||
|
rows, err := db.Query(models.QueryAggregatedDataSQL, start, end, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SQL查询失败: %v", err)
|
||||||
|
return nil, fmt.Errorf("查询聚合数据失败: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.AggregatedData
|
||||||
|
for rows.Next() {
|
||||||
|
var data models.AggregatedData
|
||||||
|
var timestampStr string
|
||||||
|
err := rows.Scan(
|
||||||
|
×tampStr, &data.Rainfall, &data.AvgTemperature, &data.AvgHumidity,
|
||||||
|
&data.AvgWindSpeed, &data.AtmPressure, &data.SolarRadiation,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("扫描行数据失败: %v", err)
|
||||||
|
return nil, fmt.Errorf("扫描聚合数据失败: %v", err)
|
||||||
|
}
|
||||||
|
data.FormattedTime = timestampStr
|
||||||
|
result = append(result, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("查询结果: 找到 %d 条记录", len(result))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
7
go.mod
Normal file
7
go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module rain_monitor
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
|
||||||
|
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
52
main.go
Normal file
52
main.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"rain_monitor/api"
|
||||||
|
"rain_monitor/db"
|
||||||
|
"rain_monitor/modbus"
|
||||||
|
"rain_monitor/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
dbHost = flag.String("db-host", "8.134.185.53", "数据库主机地址")
|
||||||
|
dbPort = flag.Int("db-port", 3306, "数据库端口")
|
||||||
|
dbUser = flag.String("db-user", "remote", "数据库用户名")
|
||||||
|
dbPassword = flag.String("db-password", "root", "数据库密码")
|
||||||
|
dbName = flag.String("db-name", "rain_monitor", "数据库名称")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 解析命令行参数
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 初始化数据库连接
|
||||||
|
dbConfig := db.DBConfig{
|
||||||
|
Host: *dbHost,
|
||||||
|
Port: *dbPort,
|
||||||
|
User: *dbUser,
|
||||||
|
Password: *dbPassword,
|
||||||
|
DBName: *dbName,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("正在连接数据库...")
|
||||||
|
err := db.InitDB(dbConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("数据库初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
defer db.CloseDB()
|
||||||
|
log.Println("数据库连接成功")
|
||||||
|
|
||||||
|
// 启动TCP服务器
|
||||||
|
log.Println("正在启动TCP服务器...")
|
||||||
|
go modbus.StartTCPServer()
|
||||||
|
|
||||||
|
// 启动定时任务调度器
|
||||||
|
log.Println("正在启动定时任务调度器...")
|
||||||
|
go scheduler.StartScheduler()
|
||||||
|
|
||||||
|
// 启动Web服务器
|
||||||
|
log.Println("正在启动Web服务器...")
|
||||||
|
api.StartWebServer() // 这个函数会阻塞主线程
|
||||||
|
}
|
||||||
313
modbus/modbus.go
Normal file
313
modbus/modbus.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package modbus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"rain_monitor/db"
|
||||||
|
"rain_monitor/models"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeviceWeatherStation = 1 // 气象站
|
||||||
|
DeviceRainGauge = 2 // 雨量计
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WeatherStationAddr = 0x01
|
||||||
|
RainGaugeAddr = 0x02
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FuncReadHoldingRegisters = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
WeatherStationCmd = []byte{0x01, 0x03, 0x01, 0xf4, 0x00, 0x10, 0x04, 0x08} // 气象站查询命令
|
||||||
|
RainGaugeCmd = []byte{0x02, 0x03, 0x00, 0x00, 0x00, 0x0a, 0xc5, 0xfe} // 雨量计查询命令
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
connectedClients map[string]net.Conn
|
||||||
|
clientsMutex sync.RWMutex
|
||||||
|
latestWeatherData *models.WeatherData
|
||||||
|
latestRainData *models.RainGaugeData
|
||||||
|
dataMutex sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
connectedClients = make(map[string]net.Conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTCPServer 启动TCP服务器
|
||||||
|
func StartTCPServer() {
|
||||||
|
listener, err := net.Listen("tcp", ":10004")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("无法启动TCP服务器: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
log.Println("TCP服务器已启动,监听端口 10004")
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("接受连接失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
clientAddr := conn.RemoteAddr().String()
|
||||||
|
log.Printf("新客户端连接: %s", clientAddr)
|
||||||
|
|
||||||
|
clientsMutex.Lock()
|
||||||
|
connectedClients[clientAddr] = conn
|
||||||
|
clientsMutex.Unlock()
|
||||||
|
|
||||||
|
go handleConnection(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConnection 处理客户端连接
|
||||||
|
func handleConnection(conn net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
conn.Close()
|
||||||
|
clientAddr := conn.RemoteAddr().String()
|
||||||
|
|
||||||
|
clientsMutex.Lock()
|
||||||
|
delete(connectedClients, clientAddr)
|
||||||
|
clientsMutex.Unlock()
|
||||||
|
|
||||||
|
log.Printf("客户端断开连接: %s", clientAddr)
|
||||||
|
}()
|
||||||
|
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(buffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("读取数据失败: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
data := buffer[:n]
|
||||||
|
log.Printf("收到数据: %s", hex.EncodeToString(data))
|
||||||
|
|
||||||
|
processModbusData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessModbusData 解析ModBus数据
|
||||||
|
func processModbusData(data []byte) {
|
||||||
|
if len(data) < 3 {
|
||||||
|
log.Println("数据长度不足")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceAddr := data[0]
|
||||||
|
functionCode := data[1]
|
||||||
|
|
||||||
|
if functionCode != FuncReadHoldingRegisters {
|
||||||
|
log.Printf("不支持的功能码: %02X", functionCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch deviceAddr {
|
||||||
|
case WeatherStationAddr:
|
||||||
|
processWeatherStationData(data)
|
||||||
|
case RainGaugeAddr:
|
||||||
|
processRainGaugeData(data)
|
||||||
|
default:
|
||||||
|
log.Printf("未知设备地址: %02X", deviceAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessWeatherStationData 处理气象站数据
|
||||||
|
func processWeatherStationData(data []byte) {
|
||||||
|
if len(data) < 35 {
|
||||||
|
log.Println("气象站数据长度不足")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byteCount := int(data[2])
|
||||||
|
if len(data) < 3+byteCount+2 {
|
||||||
|
log.Println("气象站数据长度与字节数不匹配")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSection := data[3 : 3+byteCount]
|
||||||
|
|
||||||
|
weather := &models.WeatherData{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dataSection) >= 32 {
|
||||||
|
weather.WindSpeed = float64(uint16(dataSection[0])<<8|uint16(dataSection[1])) / 100.0
|
||||||
|
weather.WindForce = int(uint16(dataSection[2])<<8 | uint16(dataSection[3]))
|
||||||
|
weather.WindDirection8 = int(uint16(dataSection[4])<<8 | uint16(dataSection[5]))
|
||||||
|
weather.WindDirection360 = int(uint16(dataSection[6])<<8 | uint16(dataSection[7]))
|
||||||
|
weather.Humidity = float64(uint16(dataSection[8])<<8|uint16(dataSection[9])) / 10.0
|
||||||
|
weather.Temperature = float64(uint16(dataSection[10])<<8|uint16(dataSection[11])) / 10.0
|
||||||
|
weather.Noise = float64(uint16(dataSection[12])<<8|uint16(dataSection[13])) / 10.0
|
||||||
|
weather.PM25 = int(uint16(dataSection[14])<<8 | uint16(dataSection[15]))
|
||||||
|
weather.PM10 = int(uint16(dataSection[16])<<8 | uint16(dataSection[17]))
|
||||||
|
weather.AtmPressure = float64(uint16(dataSection[18])<<8|uint16(dataSection[19])) / 10.0
|
||||||
|
weather.LuxHigh = int(uint16(dataSection[20])<<8 | uint16(dataSection[21]))
|
||||||
|
weather.LuxLow = int(uint16(dataSection[22])<<8 | uint16(dataSection[23]))
|
||||||
|
weather.LightIntensity = int(uint16(dataSection[24])<<8 | uint16(dataSection[25]))
|
||||||
|
weather.Rainfall = float64(uint16(dataSection[26])<<8|uint16(dataSection[27])) / 10.0
|
||||||
|
weather.CompassAngle = float64(uint16(dataSection[28])<<8|uint16(dataSection[29])) / 100.0
|
||||||
|
weather.SolarRadiation = int(uint16(dataSection[30])<<8 | uint16(dataSection[31]))
|
||||||
|
|
||||||
|
dataMutex.Lock()
|
||||||
|
latestWeatherData = weather
|
||||||
|
dataMutex.Unlock()
|
||||||
|
|
||||||
|
log.Printf("气象站数据更新: 温度=%.1f℃, 湿度=%.1f%%, 风速=%.2fm/s, 风向=%d°, 大气压力=%.1fhPa, PM2.5=%dμg/m³, PM10=%dμg/m³, 降雨量=%.1fmm, 光照强度=%dlux",
|
||||||
|
weather.Temperature, weather.Humidity, weather.WindSpeed, weather.WindDirection360, weather.AtmPressure,
|
||||||
|
weather.PM25, weather.PM10, weather.Rainfall, weather.LightIntensity)
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
_, err := db.SaveWeatherData(weather)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("保存气象站数据失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("气象站数据已保存到数据库")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessRainGaugeData 处理雨量计数据
|
||||||
|
func processRainGaugeData(data []byte) {
|
||||||
|
if len(data) < 25 {
|
||||||
|
log.Println("雨量计数据长度不足")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byteCount := int(data[2])
|
||||||
|
if len(data) < 3+byteCount+2 {
|
||||||
|
log.Println("雨量计数据长度与字节数不匹配")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSection := data[3 : 3+byteCount]
|
||||||
|
|
||||||
|
rainData := &models.RainGaugeData{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dataSection) >= 20 {
|
||||||
|
rainData.DailyRainfall = float64(uint16(dataSection[0])<<8|uint16(dataSection[1])) / 10.0
|
||||||
|
rainData.InstantRainfall = float64(uint16(dataSection[2])<<8|uint16(dataSection[3])) / 10.0
|
||||||
|
rainData.YesterdayRainfall = float64(uint16(dataSection[4])<<8|uint16(dataSection[5])) / 10.0
|
||||||
|
rainData.TotalRainfall = float64(uint16(dataSection[6])<<8|uint16(dataSection[7])) / 10.0
|
||||||
|
rainData.HourlyRainfall = float64(uint16(dataSection[8])<<8|uint16(dataSection[9])) / 10.0
|
||||||
|
rainData.LastHourRainfall = float64(uint16(dataSection[10])<<8|uint16(dataSection[11])) / 10.0
|
||||||
|
rainData.Max24hRainfall = float64(uint16(dataSection[12])<<8|uint16(dataSection[13])) / 10.0
|
||||||
|
rainData.Max24hRainfallPeriod = int(uint16(dataSection[14])<<8 | uint16(dataSection[15]))
|
||||||
|
rainData.Min24hRainfall = float64(uint16(dataSection[16])<<8|uint16(dataSection[17])) / 10.0
|
||||||
|
rainData.Min24hRainfallPeriod = int(uint16(dataSection[18])<<8 | uint16(dataSection[19]))
|
||||||
|
|
||||||
|
dataMutex.Lock()
|
||||||
|
latestRainData = rainData
|
||||||
|
dataMutex.Unlock()
|
||||||
|
|
||||||
|
log.Printf("雨量计数据更新: 当天降雨量=%.1fmm, 瞬时降雨量=%.1fmm, 总降雨量=%.1fmm, 昨日降雨量=%.1fmm, 小时降雨量=%.1fmm, 上一小时降雨量=%.1fmm",
|
||||||
|
rainData.DailyRainfall, rainData.InstantRainfall, rainData.TotalRainfall,
|
||||||
|
rainData.YesterdayRainfall, rainData.HourlyRainfall, rainData.LastHourRainfall)
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
_, err := db.SaveRainGaugeData(rainData)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("保存雨量计数据失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("雨量计数据已保存到数据库")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryDevice 向设备发送查询命令
|
||||||
|
func QueryDevice(deviceType int) error {
|
||||||
|
var cmd []byte
|
||||||
|
|
||||||
|
switch deviceType {
|
||||||
|
case DeviceWeatherStation:
|
||||||
|
cmd = WeatherStationCmd
|
||||||
|
case DeviceRainGauge:
|
||||||
|
cmd = RainGaugeCmd
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("未知设备类型: %d", deviceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientsMutex.RLock()
|
||||||
|
defer clientsMutex.RUnlock()
|
||||||
|
|
||||||
|
if len(connectedClients) == 0 {
|
||||||
|
return fmt.Errorf("没有连接的客户端")
|
||||||
|
}
|
||||||
|
|
||||||
|
for addr, conn := range connectedClients {
|
||||||
|
_, err := conn.Write(cmd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("向客户端 %s 发送命令失败: %v", addr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("向客户端 %s 发送命令: %s", addr, hex.EncodeToString(cmd))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionStatus 获取连接状态
|
||||||
|
func GetConnectionStatus() models.ConnectionStatus {
|
||||||
|
clientsMutex.RLock()
|
||||||
|
defer clientsMutex.RUnlock()
|
||||||
|
|
||||||
|
status := models.ConnectionStatus{
|
||||||
|
Connected: len(connectedClients) > 0,
|
||||||
|
Count: len(connectedClients),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(connectedClients) > 0 {
|
||||||
|
for addr := range connectedClients {
|
||||||
|
host, _, _ := net.SplitHostPort(addr)
|
||||||
|
status.IP = host
|
||||||
|
status.Port = 10004
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestWeatherData 获取最新气象站数据
|
||||||
|
func GetLatestWeatherData() *models.WeatherData {
|
||||||
|
dataMutex.RLock()
|
||||||
|
defer dataMutex.RUnlock()
|
||||||
|
|
||||||
|
if latestWeatherData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回一个副本
|
||||||
|
data := *latestWeatherData
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestRainData 获取最新雨量计数据
|
||||||
|
func GetLatestRainData() *models.RainGaugeData {
|
||||||
|
dataMutex.RLock()
|
||||||
|
defer dataMutex.RUnlock()
|
||||||
|
|
||||||
|
if latestRainData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回一个副本
|
||||||
|
data := *latestRainData
|
||||||
|
return &data
|
||||||
|
}
|
||||||
187
models/models.go
Normal file
187
models/models.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeatherData 气象站数据结构
|
||||||
|
type WeatherData struct {
|
||||||
|
ID int64 `json:"id" db:"id"`
|
||||||
|
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
||||||
|
WindSpeed float64 `json:"wind_speed" db:"wind_speed"`
|
||||||
|
WindForce int `json:"wind_force" db:"wind_force"`
|
||||||
|
WindDirection8 int `json:"wind_direction_8" db:"wind_direction_8"`
|
||||||
|
WindDirection360 int `json:"wind_direction_360" db:"wind_direction_360"`
|
||||||
|
Humidity float64 `json:"humidity" db:"humidity"`
|
||||||
|
Temperature float64 `json:"temperature" db:"temperature"`
|
||||||
|
Noise float64 `json:"noise" db:"noise"`
|
||||||
|
PM25 int `json:"pm25" db:"pm25"`
|
||||||
|
PM10 int `json:"pm10" db:"pm10"`
|
||||||
|
AtmPressure float64 `json:"atm_pressure" db:"atm_pressure"`
|
||||||
|
LuxHigh int `json:"lux_high" db:"lux_high"`
|
||||||
|
LuxLow int `json:"lux_low" db:"lux_low"`
|
||||||
|
LightIntensity int `json:"light_intensity" db:"light_intensity"`
|
||||||
|
Rainfall float64 `json:"rainfall" db:"rainfall"`
|
||||||
|
CompassAngle float64 `json:"compass_angle" db:"compass_angle"`
|
||||||
|
SolarRadiation int `json:"solar_radiation" db:"solar_radiation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RainGaugeData 雨量计数据结构
|
||||||
|
type RainGaugeData struct {
|
||||||
|
ID int64 `json:"id" db:"id"`
|
||||||
|
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
||||||
|
DailyRainfall float64 `json:"daily_rainfall" db:"daily_rainfall"`
|
||||||
|
InstantRainfall float64 `json:"instant_rainfall" db:"instant_rainfall"`
|
||||||
|
YesterdayRainfall float64 `json:"yesterday_rainfall" db:"yesterday_rainfall"`
|
||||||
|
TotalRainfall float64 `json:"total_rainfall" db:"total_rainfall"`
|
||||||
|
HourlyRainfall float64 `json:"hourly_rainfall" db:"hourly_rainfall"`
|
||||||
|
LastHourRainfall float64 `json:"last_hour_rainfall" db:"last_hour_rainfall"`
|
||||||
|
Max24hRainfall float64 `json:"max_24h_rainfall" db:"max_24h_rainfall"`
|
||||||
|
Max24hRainfallPeriod int `json:"max_24h_rainfall_period" db:"max_24h_rainfall_period"`
|
||||||
|
Min24hRainfall float64 `json:"min_24h_rainfall" db:"min_24h_rainfall"`
|
||||||
|
Min24hRainfallPeriod int `json:"min_24h_rainfall_period" db:"min_24h_rainfall_period"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregatedData 聚合数据结构,用于前端展示
|
||||||
|
type AggregatedData struct {
|
||||||
|
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
||||||
|
FormattedTime string `json:"formatted_time,omitempty"`
|
||||||
|
Rainfall float64 `json:"rainfall" db:"rainfall"`
|
||||||
|
AvgTemperature float64 `json:"avg_temperature" db:"avg_temperature"`
|
||||||
|
AvgHumidity float64 `json:"avg_humidity" db:"avg_humidity"`
|
||||||
|
AvgWindSpeed float64 `json:"avg_wind_speed" db:"avg_wind_speed"`
|
||||||
|
AtmPressure float64 `json:"atm_pressure" db:"atm_pressure"`
|
||||||
|
SolarRadiation float64 `json:"solar_radiation" db:"solar_radiation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionStatus 连接状态
|
||||||
|
type ConnectionStatus struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWeatherDataTable 创建气象站数据表SQL
|
||||||
|
const CreateWeatherDataTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_data (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
wind_speed FLOAT NOT NULL,
|
||||||
|
wind_force INT NOT NULL,
|
||||||
|
wind_direction_8 INT NOT NULL,
|
||||||
|
wind_direction_360 INT NOT NULL,
|
||||||
|
humidity FLOAT NOT NULL,
|
||||||
|
temperature FLOAT NOT NULL,
|
||||||
|
noise FLOAT NOT NULL,
|
||||||
|
pm25 INT NOT NULL,
|
||||||
|
pm10 INT NOT NULL,
|
||||||
|
atm_pressure FLOAT NOT NULL,
|
||||||
|
lux_high INT NOT NULL,
|
||||||
|
lux_low INT NOT NULL,
|
||||||
|
light_intensity INT NOT NULL,
|
||||||
|
rainfall FLOAT NOT NULL,
|
||||||
|
compass_angle FLOAT NOT NULL,
|
||||||
|
solar_radiation INT NOT NULL,
|
||||||
|
INDEX idx_timestamp (timestamp)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`
|
||||||
|
|
||||||
|
// CreateRainGaugeDataTable 创建雨量计数据表SQL
|
||||||
|
const CreateRainGaugeDataTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS rain_gauge_data (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
daily_rainfall FLOAT NOT NULL,
|
||||||
|
instant_rainfall FLOAT NOT NULL,
|
||||||
|
yesterday_rainfall FLOAT NOT NULL,
|
||||||
|
total_rainfall FLOAT NOT NULL,
|
||||||
|
hourly_rainfall FLOAT NOT NULL,
|
||||||
|
last_hour_rainfall FLOAT NOT NULL,
|
||||||
|
max_24h_rainfall FLOAT NOT NULL,
|
||||||
|
max_24h_rainfall_period INT NOT NULL,
|
||||||
|
min_24h_rainfall FLOAT NOT NULL,
|
||||||
|
min_24h_rainfall_period INT NOT NULL,
|
||||||
|
INDEX idx_timestamp (timestamp)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`
|
||||||
|
|
||||||
|
// InsertWeatherDataSQL 插入气象站数据SQL
|
||||||
|
const InsertWeatherDataSQL = `
|
||||||
|
INSERT INTO weather_data (
|
||||||
|
timestamp, wind_speed, wind_force, wind_direction_8, wind_direction_360,
|
||||||
|
humidity, temperature, noise, pm25, pm10, atm_pressure, lux_high, lux_low,
|
||||||
|
light_intensity, rainfall, compass_angle, solar_radiation
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// InsertRainGaugeDataSQL 插入雨量计数据SQL
|
||||||
|
const InsertRainGaugeDataSQL = `
|
||||||
|
INSERT INTO rain_gauge_data (
|
||||||
|
timestamp, daily_rainfall, instant_rainfall, yesterday_rainfall,
|
||||||
|
total_rainfall, hourly_rainfall, last_hour_rainfall, max_24h_rainfall,
|
||||||
|
max_24h_rainfall_period, min_24h_rainfall, min_24h_rainfall_period
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// QueryWeatherDataByTimeRangeSQL 按时间范围查询气象站数据SQL
|
||||||
|
const QueryWeatherDataByTimeRangeSQL = `
|
||||||
|
SELECT * FROM weather_data
|
||||||
|
WHERE timestamp BETWEEN ? AND ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
// QueryRainGaugeDataByTimeRangeSQL 按时间范围查询雨量计数据SQL
|
||||||
|
const QueryRainGaugeDataByTimeRangeSQL = `
|
||||||
|
SELECT * FROM rain_gauge_data
|
||||||
|
WHERE timestamp BETWEEN ? AND ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
// QueryLatestWeatherDataSQL 查询最新气象站数据SQL
|
||||||
|
const QueryLatestWeatherDataSQL = `
|
||||||
|
SELECT * FROM weather_data
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// QueryLatestRainGaugeDataSQL 查询最新雨量计数据SQL
|
||||||
|
const QueryLatestRainGaugeDataSQL = `
|
||||||
|
SELECT * FROM rain_gauge_data
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// QueryAggregatedDataSQL 查询聚合数据SQL (小时级别)
|
||||||
|
const QueryAggregatedDataSQL = `
|
||||||
|
SELECT
|
||||||
|
w.time_hour as timestamp,
|
||||||
|
MAX(r.daily_rainfall) as rainfall,
|
||||||
|
AVG(w.temperature) as avg_temperature,
|
||||||
|
AVG(w.humidity) as avg_humidity,
|
||||||
|
AVG(w.wind_speed) as avg_wind_speed,
|
||||||
|
AVG(w.atm_pressure) as atm_pressure,
|
||||||
|
AVG(w.solar_radiation) as solar_radiation
|
||||||
|
FROM
|
||||||
|
(SELECT
|
||||||
|
DATE_FORMAT(timestamp, '%Y-%m-%d %H:00:00') as time_hour,
|
||||||
|
temperature, humidity, wind_speed, atm_pressure, solar_radiation
|
||||||
|
FROM weather_data
|
||||||
|
WHERE timestamp BETWEEN ? AND ?
|
||||||
|
) w
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT
|
||||||
|
DATE_FORMAT(timestamp, '%Y-%m-%d %H:00:00') as time_hour,
|
||||||
|
daily_rainfall
|
||||||
|
FROM rain_gauge_data
|
||||||
|
WHERE timestamp BETWEEN ? AND ?
|
||||||
|
) r ON w.time_hour = r.time_hour
|
||||||
|
GROUP BY
|
||||||
|
w.time_hour
|
||||||
|
ORDER BY
|
||||||
|
w.time_hour DESC
|
||||||
|
`
|
||||||
177
scheduler/scheduler.go
Normal file
177
scheduler/scheduler.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"rain_monitor/db"
|
||||||
|
"rain_monitor/modbus"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务配置
|
||||||
|
type TaskConfig struct {
|
||||||
|
WeatherStationInterval time.Duration // 气象站查询间隔
|
||||||
|
RainGaugeInterval time.Duration // 雨量计查询间隔
|
||||||
|
Enabled bool // 是否启用定时查询
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
config TaskConfig
|
||||||
|
weatherTick *time.Ticker
|
||||||
|
stopChan chan struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始化默认配置
|
||||||
|
func init() {
|
||||||
|
config = TaskConfig{
|
||||||
|
WeatherStationInterval: 15 * time.Minute, // 默认15分钟查询一次气象站
|
||||||
|
RainGaugeInterval: time.Hour, // 默认每小时查询一次雨量计
|
||||||
|
Enabled: true, // 默认启用
|
||||||
|
}
|
||||||
|
stopChan = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScheduler 启动定时任务调度器
|
||||||
|
func StartScheduler() {
|
||||||
|
if !config.Enabled {
|
||||||
|
log.Println("定时查询任务已禁用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("启动定时查询任务,气象站间隔: %v, 雨量计整点查询",
|
||||||
|
config.WeatherStationInterval)
|
||||||
|
|
||||||
|
// 启动气象站查询任务
|
||||||
|
weatherTick = time.NewTicker(config.WeatherStationInterval)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-weatherTick.C:
|
||||||
|
queryWeatherStation()
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 启动雨量计整点查询任务
|
||||||
|
go scheduleHourlyRainGaugeQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算到下一个整点的等待时间
|
||||||
|
func durationUntilNextHour() time.Duration {
|
||||||
|
now := time.Now()
|
||||||
|
nextHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour()+1, 0, 0, 0, now.Location())
|
||||||
|
return nextHour.Sub(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整点查询雨量计任务
|
||||||
|
func scheduleHourlyRainGaugeQuery() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// 计算到下一个整点的等待时间
|
||||||
|
waitTime := durationUntilNextHour()
|
||||||
|
log.Printf("下一次雨量计查询将在 %s 后进行 (整点: %s)",
|
||||||
|
waitTime.String(), time.Now().Add(waitTime).Format("15:04:05"))
|
||||||
|
|
||||||
|
// 等待到下一个整点
|
||||||
|
timer := time.NewTimer(waitTime)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
queryRainGauge()
|
||||||
|
case <-stopChan:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopScheduler 停止定时任务调度器
|
||||||
|
func StopScheduler() {
|
||||||
|
if weatherTick != nil {
|
||||||
|
weatherTick.Stop()
|
||||||
|
}
|
||||||
|
close(stopChan)
|
||||||
|
log.Println("定时查询任务已停止")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTaskConfig 设置任务配置
|
||||||
|
func SetTaskConfig(newConfig TaskConfig) {
|
||||||
|
// 先停止现有任务
|
||||||
|
StopScheduler()
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
config = newConfig
|
||||||
|
|
||||||
|
// 重新启动任务
|
||||||
|
if config.Enabled {
|
||||||
|
StartScheduler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryWeatherStation 查询气象站并保存数据
|
||||||
|
func queryWeatherStation() {
|
||||||
|
log.Println("执行气象站查询任务")
|
||||||
|
|
||||||
|
// 发送查询命令
|
||||||
|
err := modbus.QueryDevice(modbus.DeviceWeatherStation)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("气象站查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待设备响应
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// 获取最新数据
|
||||||
|
weatherData := modbus.GetLatestWeatherData()
|
||||||
|
if weatherData == nil {
|
||||||
|
log.Println("未获取到气象站数据")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
_, err = db.SaveWeatherData(weatherData)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("保存气象站数据失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("气象站数据已保存,温度: %.1f℃, 湿度: %.1f%%",
|
||||||
|
weatherData.Temperature, weatherData.Humidity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryRainGauge 查询雨量计并保存数据
|
||||||
|
func queryRainGauge() {
|
||||||
|
log.Println("执行雨量计查询任务 (整点)")
|
||||||
|
|
||||||
|
// 发送查询命令
|
||||||
|
err := modbus.QueryDevice(modbus.DeviceRainGauge)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("雨量计查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待设备响应
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// 获取最新数据
|
||||||
|
rainData := modbus.GetLatestRainData()
|
||||||
|
if rainData == nil {
|
||||||
|
log.Println("未获取到雨量计数据")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
_, err = db.SaveRainGaugeData(rainData)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("保存雨量计数据失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("雨量计数据已保存,当天降雨量: %.1fmm, 总降雨量: %.1fmm",
|
||||||
|
rainData.DailyRainfall, rainData.TotalRainfall)
|
||||||
|
}
|
||||||
733
static/index.html
Normal file
733
static/index.html
Normal file
@ -0,0 +1,733 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>雨量监测系统</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-data {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-data h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-item {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-value {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input, button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>雨量监测系统</h1>
|
||||||
|
<div id="connectionStatus" style="display: inline-block; padding: 5px 10px; border-radius: 4px; margin-left: 10px; background-color: red; color: white;">
|
||||||
|
未连接
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="interval">数据粒度:</label>
|
||||||
|
<select id="interval">
|
||||||
|
<option value="raw">原始数据</option>
|
||||||
|
<option value="1min">1分钟(测试用)</option>
|
||||||
|
<option value="5min">5分钟</option>
|
||||||
|
<option value="30min">30分钟</option>
|
||||||
|
<option value="1hour" selected>1小时</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="startDate">开始时间:</label>
|
||||||
|
<input type="datetime-local" id="startDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="endDate">结束时间:</label>
|
||||||
|
<input type="datetime-local" id="endDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<button onclick="queryData()">查询历史数据</button>
|
||||||
|
<button onclick="queryLatestData()">查询最新数据</button>
|
||||||
|
<button onclick="exportData()">导出数据</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最新数据显示区域 -->
|
||||||
|
<div class="latest-data">
|
||||||
|
<h3>最新传感器数据 <span id="latest-time" style="font-weight: normal; font-size: 14px;"></span></h3>
|
||||||
|
<div class="data-grid">
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">温度</div>
|
||||||
|
<div class="data-value" id="latest-temperature">--</div>
|
||||||
|
<div class="data-unit">℃</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">湿度</div>
|
||||||
|
<div class="data-value" id="latest-humidity">--</div>
|
||||||
|
<div class="data-unit">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">风速</div>
|
||||||
|
<div class="data-value" id="latest-wind-speed">--</div>
|
||||||
|
<div class="data-unit">m/s</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">风向</div>
|
||||||
|
<div class="data-value" id="latest-wind-direction">--</div>
|
||||||
|
<div class="data-unit">°</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">大气压</div>
|
||||||
|
<div class="data-value" id="latest-atm-pressure">--</div>
|
||||||
|
<div class="data-unit">kPa</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">太阳辐射</div>
|
||||||
|
<div class="data-value" id="latest-solar-radiation">--</div>
|
||||||
|
<div class="data-unit">W/m²</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">累计雨量</div>
|
||||||
|
<div class="data-value" id="latest-rainfall">--</div>
|
||||||
|
<div class="data-unit">mm</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="mainChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>降雨量(mm)</th>
|
||||||
|
<th>温度(℃)</th>
|
||||||
|
<th>湿度(%)</th>
|
||||||
|
<th>风速(m/s)</th>
|
||||||
|
<th>大气压(kPa)</th>
|
||||||
|
<th>太阳辐射(W/m²)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let mainChart = null;
|
||||||
|
let connectionCheckTimer = null;
|
||||||
|
|
||||||
|
// 检查连接状态
|
||||||
|
function checkConnectionStatus() {
|
||||||
|
fetch('/api/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const statusElem = document.getElementById('connectionStatus');
|
||||||
|
if (data.connected) {
|
||||||
|
statusElem.style.backgroundColor = 'green';
|
||||||
|
if (data.count > 1) {
|
||||||
|
statusElem.textContent = `已连接: ${data.ip}:${data.port} (共${data.count}个设备)`;
|
||||||
|
} else {
|
||||||
|
statusElem.textContent = `已连接: ${data.ip}:${data.port}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusElem.style.backgroundColor = 'red';
|
||||||
|
statusElem.textContent = '未连接';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('获取连接状态失败:', error);
|
||||||
|
const statusElem = document.getElementById('connectionStatus');
|
||||||
|
statusElem.style.backgroundColor = 'red';
|
||||||
|
statusElem.textContent = '状态未知';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化日期选择器
|
||||||
|
function initDatePickers() {
|
||||||
|
// 获取当前北京时间(UTC+8)
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 设置开始时间为当天的0点(北京时间)
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
document.getElementById('startDate').value = formatDateTime(today);
|
||||||
|
document.getElementById('endDate').value = formatDateTime(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间为中国时区(UTC+8)
|
||||||
|
function formatDateTime(date) {
|
||||||
|
// 转换为ISO字符串,但不使用Z(表示UTC),而是使用+08:00表示中国时区
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询历史数据
|
||||||
|
function queryData() {
|
||||||
|
const interval = document.getElementById('interval').value;
|
||||||
|
const startDate = document.getElementById('startDate').value;
|
||||||
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
alert('请选择开始和结束时间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保时间格式符合后端要求,添加本地时区信息
|
||||||
|
const startDateTime = new Date(startDate).toISOString();
|
||||||
|
const endDateTime = new Date(endDate).toISOString();
|
||||||
|
|
||||||
|
// 加载状态指示
|
||||||
|
document.getElementById('mainChart').style.opacity = 0.5;
|
||||||
|
|
||||||
|
fetch(`/api/data?interval=${interval}&start=${startDateTime}&end=${endDateTime}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
updateChart(data);
|
||||||
|
updateTable(data);
|
||||||
|
// 恢复正常显示
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('获取数据失败,请检查网络连接');
|
||||||
|
// 恢复正常显示
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最新传感器数据(不触发设备查询)
|
||||||
|
function fetchLatestSensorData() {
|
||||||
|
fetch('/api/raw/latest')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.log('获取最新传感器数据失败:', data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.timestamp) {
|
||||||
|
console.log('No latest sensor data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最新数据显示
|
||||||
|
document.getElementById('latest-time').textContent = `(${data.formatted_time})`;
|
||||||
|
document.getElementById('latest-temperature').textContent = data.temperature ? data.temperature.toFixed(1) : '--';
|
||||||
|
document.getElementById('latest-humidity').textContent = data.humidity ? data.humidity.toFixed(1) : '--';
|
||||||
|
document.getElementById('latest-wind-speed').textContent = data.wind_speed ? data.wind_speed.toFixed(2) : '--';
|
||||||
|
document.getElementById('latest-wind-direction').textContent = data.wind_direction_360 || '--';
|
||||||
|
document.getElementById('latest-atm-pressure').textContent = data.atm_pressure ? data.atm_pressure.toFixed(1) : '--';
|
||||||
|
document.getElementById('latest-solar-radiation').textContent = data.solar_radiation || '--';
|
||||||
|
document.getElementById('latest-rainfall').textContent = data.rainfall ? data.rainfall.toFixed(1) : '--';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('获取最新传感器数据失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发设备进行数据查询
|
||||||
|
function triggerDeviceQuery() {
|
||||||
|
return fetch('/api/trigger-query')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('触发设备查询结果:', data);
|
||||||
|
if (data.success) {
|
||||||
|
// 如果成功触发查询,等待3秒后获取新数据
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('等待3秒后获取新数据');
|
||||||
|
resolve(data);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('触发设备查询失败:', error);
|
||||||
|
return { success: false, message: '触发设备查询失败' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发设备进行数据查询并获取最新数据
|
||||||
|
function triggerQueryAndFetchData() {
|
||||||
|
const latestDataElement = document.querySelector('.latest-data');
|
||||||
|
latestDataElement.style.opacity = 0.5;
|
||||||
|
|
||||||
|
// 触发设备查询
|
||||||
|
return triggerDeviceQuery()
|
||||||
|
.then(() => {
|
||||||
|
// 获取最新传感器数据
|
||||||
|
return fetchLatestSensorData();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// 恢复最新数据区域的不透明度
|
||||||
|
latestDataElement.style.opacity = 1;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('触发查询并获取数据失败:', error);
|
||||||
|
// 恢复最新数据区域的不透明度
|
||||||
|
latestDataElement.style.opacity = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询最新数据
|
||||||
|
function queryLatestData() {
|
||||||
|
const interval = document.getElementById('interval').value;
|
||||||
|
|
||||||
|
// 计算最近时间范围
|
||||||
|
const endTime = new Date();
|
||||||
|
const startTime = new Date(endTime);
|
||||||
|
startTime.setHours(0, 0, 0, 0); // 设置为当天0点
|
||||||
|
|
||||||
|
// 确保时间格式符合后端要求
|
||||||
|
const startDateTime = startTime.toISOString();
|
||||||
|
const endDateTime = endTime.toISOString();
|
||||||
|
|
||||||
|
// 加载状态指示
|
||||||
|
document.getElementById('mainChart').style.opacity = 0.5;
|
||||||
|
|
||||||
|
// 首先触发设备查询并获取最新数据
|
||||||
|
triggerQueryAndFetchData()
|
||||||
|
.then(() => {
|
||||||
|
// 获取聚合数据
|
||||||
|
return fetch(`/api/latest?interval=${interval}&start=${startDateTime}&end=${endDateTime}`);
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.error('后端错误:', data.error);
|
||||||
|
alert('获取数据失败: ' + data.error);
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查数据是否为空数组
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
console.log('没有可用的数据');
|
||||||
|
alert('没有可用的数据,请稍后再试');
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChart(data);
|
||||||
|
updateTable(data);
|
||||||
|
|
||||||
|
// 恢复正常显示
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
|
||||||
|
// 自动更新日期选择器为最近查询的时间范围
|
||||||
|
document.getElementById('startDate').value = formatDateTime(startTime);
|
||||||
|
document.getElementById('endDate').value = formatDateTime(endTime);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('获取最新数据失败,请检查网络连接');
|
||||||
|
// 恢复正常显示
|
||||||
|
document.getElementById('mainChart').style.opacity = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载历史数据(不触发设备查询)
|
||||||
|
function loadInitialData() {
|
||||||
|
const interval = document.getElementById('interval').value;
|
||||||
|
|
||||||
|
// 计算最近时间范围
|
||||||
|
const endTime = new Date();
|
||||||
|
const startTime = new Date(endTime);
|
||||||
|
startTime.setHours(0, 0, 0, 0); // 设置为当天0点
|
||||||
|
|
||||||
|
// 确保时间格式符合后端要求
|
||||||
|
const startDateTime = startTime.toISOString();
|
||||||
|
const endDateTime = endTime.toISOString();
|
||||||
|
|
||||||
|
// 获取聚合数据
|
||||||
|
fetch(`/api/latest?interval=${interval}&start=${startDateTime}&end=${endDateTime}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.error('后端错误:', data.error);
|
||||||
|
console.log('获取历史数据失败,将尝试获取最新传感器数据');
|
||||||
|
fetchLatestSensorData(); // 至少尝试获取最新传感器数据
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查数据是否为空数组
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
console.log('没有可用的历史数据,将尝试获取最新传感器数据');
|
||||||
|
fetchLatestSensorData(); // 至少尝试获取最新传感器数据
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChart(data);
|
||||||
|
updateTable(data);
|
||||||
|
|
||||||
|
// 自动更新日期选择器为最近查询的时间范围
|
||||||
|
document.getElementById('startDate').value = formatDateTime(startTime);
|
||||||
|
document.getElementById('endDate').value = formatDateTime(endTime);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
console.log('获取历史数据失败,将尝试获取最新传感器数据');
|
||||||
|
fetchLatestSensorData(); // 至少尝试获取最新传感器数据
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新图表
|
||||||
|
function updateChart(data) {
|
||||||
|
const ctx = document.getElementById('mainChart').getContext('2d');
|
||||||
|
|
||||||
|
if (mainChart) {
|
||||||
|
mainChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查数据是否有效
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
console.log('没有可用的图表数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 按时间正序排列数据(从早到晚)
|
||||||
|
data.sort((a, b) => {
|
||||||
|
const timeA = a.timestamp ? new Date(a.timestamp) : new Date(a.formatted_time);
|
||||||
|
const timeB = b.timestamp ? new Date(b.timestamp) : new Date(b.formatted_time);
|
||||||
|
return timeA - timeB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = data.map(item => {
|
||||||
|
// 解析时间字符串为本地时间
|
||||||
|
const timeStr = item.formatted_time || item.timestamp;
|
||||||
|
const date = new Date(timeStr);
|
||||||
|
|
||||||
|
// 格式化为中文日期时间格式
|
||||||
|
return date.getFullYear() + '/' +
|
||||||
|
(date.getMonth() + 1).toString().padStart(2, '0') + '/' +
|
||||||
|
date.getDate().toString().padStart(2, '0') + ' ' +
|
||||||
|
date.getHours().toString().padStart(2, '0') + ':' +
|
||||||
|
date.getMinutes().toString().padStart(2, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 是否使用自适应范围
|
||||||
|
const useAdaptiveScale = 1;
|
||||||
|
|
||||||
|
// 计算降雨量和温度的范围
|
||||||
|
let rainfallMin = 0;
|
||||||
|
let rainfallMax = 50;
|
||||||
|
let tempMin = -10;
|
||||||
|
let tempMax = 40;
|
||||||
|
|
||||||
|
if (useAdaptiveScale && data.length > 0) {
|
||||||
|
// 找出温度的最小值和最大值,并添加一些边距
|
||||||
|
const temps = data.map(item => item.avg_temperature);
|
||||||
|
tempMin = Math.floor(Math.min(...temps) - 5);
|
||||||
|
tempMax = Math.ceil(Math.max(...temps) + 5);
|
||||||
|
|
||||||
|
// 找出降雨量的最大值,并添加一些边距
|
||||||
|
const rainfalls = data.map(item => item.rainfall);
|
||||||
|
rainfallMax = Math.ceil(Math.max(...rainfalls) * 1.2) || 10; // 如果最大值是0,则默认为10
|
||||||
|
}
|
||||||
|
|
||||||
|
mainChart = new Chart(ctx, {
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: '降雨量(mm)',
|
||||||
|
data: data.map(item => item.rainfall !== null && !isNaN(item.rainfall) ? item.rainfall : 0),
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.5)',
|
||||||
|
borderColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
yAxisID: 'y-rainfall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: '温度(℃)',
|
||||||
|
data: data.map(item => item.avg_temperature !== null && !isNaN(item.avg_temperature) ? item.avg_temperature : null),
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.5)',
|
||||||
|
tension: 0.1,
|
||||||
|
yAxisID: 'y-temp',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
'y-rainfall': {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '降雨量(mm)'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false
|
||||||
|
},
|
||||||
|
min: rainfallMin,
|
||||||
|
max: rainfallMax,
|
||||||
|
suggestedMax: Math.min(10, rainfallMax)
|
||||||
|
},
|
||||||
|
'y-temp': {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '温度(℃)'
|
||||||
|
},
|
||||||
|
min: tempMin,
|
||||||
|
max: tempMax,
|
||||||
|
suggestedMin: Math.max(0, tempMin),
|
||||||
|
suggestedMax: Math.min(30, tempMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新图表失败:', error);
|
||||||
|
alert('更新图表失败,请检查数据格式');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新表格
|
||||||
|
function updateTable(data) {
|
||||||
|
const tbody = document.getElementById('tableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
// 按时间倒序排列数据(从晚到早),这样最新的数据在表格顶部
|
||||||
|
const sortedData = [...data].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
|
||||||
|
sortedData.forEach(item => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
// 解析时间字符串为本地时间
|
||||||
|
const date = new Date(item.timestamp);
|
||||||
|
|
||||||
|
const formattedDate =
|
||||||
|
date.getFullYear() + '/' +
|
||||||
|
(date.getMonth() + 1).toString().padStart(2, '0') + '/' +
|
||||||
|
date.getDate().toString().padStart(2, '0') + ' ' +
|
||||||
|
date.getHours().toString().padStart(2, '0') + ':' +
|
||||||
|
date.getMinutes().toString().padStart(2, '0') + ':' +
|
||||||
|
date.getSeconds().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${formattedDate}</td>
|
||||||
|
<td>${item.rainfall !== null && !isNaN(item.rainfall) ? item.rainfall.toFixed(1) : '0.0'}</td>
|
||||||
|
<td>${item.avg_temperature !== null && !isNaN(item.avg_temperature) ? item.avg_temperature.toFixed(1) : '0.0'}</td>
|
||||||
|
<td>${item.avg_humidity !== null && !isNaN(item.avg_humidity) ? item.avg_humidity.toFixed(1) : '0.0'}</td>
|
||||||
|
<td>${item.avg_wind_speed !== null && !isNaN(item.avg_wind_speed) ? item.avg_wind_speed.toFixed(2) : '0.00'}</td>
|
||||||
|
<td>${item.atm_pressure !== null && !isNaN(item.atm_pressure) ? item.atm_pressure.toFixed(1) : '0.0'}</td>
|
||||||
|
<td>${item.solar_radiation !== null && !isNaN(item.solar_radiation) ? item.solar_radiation.toFixed(0) : '0'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出数据
|
||||||
|
function exportData() {
|
||||||
|
// 从表格中获取完整数据
|
||||||
|
const tableRows = document.querySelectorAll('#tableBody tr');
|
||||||
|
let csv = '时间,降雨量(mm),温度(℃),湿度(%),风速(m/s),大气压(kPa),太阳辐射(W/m²)\n';
|
||||||
|
|
||||||
|
tableRows.forEach(row => {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
const rowData = [
|
||||||
|
cells[0].textContent, // 时间
|
||||||
|
cells[1].textContent, // 降雨量
|
||||||
|
cells[2].textContent, // 温度
|
||||||
|
cells[3].textContent, // 湿度
|
||||||
|
cells[4].textContent, // 风速
|
||||||
|
cells[5].textContent, // 大气压
|
||||||
|
cells[6].textContent // 太阳辐射
|
||||||
|
];
|
||||||
|
csv += rowData.join(',') + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = '雨量监测数据.csv';
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载完成后初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initDatePickers();
|
||||||
|
loadInitialData();
|
||||||
|
fetchLatestSensorData();
|
||||||
|
|
||||||
|
// 每30秒检查一次连接状态
|
||||||
|
checkConnectionStatus();
|
||||||
|
connectionCheckTimer = setInterval(checkConnectionStatus, 30000);
|
||||||
|
|
||||||
|
// 每分钟自动刷新最新传感器数据(不触发设备查询)
|
||||||
|
setInterval(fetchLatestSensorData, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 页面卸载时清除定时器
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (connectionCheckTimer) {
|
||||||
|
clearInterval(connectionCheckTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
todo.md
Normal file
54
todo.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# 雨量监测系统开发计划
|
||||||
|
|
||||||
|
## 系统概述
|
||||||
|
开发一个监测系统,通过ModBus-RTU协议与两种设备通信,收集并展示气象和雨量数据。
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
- Web界面:监听10003端口
|
||||||
|
- TCP服务器:监听10004端口,与设备通信
|
||||||
|
- 两种客户端设备:地址码01(气象站)和02(雨量计)
|
||||||
|
|
||||||
|
## 开发任务
|
||||||
|
|
||||||
|
### 1. 数据采集模块
|
||||||
|
- [ ] 实现TCP服务器,监听10004端口
|
||||||
|
- [ ] 实现ModBus-RTU协议解析
|
||||||
|
- [ ] 设备01(气象站)数据采集,15分钟一次
|
||||||
|
- 发送指令:`010301f400100408`
|
||||||
|
- 解析返回数据(风速、风向、温湿度等)
|
||||||
|
- [ ] 设备02(雨量计)数据采集
|
||||||
|
- 发送指令:`02030000000ac5fe`
|
||||||
|
- 解析返回数据(当天降雨量、瞬时降雨量等)
|
||||||
|
- [ ] 实现数据转换(根据设备寄存器定义)
|
||||||
|
|
||||||
|
### 2. 数据存储模块
|
||||||
|
- [ ] 设计MySQL数据库表结构
|
||||||
|
- 设备01数据表
|
||||||
|
- 设备02数据表
|
||||||
|
- [ ] 实现数据持久化存储
|
||||||
|
- [ ] 实现数据查询接口
|
||||||
|
|
||||||
|
### 3. Web服务器模块
|
||||||
|
- [ ] 实现Web服务器,监听10003端口
|
||||||
|
- [ ] 设计API接口
|
||||||
|
- 获取最新数据
|
||||||
|
- 查询历史数据(支持时间范围)
|
||||||
|
- 数据聚合(按小时、天等)
|
||||||
|
- 触发设备查询
|
||||||
|
|
||||||
|
### 4. 前端界面
|
||||||
|
- [ ] 参考提供的HTML风格,实现Web界面
|
||||||
|
- [ ] 实现数据可视化(图表展示)
|
||||||
|
- [ ] 实现数据表格展示
|
||||||
|
- [ ] 实现数据导出功能
|
||||||
|
- [ ] 实现设备连接状态显示
|
||||||
|
|
||||||
|
### 5. 系统集成与测试
|
||||||
|
- [ ] 集成各模块
|
||||||
|
- [ ] 系统测试
|
||||||
|
- [ ] 性能优化
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- 后端:Go语言
|
||||||
|
- 数据库:MySQL
|
||||||
|
- 前端:HTML, CSS, JavaScript, Chart.js
|
||||||
Loading…
x
Reference in New Issue
Block a user