Compare commits

..

2 Commits

Author SHA1 Message Date
f2deb5512f feat: 新增前端页面 2025-08-07 20:12:32 +08:00
26c13351d7 feat: 新增前端页面 2025-08-07 20:12:28 +08:00
10 changed files with 2003 additions and 254 deletions

249
gin_server.go Normal file
View File

@ -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(&timestamp, &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")
}

29
go.mod
View File

@ -8,3 +8,32 @@ require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
gopkg.in/yaml.v3 v3.0.1 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
)

76
go.sum
View File

@ -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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=

54
launcher.go Normal file
View File

@ -0,0 +1,54 @@
package main
import (
"flag"
"log"
"sync"
)
func main() {
var webOnly = flag.Bool("web", false, "只启动Web服务器原生http")
var ginOnly = flag.Bool("gin", false, "只启动Gin Web服务器")
var udpOnly = flag.Bool("udp", false, "只启动UDP服务器")
flag.Parse()
// 设置日志
setupLogger()
var wg sync.WaitGroup
if *webOnly {
// 只启动原生Web服务器
log.Println("启动原生Web服务器模式...")
StartWebServer()
} else if *ginOnly {
// 只启动Gin Web服务器
log.Println("启动Gin Web服务器模式...")
StartGinServer()
} else if *udpOnly {
// 只启动UDP服务器
log.Println("启动UDP服务器模式...")
startUDP()
} else {
// 同时启动UDP和Gin Web服务器
log.Println("启动完整模式UDP + Gin Web服务器...")
wg.Add(2)
// 启动UDP服务器
go func() {
defer wg.Done()
log.Println("正在启动UDP服务器...")
startUDP()
}()
// 启动Gin Web服务器
go func() {
defer wg.Done()
log.Println("正在启动Gin Web服务器...")
StartGinServer()
}()
wg.Wait()
}
}

258
main.go
View File

