diff --git a/gin_server.go b/gin_server.go new file mode 100644 index 0000000..3bf6462 --- /dev/null +++ b/gin_server.go @@ -0,0 +1,249 @@ +package main + +import ( + "database/sql" + "fmt" + "net/http" + "time" + + "weatherstation/config" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" +) + +var ginDB *sql.DB + +func initGinDB() error { + cfg := config.GetConfig() + + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Database.Host, cfg.Database.Port, cfg.Database.User, + cfg.Database.Password, cfg.Database.DBName, cfg.Database.SSLMode) + + var err error + ginDB, err = sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("无法连接到数据库: %v", err) + } + + err = ginDB.Ping() + if err != nil { + return fmt.Errorf("数据库连接测试失败: %v", err) + } + + return nil +} + +// 获取在线设备数量 +func getOnlineDevicesCount() int { + if ginDB == nil { + return 0 + } + + query := ` + SELECT COUNT(DISTINCT station_id) + FROM rs485_weather_data + WHERE timestamp > NOW() - INTERVAL '5 minutes'` + + var count int + err := ginDB.QueryRow(query).Scan(&count) + if err != nil { + return 0 + } + return count +} + +// 主页面处理器 +func indexHandler(c *gin.Context) { + data := PageData{ + Title: "英卓气象站", + ServerTime: time.Now().Format("2006-01-02 15:04:05"), + OnlineDevices: getOnlineDevicesCount(), + } + c.HTML(http.StatusOK, "index.html", data) +} + +// 系统状态API +func systemStatusHandler(c *gin.Context) { + status := SystemStatus{ + OnlineDevices: getOnlineDevicesCount(), + ServerTime: time.Now().Format("2006-01-02 15:04:05"), + } + c.JSON(http.StatusOK, status) +} + +// 获取WH65LP站点列表API +func getStationsHandler(c *gin.Context) { + query := ` + SELECT DISTINCT s.station_id, + COALESCE(s.password, '') as station_name, + 'WH65LP' as device_type, + COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update + FROM stations s + LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id + WHERE s.station_id LIKE 'RS485-%' + GROUP BY s.station_id, s.password + ORDER BY s.station_id` + + rows, err := ginDB.Query(query) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询站点失败"}) + return + } + defer rows.Close() + + var stations []Station + for rows.Next() { + var station Station + var lastUpdate time.Time + err := rows.Scan(&station.StationID, &station.StationName, &station.DeviceType, &lastUpdate) + if err != nil { + continue + } + station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05") + stations = append(stations, station) + } + + c.JSON(http.StatusOK, stations) +} + +// 获取站点历史数据API +func getDataHandler(c *gin.Context) { + stationID := c.Query("station_id") + startTime := c.Query("start_time") + endTime := c.Query("end_time") + interval := c.DefaultQuery("interval", "1hour") + + if stationID == "" || startTime == "" || endTime == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必要参数"}) + return + } + + var intervalSQL string + switch interval { + case "10min": + intervalSQL = "10 minutes" + case "30min": + intervalSQL = "30 minutes" + case "1hour": + intervalSQL = "1 hour" + default: + intervalSQL = "1 hour" + } + + // 构建查询SQL - 使用时间窗口聚合 + query := fmt.Sprintf(` + WITH time_series AS ( + SELECT + date_trunc('hour', timestamp) + + INTERVAL '%s' * FLOOR(EXTRACT(EPOCH FROM timestamp - date_trunc('hour', timestamp)) / EXTRACT(EPOCH FROM INTERVAL '%s')) as time_bucket, + temperature, + humidity, + pressure, + wind_speed, + wind_direction, + rainfall, + light, + uv + FROM rs485_weather_data + WHERE station_id = $1 + AND timestamp >= $2::timestamp + AND timestamp <= $3::timestamp + ), + aggregated_data AS ( + SELECT + time_bucket, + ROUND(AVG(temperature)::numeric, 2) as temperature, + ROUND(AVG(humidity)::numeric, 2) as humidity, + ROUND(AVG(pressure)::numeric, 2) as pressure, + ROUND(AVG(wind_speed)::numeric, 2) as wind_speed, + -- 风向使用矢量平均 + ROUND(DEGREES(ATAN2( + AVG(SIN(RADIANS(wind_direction))), + AVG(COS(RADIANS(wind_direction))) + ))::numeric + CASE + WHEN DEGREES(ATAN2( + AVG(SIN(RADIANS(wind_direction))), + AVG(COS(RADIANS(wind_direction))) + )) < 0 THEN 360 + ELSE 0 + END, 2) as wind_direction, + -- 雨量使用差值计算 + ROUND((MAX(rainfall) - MIN(rainfall))::numeric, 3) as rainfall_diff, + ROUND(AVG(light)::numeric, 2) as light, + ROUND(AVG(uv)::numeric, 2) as uv + FROM time_series + GROUP BY time_bucket + ) + SELECT + time_bucket, + COALESCE(temperature, 0) as temperature, + COALESCE(humidity, 0) as humidity, + COALESCE(pressure, 0) as pressure, + COALESCE(wind_speed, 0) as wind_speed, + COALESCE(wind_direction, 0) as wind_direction, + COALESCE(rainfall_diff, 0) as rainfall, + COALESCE(light, 0) as light, + COALESCE(uv, 0) as uv + FROM aggregated_data + ORDER BY time_bucket`, intervalSQL, intervalSQL) + + rows, err := ginDB.Query(query, stationID, startTime, endTime) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询数据失败"}) + return + } + defer rows.Close() + + var data []WeatherPoint + for rows.Next() { + var point WeatherPoint + var timestamp time.Time + err := rows.Scan(×tamp, &point.Temperature, &point.Humidity, + &point.Pressure, &point.WindSpeed, &point.WindDir, + &point.Rainfall, &point.Light, &point.UV) + if err != nil { + continue + } + point.DateTime = timestamp.Format("2006-01-02 15:04:05") + data = append(data, point) + } + + c.JSON(http.StatusOK, data) +} + +func StartGinServer() { + err := initGinDB() + if err != nil { + fmt.Printf("初始化Gin数据库连接失败: %v\n", err) + return + } + + // 设置Gin模式 + gin.SetMode(gin.ReleaseMode) + + // 创建Gin引擎 + r := gin.Default() + + // 加载HTML模板 + r.LoadHTMLGlob("templates/*") + + // 静态文件服务 + r.Static("/static", "./static") + + // 路由设置 + r.GET("/", indexHandler) + + // API路由组 + api := r.Group("/api") + { + api.GET("/system/status", systemStatusHandler) + api.GET("/stations", getStationsHandler) + api.GET("/data", getDataHandler) + } + + // 启动服务器 + fmt.Println("Gin Web服务器启动,监听端口 10003...") + r.Run(":10003") +} diff --git a/go.mod b/go.mod index 92b19d1..76bfe67 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,32 @@ require ( github.com/lib/pq v1.10.9 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/go.sum b/go.sum index c95a75b..a202db1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,82 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go index 64273a7..bb69db0 100644 --- a/main.go +++ b/main.go @@ -1,227 +1,16 @@ package main import ( - "bufio" "encoding/hex" "fmt" - "io" "log" - "net" "os" - "path/filepath" "strings" - "sync" "time" - "unicode/utf8" - "weatherstation/config" "weatherstation/model" ) -type UTF8Writer struct { - w io.Writer -} - -func NewUTF8Writer(w io.Writer) *UTF8Writer { - return &UTF8Writer{w: w} -} - -func (w *UTF8Writer) Write(p []byte) (n int, err error) { - if utf8.Valid(p) { - return w.w.Write(p) - } - s := string(p) - s = strings.ToValidUTF8(s, "") - return w.w.Write([]byte(s)) -} - -var ( - logFile *os.File - logFileMutex sync.Mutex - currentLogDay int -) - -func getLogFileName() string { - currentTime := time.Now() - return filepath.Join("log", fmt.Sprintf("%s.log", currentTime.Format("2006-01-02"))) -} - -func openLogFile() (*os.File, error) { - logDir := "log" - if _, err := os.Stat(logDir); os.IsNotExist(err) { - os.MkdirAll(logDir, 0755) - } - - logFileName := getLogFileName() - return os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) -} - -func setupLogger() { - var err error - logFile, err = openLogFile() - if err != nil { - log.Fatalf("无法创建日志文件: %v", err) - } - - currentLogDay = time.Now().Day() - - bufferedWriter := bufio.NewWriter(logFile) - utf8Writer := NewUTF8Writer(bufferedWriter) - - go func() { - for { - time.Sleep(1 * time.Second) - - logFileMutex.Lock() - bufferedWriter.Flush() - - now := time.Now() - if now.Day() != currentLogDay { - oldLogFile := logFile - logFile, err = openLogFile() - if err != nil { - log.Printf("无法创建新日志文件: %v", err) - } else { - oldLogFile.Close() - currentLogDay = now.Day() - bufferedWriter = bufio.NewWriter(logFile) - utf8Writer = NewUTF8Writer(bufferedWriter) - log.SetOutput(io.MultiWriter(os.Stdout, utf8Writer)) - log.Println("日志文件已轮转") - } - } - logFileMutex.Unlock() - } - }() - - multiWriter := io.MultiWriter(os.Stdout, utf8Writer) - log.SetOutput(multiWriter) - log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) -} - -func startUDP() { - cfg := config.GetConfig() - err := model.InitDB() - if err != nil { - log.Fatalf("初始化数据库失败: %v", err) - } - defer model.CloseDB() - addr := fmt.Sprintf(":%d", cfg.Server.UDPPort) - conn, err := net.ListenPacket("udp", addr) - if err != nil { - log.Fatalf("无法监听UDP端口 %d: %v", cfg.Server.UDPPort, err) - } - defer conn.Close() - log.Printf("UDP服务器已启动,监听端口 %d...", cfg.Server.UDPPort) - buffer := make([]byte, 2048) - for { - n, addr, err := conn.ReadFrom(buffer) - if err != nil { - log.Printf("读取数据错误: %v", err) - continue - } - rawData := buffer[:n] - log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n) - - hexDump := hexDump(rawData) - log.Printf("原始码流(十六进制):\n%s", hexDump) - asciiDump := asciiDump(rawData) - log.Printf("ASCII码:\n%s", asciiDump) - - if len(rawData) == 25 && rawData[0] == 0x24 { - log.Println("485 型气象站数据") - - // 生成源码字符串(用于日志记录) - sourceHex := strings.ReplaceAll(strings.TrimSpace(hexDump), "\n", " ") - log.Printf("源码: %s", sourceHex) - - // 解析RS485数据 - protocol := model.NewProtocol(rawData) - rs485Protocol := model.NewRS485Protocol(rawData) - - // 获取设备ID - idParts, err := protocol.GetCompleteID() - if err != nil { - log.Printf("获取设备ID失败: %v", err) - continue - } - - // 解析RS485数据 - rs485Data, err := rs485Protocol.ParseRS485Data() - if err != nil { - log.Printf("解析RS485数据失败: %v", err) - continue - } - - // 添加设备ID和时间戳 - rs485Data.DeviceID = idParts.Complete.Hex - rs485Data.ReceivedAt = time.Now() - rs485Data.RawDataHex = sourceHex - - // 打印解析结果到日志 - log.Println("=== RS485 ===") - log.Printf("设备ID: RS485-%s", rs485Data.DeviceID) - log.Printf("温度: %.2f°C", rs485Data.Temperature) - log.Printf("湿度: %.1f%%", rs485Data.Humidity) - log.Printf("风速: %.5f m/s", rs485Data.WindSpeed) - log.Printf("风向: %.1f°", rs485Data.WindDirection) - log.Printf("降雨量: %.3f mm", rs485Data.Rainfall) - log.Printf("光照: %.1f lux", rs485Data.Light) - log.Printf("紫外线: %.1f", rs485Data.UV) - log.Printf("气压: %.2f hPa", rs485Data.Pressure) - log.Printf("接收时间: %s", rs485Data.ReceivedAt.Format("2006-01-02 15:04:05")) - - // 注册设备 - stationID := fmt.Sprintf("RS485-%s", rs485Data.DeviceID) - model.RegisterDevice(stationID, addr) - log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String()) - - // 保存到数据库 - err = model.SaveWeatherData(rs485Data, string(rawData)) - if err != nil { - log.Printf("保存数据到数据库失败: %v", err) - } else { - log.Printf("数据已成功保存到数据库") - } - - } else { - // 尝试解析WIFI数据 - data, deviceType, err := model.ParseData(rawData) - if err != nil { - log.Printf("解析数据失败: %v", err) - continue - } - - log.Println("成功解析气象站数据:") - log.Printf("设备类型: %s", getDeviceTypeString(deviceType)) - log.Println(data) - - if deviceType == model.DeviceTypeWIFI { - if wifiData, ok := data.(*model.WeatherData); ok { - stationID := wifiData.StationID - if stationID != "" { - model.RegisterDevice(stationID, addr) - log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String()) - } else { - log.Printf("警告: 收到的数据没有站点ID") - } - } - } - } - } -} - -func getDeviceTypeString(deviceType model.DeviceType) string { - switch deviceType { - case model.DeviceTypeWIFI: - return "WIFI" - case model.DeviceTypeRS485: - return "RS485" - default: - return "未知" - } -} - func main() { // 检查是否有命令行参数 if len(os.Args) > 1 && os.Args[1] == "parse" { @@ -234,52 +23,13 @@ func main() { fmt.Println("示例: ./weatherstation parse \"24 F2 10 02 C7 48 10 03 00 6A 03 E8 05 F5 96 10 3F 01 83 2D B1 00 29 9B A4\"") } } else { - // 正常启动服务器 - setupLogger() - startUDP() + fmt.Println("请使用新的启动程序:") + fmt.Println(" ./weatherstation_launcher # 同时启动UDP和Web服务器") + fmt.Println(" ./weatherstation_launcher -web # 只启动Web服务器") + fmt.Println(" ./weatherstation_launcher -udp # 只启动UDP服务器") } } -func hexDump(data []byte) string { - var result strings.Builder - for i := 0; i < len(data); i += 16 { - end := i + 16 - if end > len(data) { - end = len(data) - } - chunk := data[i:end] - hexStr := hex.EncodeToString(chunk) - for j := 0; j < len(hexStr); j += 2 { - if j+2 <= len(hexStr) { - result.WriteString(strings.ToUpper(hexStr[j : j+2])) - result.WriteString(" ") - } - } - result.WriteString("\n") - } - return result.String() -} - -func asciiDump(data []byte) string { - var result strings.Builder - for i := 0; i < len(data); i += 64 { - end := i + 64 - if end > len(data) { - end = len(data) - } - chunk := data[i:end] - for _, b := range chunk { - if b >= 32 && b <= 126 { - result.WriteByte(b) - } else { - result.WriteString(".") - } - } - result.WriteString("\n") - } - return result.String() -} - // parseHexData 解析十六进制字符串数据 func parseHexData(hexStr string) { // 移除所有空格 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7353f57 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,543 @@ + + + + + + {{.Title}} + + + + +
+

