From 94308d81a0016922c9340b5f9b62df9835a8478b Mon Sep 17 00:00:00 2001 From: fengyarnom Date: Fri, 27 Jun 2025 18:09:37 +0800 Subject: [PATCH] init --- .idea/.gitignore | 8 + .idea/modules.xml | 8 + .idea/rain_monitor.iml | 9 + api/api.go | 199 +++++++++++ db/db.go | 225 +++++++++++++ go.mod | 7 + go.sum | 4 + main.go | 52 +++ modbus/modbus.go | 313 ++++++++++++++++++ models/models.go | 187 +++++++++++ scheduler/scheduler.go | 177 ++++++++++ static/index.html | 733 +++++++++++++++++++++++++++++++++++++++++ todo.md | 54 +++ 13 files changed, 1976 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/rain_monitor.iml create mode 100644 api/api.go create mode 100644 db/db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 modbus/modbus.go create mode 100644 models/models.go create mode 100644 scheduler/scheduler.go create mode 100644 static/index.html create mode 100644 todo.md diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f3d1c8a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/rain_monitor.iml b/.idea/rain_monitor.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/rain_monitor.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..fc84ff5 --- /dev/null +++ b/api/api.go @@ -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) +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..6e102e8 --- /dev/null +++ b/db/db.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..17e83a2 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcdcfa --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f04864f --- /dev/null +++ b/main.go @@ -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() // 这个函数会阻塞主线程 +} diff --git a/modbus/modbus.go b/modbus/modbus.go new file mode 100644 index 0000000..83ed459 --- /dev/null +++ b/modbus/modbus.go @@ -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 +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..983e2bb --- /dev/null +++ b/models/models.go @@ -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 +` diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go new file mode 100644 index 0000000..18245bd --- /dev/null +++ b/scheduler/scheduler.go @@ -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) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..61b90d8 --- /dev/null +++ b/static/index.html @@ -0,0 +1,733 @@ + + + + + + 雨量监测系统 + + + + +
+

雨量监测系统

+
+ 未连接 +
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ + +
+

最新传感器数据

+
+
+
温度
+
--
+
+
+
+
湿度
+
--
+
%
+
+
+
风速
+
--
+
m/s
+
+
+
风向
+
--
+
°
+
+
+
大气压
+
--
+
kPa
+
+
+
太阳辐射
+
--
+
W/m²
+
+
+
累计雨量
+
--
+
mm
+
+
+
+ +
+ +
+ +
+ + + + + + + + + + + + + +
时间降雨量(mm)温度(℃)湿度(%)风速(m/s)大气压(kPa)太阳辐射(W/m²)
+
+
+ + + + \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..dfe5760 --- /dev/null +++ b/todo.md @@ -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 \ No newline at end of file