@ -1,227 +1,16 @@
package main package main
import ( import (
"bufio"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"log" "log"
"net"
"os" "os"
"path/filepath"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8"
"weatherstation/config"
"weatherstation/model" "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() { func main() {
// 检查是否有命令行参数 // 检查是否有命令行参数
if len(os.Args) > 1 && os.Args[1] == "parse" { 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\"") 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 { } else {
// 正常启动服务器 fmt.Println("请使用新的启动程序:")
setupLogger() fmt.Println(" ./weatherstation_launcher # 同时启动UDP和Web服务器")
startUDP() 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 解析十六进制字符串数据 // parseHexData 解析十六进制字符串数据
func parseHexData(hexStr string) { func parseHexData(hexStr string) {
// 移除所有空格 // 移除所有空格

543
templates/index.html Normal file
View File

@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.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;
}
.station-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f9fa;
}
.station-card {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
cursor: pointer;
transition: all 0.3s;
min-width: 150px;
}
.station-card:hover {
background-color: #e9ecef;
}
.station-card.selected {
background-color: #007bff;
color: white;
}
.station-name {
font-weight: bold;
font-size: 14px;
}
.station-id {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.station-card.selected .station-id {
color: #cce7ff;
}
.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;
}
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;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.chart-container {
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #fff;
display: none;
}
.chart-container.show {
display: block;
}
.chart-wrapper {
height: 500px;
margin-bottom: 30px;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
display: none;
}
.table-container.show {
display: block;
}
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;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
}
.system-info {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
font-size: 14px;
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
}
.control-group {
width: 100%;
}
select, input {
width: 100%;
}
}
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<div class="container">
<!-- 系统信息 -->
<div class="system-info">
<strong>系统信息:</strong> 服务器时间: {{.ServerTime}} | 在线设备: <span id="onlineDevices">{{.OnlineDevices}}</span>
</div>
<!-- 站点选择 -->
<div class="station-selection">
<h3 style="width: 100%; margin: 0 0 10px 0;">请选择监测站点:</h3>
<div id="stationList" class="loading">正在加载站点信息...</div>
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="control-group">
<label for="interval">数据粒度:</label>
<select id="interval">
<option value="10min">10分钟</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="queryHistoryData()" id="queryBtn" disabled>查询历史数据</button>
<button onclick="refreshStations()" id="refreshBtn">刷新站点</button>
</div>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div class="chart-wrapper">
<canvas id="combinedChart"></canvas>
</div>
</div>
<!-- 数据表格 -->
<div class="table-container" id="tableContainer">
<table>
<thead>
<tr>
<th>时间</th>
<th>温度 (°C)</th>
<th>湿度 (%)</th>
<th>气压 (hPa)</th>
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- 数据行将动态填充 -->
</tbody>
</table>
</div>
</div>
<script>
let selectedStation = null;
let combinedChart = null;
// 初始化页面
document.addEventListener('DOMContentLoaded', function() {
loadStations();
initializeDateInputs();
// 每30秒刷新在线设备数量
setInterval(updateOnlineDevices, 30000);
});
// 初始化日期输入
function initializeDateInputs() {
const now = new Date();
const endDate = new Date(now);
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
}
function formatDatetimeLocal(date) {
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
// 更新在线设备数量
async function updateOnlineDevices() {
try {
const response = await fetch('/api/system/status');
const data = await response.json();
document.getElementById('onlineDevices').textContent = data.online_devices;
} catch (error) {
console.error('更新在线设备数量失败:', error);
}
}
// 刷新站点列表
function refreshStations() {
loadStations();
}
// 加载站点列表
async function loadStations() {
try {
const response = await fetch('/api/stations');
const stations = await response.json();
const stationList = document.getElementById('stationList');
stationList.innerHTML = '';
if (stations.length === 0) {
stationList.innerHTML = '<div class="error">暂无WH65LP设备站点</div>';
return;
}
stations.forEach(station => {
const stationCard = document.createElement('div');
stationCard.className = 'station-card';
stationCard.onclick = () => selectStation(station, stationCard);
stationCard.innerHTML = `
<div class="station-name">${station.station_name || station.station_id}</div>
<div class="station-id">${station.station_id}</div>
<div class="station-id">最后更新: ${station.last_update}</div>
`;
stationList.appendChild(stationCard);
});
} catch (error) {
console.error('加载站点失败:', error);
document.getElementById('stationList').innerHTML = '<div class="error">加载站点失败</div>';
}
}
// 选择站点
function selectStation(station, cardElement) {
// 清除之前的选择
document.querySelectorAll('.station-card').forEach(card => {
card.classList.remove('selected');
});
// 选择当前站点
cardElement.classList.add('selected');
selectedStation = station;
// 启用按钮
document.getElementById('queryBtn').disabled = false;
// 隐藏之前的数据
hideDataContainers();
}
// 隐藏数据容器
function hideDataContainers() {
document.getElementById('chartContainer').classList.remove('show');
document.getElementById('tableContainer').classList.remove('show');
}
// 查询历史数据
async function queryHistoryData() {
if (!selectedStation) {
alert('请先选择一个站点');
return;
}
const startTime = document.getElementById('startDate').value;
const endTime = document.getElementById('endDate').value;
const interval = document.getElementById('interval').value;
if (!startTime || !endTime) {
alert('请选择开始和结束时间');
return;
}
try {
const params = new URLSearchParams({
station_id: selectedStation.station_id,
start_time: startTime.replace('T', ' ') + ':00',
end_time: endTime.replace('T', ' ') + ':00',
interval: interval
});
const response = await fetch(`/api/data?${params}`);
const data = await response.json();
if (data.length === 0) {
alert('该时间段内无数据');
return;
}
displayChart(data);
displayTable(data);
// 显示图表和表格
document.getElementById('chartContainer').classList.add('show');
document.getElementById('tableContainer').classList.add('show');
} catch (error) {
console.error('查询历史数据失败:', error);
alert('查询历史数据失败');
}
}
// 显示图表
function displayChart(data) {
const labels = data.map(item => item.date_time);
const temperatures = data.map(item => item.temperature);
const humidities = data.map(item => item.humidity);
const rainfalls = data.map(item => item.rainfall);
// 销毁旧图表
if (combinedChart) combinedChart.destroy();
// 创建组合图表
const ctx = document.getElementById('combinedChart').getContext('2d');
combinedChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '温度 (°C)',
data: temperatures,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
yAxisID: 'y-temperature',
tension: 0.4
},
{
label: '湿度 (%)',
data: humidities,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
yAxisID: 'y-humidity',
tension: 0.4,
hidden: true // 默认隐藏湿度数据
},
{
label: '雨量 (mm)',
data: rainfalls,
type: 'bar',
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgb(54, 162, 235)',
yAxisID: 'y-rainfall'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
'y-temperature': {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: '温度 (°C)'
}
},
'y-humidity': {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '湿度 (%)'
},
grid: {
drawOnChartArea: false
},
min: 0,
max: 100
},
'y-rainfall': {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '雨量 (mm)'
},
grid: {
drawOnChartArea: false
},
beginAtZero: true
}
}
}
});
}
// 显示数据表格
function displayTable(data) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
data.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.date_time}</td>
<td>${item.temperature.toFixed(2)}</td>
<td>${item.humidity.toFixed(2)}</td>
<td>${item.pressure.toFixed(2)}</td>
<td>${item.wind_speed.toFixed(2)}</td>
<td>${item.wind_direction.toFixed(2)}</td>
<td>${item.rainfall.toFixed(3)}</td>
<td>${item.light.toFixed(2)}</td>
<td>${item.uv.toFixed(2)}</td>
`;
tbody.appendChild(row);
});
}
</script>
</body>
</html>

35
types.go Normal file
View File

@ -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"`
}

