This commit is contained in:
fengyarnom 2025-06-27 18:09:37 +08:00
commit 94308d81a0
13 changed files with 1976 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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(
&timestampStr, &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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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