{{.Title}}

+
+ +
+ +
+ 系统信息: 服务器时间: {{.ServerTime}} | 在线设备: {{.OnlineDevices}} 个 +
+ + +
+

请选择监测站点:

+
正在加载站点信息...
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + +
时间温度 (°C)湿度 (%)气压 (hPa)风速 (m/s)风向 (°)雨量 (mm)光照 (lux)紫外线
+
+
+ + + + \ No newline at end of file diff --git a/types.go b/types.go new file mode 100644 index 0000000..d8bc30a --- /dev/null +++ b/types.go @@ -0,0 +1,35 @@ +package main + +// Station 站点信息 +type Station struct { + StationID string `json:"station_id"` + StationName string `json:"station_name"` + DeviceType string `json:"device_type"` + LastUpdate string `json:"last_update"` +} + +// WeatherPoint 气象数据点 +type WeatherPoint struct { + DateTime string `json:"date_time"` + Temperature float64 `json:"temperature"` + Humidity float64 `json:"humidity"` + Pressure float64 `json:"pressure"` + WindSpeed float64 `json:"wind_speed"` + WindDir float64 `json:"wind_direction"` + Rainfall float64 `json:"rainfall"` + Light float64 `json:"light"` + UV float64 `json:"uv"` +} + +// PageData 页面数据结构 +type PageData struct { + Title string + ServerTime string + OnlineDevices int +} + +// SystemStatus 系统状态结构 +type SystemStatus struct { + OnlineDevices int `json:"online_devices"` + ServerTime string `json:"server_time"` +} diff --git a/udp_server.go b/udp_server.go new file mode 100644 index 0000000..fd72c2a --- /dev/null +++ b/udp_server.go @@ -0,0 +1,263 @@ +package main + +import ( + "bufio" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unicode/utf8" + + "weatherstation/config" + "weatherstation/model" +) + +type UTF8Writer struct { + w io.Writer +} + +func NewUTF8Writer(w io.Writer) *UTF8Writer { + return &UTF8Writer{w: w} +} + +func (w *UTF8Writer) Write(p []byte) (n int, err error) { + if utf8.Valid(p) { + return w.w.Write(p) + } + s := string(p) + s = strings.ToValidUTF8(s, "") + return w.w.Write([]byte(s)) +} + +var ( + logFile *os.File + logFileMutex sync.Mutex + currentLogDay int +) + +func getLogFileName() string { + currentTime := time.Now() + return filepath.Join("log", fmt.Sprintf("%s.log", currentTime.Format("2006-01-02"))) +} + +func openLogFile() (*os.File, error) { + logDir := "log" + if _, err := os.Stat(logDir); os.IsNotExist(err) { + os.MkdirAll(logDir, 0755) + } + + logFileName := getLogFileName() + return os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) +} + +func setupLogger() { + var err error + logFile, err = openLogFile() + if err != nil { + log.Fatalf("无法创建日志文件: %v", err) + } + + currentLogDay = time.Now().Day() + + bufferedWriter := bufio.NewWriter(logFile) + utf8Writer := NewUTF8Writer(bufferedWriter) + + go func() { + for { + time.Sleep(1 * time.Second) + + logFileMutex.Lock() + bufferedWriter.Flush() + + now := time.Now() + if now.Day() != currentLogDay { + oldLogFile := logFile + logFile, err = openLogFile() + if err != nil { + log.Printf("无法创建新日志文件: %v", err) + } else { + oldLogFile.Close() + currentLogDay = now.Day() + bufferedWriter = bufio.NewWriter(logFile) + utf8Writer = NewUTF8Writer(bufferedWriter) + log.SetOutput(io.MultiWriter(os.Stdout, utf8Writer)) + log.Println("日志文件已轮转") + } + } + logFileMutex.Unlock() + } + }() + + multiWriter := io.MultiWriter(os.Stdout, utf8Writer) + log.SetOutput(multiWriter) + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) +} + +func startUDP() { + cfg := config.GetConfig() + err := model.InitDB() + if err != nil { + log.Fatalf("初始化数据库失败: %v", err) + } + defer model.CloseDB() + addr := fmt.Sprintf(":%d", cfg.Server.UDPPort) + conn, err := net.ListenPacket("udp", addr) + if err != nil { + log.Fatalf("无法监听UDP端口 %d: %v", cfg.Server.UDPPort, err) + } + defer conn.Close() + log.Printf("UDP服务器已启动,监听端口 %d...", cfg.Server.UDPPort) + buffer := make([]byte, 2048) + for { + n, addr, err := conn.ReadFrom(buffer) + if err != nil { + log.Printf("读取数据错误: %v", err) + continue + } + rawData := buffer[:n] + log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n) + + hexDump := hexDump(rawData) + log.Printf("原始码流(十六进制):\n%s", hexDump) + asciiDump := asciiDump(rawData) + log.Printf("ASCII码:\n%s", asciiDump) + + if len(rawData) == 25 && rawData[0] == 0x24 { + log.Println("485 型气象站数据") + + // 生成源码字符串(用于日志记录) + sourceHex := strings.ReplaceAll(strings.TrimSpace(hexDump), "\n", " ") + log.Printf("源码: %s", sourceHex) + + // 解析RS485数据 + protocol := model.NewProtocol(rawData) + rs485Protocol := model.NewRS485Protocol(rawData) + + // 获取设备ID + idParts, err := protocol.GetCompleteID() + if err != nil { + log.Printf("获取设备ID失败: %v", err) + continue + } + + // 解析RS485数据 + rs485Data, err := rs485Protocol.ParseRS485Data() + if err != nil { + log.Printf("解析RS485数据失败: %v", err) + continue + } + + // 添加设备ID和时间戳 + rs485Data.DeviceID = idParts.Complete.Hex + rs485Data.ReceivedAt = time.Now() + rs485Data.RawDataHex = sourceHex + + // 打印解析结果到日志 + log.Println("=== RS485 ===") + log.Printf("设备ID: RS485-%s", rs485Data.DeviceID) + log.Printf("温度: %.2f°C", rs485Data.Temperature) + log.Printf("湿度: %.1f%%", rs485Data.Humidity) + log.Printf("风速: %.5f m/s", rs485Data.WindSpeed) + log.Printf("风向: %.1f°", rs485Data.WindDirection) + log.Printf("降雨量: %.3f mm", rs485Data.Rainfall) + log.Printf("光照: %.1f lux", rs485Data.Light) + log.Printf("紫外线: %.1f", rs485Data.UV) + log.Printf("气压: %.2f hPa", rs485Data.Pressure) + log.Printf("接收时间: %s", rs485Data.ReceivedAt.Format("2006-01-02 15:04:05")) + + // 注册设备 + stationID := fmt.Sprintf("RS485-%s", rs485Data.DeviceID) + model.RegisterDevice(stationID, addr) + log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String()) + + // 保存到数据库 + err = model.SaveWeatherData(rs485Data, string(rawData)) + if err != nil { + log.Printf("保存数据到数据库失败: %v", err) + } else { + log.Printf("数据已成功保存到数据库") + } + + } else { + // 尝试解析WIFI数据 + data, deviceType, err := model.ParseData(rawData) + if err != nil { + log.Printf("解析数据失败: %v", err) + continue + } + + log.Println("成功解析气象站数据:") + log.Printf("设备类型: %s", getDeviceTypeString(deviceType)) + log.Println(data) + + if deviceType == model.DeviceTypeWIFI { + if wifiData, ok := data.(*model.WeatherData); ok { + stationID := wifiData.StationID + if stationID != "" { + model.RegisterDevice(stationID, addr) + log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String()) + } else { + log.Printf("警告: 收到的数据没有站点ID") + } + } + } + } + } +} + +func getDeviceTypeString(deviceType model.DeviceType) string { + switch deviceType { + case model.DeviceTypeWIFI: + return "WIFI" + case model.DeviceTypeRS485: + return "RS485" + default: + return "未知" + } +} + +func hexDump(data []byte) string { + var result strings.Builder + for i := 0; i < len(data); i += 16 { + end := i + 16 + if end > len(data) { + end = len(data) + } + chunk := data[i:end] + hexStr := hex.EncodeToString(chunk) + for j := 0; j < len(hexStr); j += 2 { + if j+2 <= len(hexStr) { + result.WriteString(strings.ToUpper(hexStr[j : j+2])) + result.WriteString(" ") + } + } + result.WriteString("\n") + } + return result.String() +} + +func asciiDump(data []byte) string { + var result strings.Builder + for i := 0; i < len(data); i += 64 { + end := i + 64 + if end > len(data) { + end = len(data) + } + chunk := data[i:end] + for _, b := range chunk { + if b >= 32 && b <= 126 { + result.WriteByte(b) + } else { + result.WriteString(".") + } + } + result.WriteString("\n") + } + return result.String() +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7c1e733 --- /dev/null +++ b/web/index.html @@ -0,0 +1,510 @@ + + + + + + 英卓气象站 + + + + +
+