263
udp_server.go Normal file
View File

@ -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()
}

510
web/index.html Normal file
View File

@ -0,0 +1,510 @@
<!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;
}
.station-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f9fa;
}
.station-card {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
cursor: pointer;
transition: all 0.3s;
min-width: 150px;
}
.station-card:hover {
background-color: #e9ecef;
}
.station-card.selected {
background-color: #007bff;
color: white;
}
.station-name {
font-weight: bold;
font-size: 14px;
}
.station-id {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.station-card.selected .station-id {
color: #cce7ff;
}
.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;
}
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;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.chart-container {
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #fff;
display: none;
}
.chart-container.show {
display: block;
}
.chart-wrapper {
height: 500px;
margin-bottom: 30px;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
display: none;
}
.table-container.show {
display: block;
}
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;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
}
.control-group {
width: 100%;
}
select, input {
width: 100%;
}
}
</style>
</head>
<body>
<div class="header">
<h1>英卓气象站 - WH65LP设备监控</h1>
</div>
<div class="container">
<!-- 站点选择 -->
<div class="station-selection">
<h3 style="width: 100%; margin: 0 0 10px 0;">请选择监测站点:</h3>
<div id="stationList" class="loading">正在加载站点信息...</div>
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="control-group">
<label for="interval">数据粒度:</label>
<select id="interval">
<option value="10min">10分钟</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="queryHistoryData()" id="queryBtn" disabled>查询历史数据</button>
</div>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div class="chart-wrapper">
<canvas id="combinedChart"></canvas>
</div>
</div>
<!-- 数据表格 -->
<div class="table-container" id="tableContainer">
<table>
<thead>
<tr>
<th>时间</th>
<th>温度 (°C)</th>
<th>湿度 (%)</th>
<th>气压 (hPa)</th>
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- 数据行将动态填充 -->
</tbody>
</table>
</div>
</div>
<script>
let selectedStation = null;
let combinedChart = null;
// 初始化页面
document.addEventListener('DOMContentLoaded', function() {
loadStations();
initializeDateInputs();
});
// 初始化日期输入
function initializeDateInputs() {
const now = new Date();
const endDate = new Date(now);
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
}
function formatDatetimeLocal(date) {
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
// 加载站点列表
async function loadStations() {
try {
const response = await fetch('/api/stations');
const stations = await response.json();
const stationList = document.getElementById('stationList');
stationList.innerHTML = '';
if (stations.length === 0) {
stationList.innerHTML = '<div class="error">暂无WH65LP设备站点</div>';
return;
}
stations.forEach(station => {
const stationCard = document.createElement('div');
stationCard.className = 'station-card';
stationCard.onclick = () => selectStation(station, stationCard);
stationCard.innerHTML = `
<div class="station-name">${station.station_name || station.station_id}</div>
<div class="station-id">${station.station_id}</div>
<div class="station-id">最后更新: ${station.last_update}</div>
`;
stationList.appendChild(stationCard);
});
} catch (error) {
console.error('加载站点失败:', error);
document.getElementById('stationList').innerHTML = '<div class="error">加载站点失败</div>';
}
}
// 选择站点
function selectStation(station, cardElement) {
// 清除之前的选择
document.querySelectorAll('.station-card').forEach(card => {
card.classList.remove('selected');
});
// 选择当前站点
cardElement.classList.add('selected');
selectedStation = station;
// 启用按钮
document.getElementById('queryBtn').disabled = false;
// 隐藏之前的数据
hideDataContainers();
}
// 隐藏数据容器
function hideDataContainers() {
document.getElementById('chartContainer').classList.remove('show');
document.getElementById('tableContainer').classList.remove('show');
}
// 查询历史数据
async function queryHistoryData() {
if (!selectedStation) {
alert('请先选择一个站点');
return;
}
const startTime = document.getElementById('startDate').value;
const endTime = document.getElementById('endDate').value;
const interval = document.getElementById('interval').value;
if (!startTime || !endTime) {
alert('请选择开始和结束时间');
return;
}
try {
const params = new URLSearchParams({
station_id: selectedStation.station_id,
start_time: startTime.replace('T', ' ') + ':00',
end_time: endTime.replace('T', ' ') + ':00',
interval: interval
});
const response = await fetch(`/api/data?${params}`);
const data = await response.json();
if (data.length === 0) {
alert('该时间段内无数据');
return;
}
displayChart(data);
displayTable(data);
// 显示图表和表格
document.getElementById('chartContainer').classList.add('show');
document.getElementById('tableContainer').classList.add('show');
} catch (error) {
console.error('查询历史数据失败:', error);
alert('查询历史数据失败');
}
}
// 显示图表
function displayChart(data) {
const labels = data.map(item => item.date_time);
const temperatures = data.map(item => item.temperature);
const humidities = data.map(item => item.humidity);
const rainfalls = data.map(item => item.rainfall);
// 销毁旧图表
if (combinedChart) combinedChart.destroy();
// 创建组合图表
const ctx = document.getElementById('combinedChart').getContext('2d');
combinedChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '温度 (°C)',
data: temperatures,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
yAxisID: 'y-temperature',
tension: 0.4
},
{
label: '湿度 (%)',
data: humidities,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
yAxisID: 'y-humidity',
tension: 0.4,
hidden: true // 默认隐藏湿度数据
},
{
label: '雨量 (mm)',
data: rainfalls,
type: 'bar',
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgb(54, 162, 235)',
yAxisID: 'y-rainfall'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
'y-temperature': {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: '温度 (°C)'
}
},
'y-humidity': {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '湿度 (%)'
},
grid: {
drawOnChartArea: false
},
min: 0,
max: 100
},
'y-rainfall': {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '雨量 (mm)'
},
grid: {
drawOnChartArea: false
},
beginAtZero: true
}
}
}
});
}
// 显示数据表格
function displayTable(data) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
data.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.date_time}</td>
<td>${item.temperature.toFixed(2)}</td>
<td>${item.humidity.toFixed(2)}</td>
<td>${item.pressure.toFixed(2)}</td>
<td>${item.wind_speed.toFixed(2)}</td>
<td>${item.wind_direction.toFixed(2)}</td>
<td>${item.rainfall.toFixed(3)}</td>
<td>${item.light.toFixed(2)}</td>
<td>${item.uv.toFixed(2)}</td>
`;
tbody.appendChild(row);
});
}
</script>
</body>
</html>

240
web_server.go Normal file
View File

@ -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(&timestamp, &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))
}