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}}
+
+
+
+
+
+
+
+
+
+ 系统信息: 服务器时间: {{.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 @@
+
+
+
+
+
+ 英卓气象站
+
+
+
+
+
+
+
+
+
+
请选择监测站点:
+
正在加载站点信息...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 时间 |
+ 温度 (°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))
+}