Compare commits
2 Commits
89fc15b5c4
...
f2deb5512f
| Author | SHA1 | Date | |
|---|---|---|---|
| f2deb5512f | |||
| 26c13351d7 |
249
gin_server.go
Normal file
249
gin_server.go
Normal 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(×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")
|
||||||
|
}
|
||||||
29
go.mod
29
go.mod
@ -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
76
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 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
54
launcher.go
Normal 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
258
main.go
@ -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
543
templates/index.html
Normal 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
35
types.go
Normal 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
263
udp_server.go
Normal 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
510
web/index.html
Normal 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
240
web_server.go
Normal 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(×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))
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user