英卓气象站 - WH65LP设备监控

+
+ +
+ +
+

请选择监测站点:

+
正在加载站点信息...
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + +
时间温度 (°C)湿度 (%)气压 (hPa)风速 (m/s)风向 (°)雨量 (mm)光照 (lux)紫外线
+
+
+ + + + \ No newline at end of file diff --git a/web_server.go b/web_server.go new file mode 100644 index 0000000..7d8e52c --- /dev/null +++ b/web_server.go @@ -0,0 +1,240 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "weatherstation/config" + + _ "github.com/lib/pq" +) + +var db *sql.DB + +func initWebDB() error { + cfg := config.GetConfig() + + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Database.Host, cfg.Database.Port, cfg.Database.User, + cfg.Database.Password, cfg.Database.DBName, cfg.Database.SSLMode) + + var err error + db, err = sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("无法连接到数据库: %v", err) + } + + err = db.Ping() + if err != nil { + return fmt.Errorf("数据库连接测试失败: %v", err) + } + + return nil +} + +// 获取WH65LP站点列表 +func getWH65LPStations(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + query := ` + SELECT DISTINCT s.station_id, + COALESCE(s.password, '') as station_name, + 'WH65LP' as device_type, + COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update + FROM stations s + LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id + WHERE s.station_id LIKE 'RS485-%' + GROUP BY s.station_id, s.password + ORDER BY s.station_id` + + rows, err := db.Query(query) + if err != nil { + http.Error(w, "查询站点失败", http.StatusInternalServerError) + log.Printf("查询站点失败: %v", err) + return + } + defer rows.Close() + + var stations []Station + for rows.Next() { + var station Station + var lastUpdate time.Time + err := rows.Scan(&station.StationID, &station.StationName, &station.DeviceType, &lastUpdate) + if err != nil { + log.Printf("扫描站点数据失败: %v", err) + continue + } + station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05") + stations = append(stations, station) + } + + json.NewEncoder(w).Encode(stations) +} + +// 获取站点历史数据 +func getStationData(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + stationID := r.URL.Query().Get("station_id") + startTime := r.URL.Query().Get("start_time") + endTime := r.URL.Query().Get("end_time") + interval := r.URL.Query().Get("interval") + + if stationID == "" || startTime == "" || endTime == "" { + http.Error(w, "缺少必要参数", http.StatusBadRequest) + return + } + + // 默认间隔为1小时 + if interval == "" { + interval = "1hour" + } + + var query string + var intervalSQL string + + switch interval { + case "10min": + intervalSQL = "10 minutes" + case "30min": + intervalSQL = "30 minutes" + case "1hour": + intervalSQL = "1 hour" + default: + intervalSQL = "1 hour" + } + + // 构建查询SQL - 使用时间窗口聚合 + query = fmt.Sprintf(` + WITH time_series AS ( + SELECT + date_trunc('hour', timestamp) + + INTERVAL '%s' * FLOOR(EXTRACT(EPOCH FROM timestamp - date_trunc('hour', timestamp)) / EXTRACT(EPOCH FROM INTERVAL '%s')) as time_bucket, + temperature, + humidity, + pressure, + wind_speed, + wind_direction, + rainfall, + light, + uv + FROM rs485_weather_data + WHERE station_id = $1 + AND timestamp >= $2::timestamp + AND timestamp <= $3::timestamp + ), + aggregated_data AS ( + SELECT + time_bucket, + ROUND(AVG(temperature)::numeric, 2) as temperature, + ROUND(AVG(humidity)::numeric, 2) as humidity, + ROUND(AVG(pressure)::numeric, 2) as pressure, + ROUND(AVG(wind_speed)::numeric, 2) as wind_speed, + -- 风向使用矢量平均 + ROUND(DEGREES(ATAN2( + AVG(SIN(RADIANS(wind_direction))), + AVG(COS(RADIANS(wind_direction))) + ))::numeric + CASE + WHEN DEGREES(ATAN2( + AVG(SIN(RADIANS(wind_direction))), + AVG(COS(RADIANS(wind_direction))) + )) < 0 THEN 360 + ELSE 0 + END, 2) as wind_direction, + -- 雨量使用差值计算 + ROUND((MAX(rainfall) - MIN(rainfall))::numeric, 3) as rainfall_diff, + ROUND(AVG(light)::numeric, 2) as light, + ROUND(AVG(uv)::numeric, 2) as uv + FROM time_series + GROUP BY time_bucket + ) + SELECT + time_bucket, + COALESCE(temperature, 0) as temperature, + COALESCE(humidity, 0) as humidity, + COALESCE(pressure, 0) as pressure, + COALESCE(wind_speed, 0) as wind_speed, + COALESCE(wind_direction, 0) as wind_direction, + COALESCE(rainfall_diff, 0) as rainfall, + COALESCE(light, 0) as light, + COALESCE(uv, 0) as uv + FROM aggregated_data + ORDER BY time_bucket`, intervalSQL, intervalSQL) + + rows, err := db.Query(query, stationID, startTime, endTime) + if err != nil { + http.Error(w, "查询数据失败", http.StatusInternalServerError) + log.Printf("查询数据失败: %v", err) + return + } + defer rows.Close() + + var data []WeatherPoint + for rows.Next() { + var point WeatherPoint + var timestamp time.Time + err := rows.Scan(×tamp, &point.Temperature, &point.Humidity, + &point.Pressure, &point.WindSpeed, &point.WindDir, + &point.Rainfall, &point.Light, &point.UV) + if err != nil { + log.Printf("扫描数据失败: %v", err) + continue + } + point.DateTime = timestamp.Format("2006-01-02 15:04:05") + data = append(data, point) + } + + json.NewEncoder(w).Encode(data) +} + +// 提供静态文件服务 +func serveStaticFiles() { + // 获取当前工作目录 + workDir := "/home/yarnom/Archive/code/WeatherStation" + webDir := filepath.Join(workDir, "web") + + // 创建文件服务器 + fs := http.FileServer(http.Dir(webDir)) + + // 处理根路径请求 + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + return + } + + // 检查文件是否存在 + if _, err := http.Dir(webDir).Open(strings.TrimPrefix(r.URL.Path, "/")); err != nil { + http.NotFound(w, r) + return + } + + // 提供静态文件 + fs.ServeHTTP(w, r) + }) +} + +func StartWebServer() { + err := initWebDB() + if err != nil { + log.Fatalf("初始化Web数据库连接失败: %v", err) + } + + // API路由 + http.HandleFunc("/api/stations", getWH65LPStations) + http.HandleFunc("/api/data", getStationData) + + // 静态文件服务 + serveStaticFiles() + + log.Println("Web服务器启动,监听端口 10003...") + log.Fatal(http.ListenAndServe(":10003", nil)) +}