feat: add a new 485 type weather station

This commit is contained in:
yarnom 2025-08-01 21:05:21 +08:00
parent 1be274e62f
commit c09b3e789a
9 changed files with 493 additions and 98 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -8,11 +8,3 @@ database:
password: "yourpassword" password: "yourpassword"
dbname: "weatherdb" dbname: "weatherdb"
sslmode: "disable" sslmode: "disable"
heartbeat:
interval: 5
message: "Hello"
device_check:
interval: 5
message: "Hello"

View File

@ -21,21 +21,9 @@ type DatabaseConfig struct {
SSLMode string `yaml:"sslmode"` SSLMode string `yaml:"sslmode"`
} }
type HeartbeatConfig struct {
Interval int `yaml:"interval"`
Message string `yaml:"message"`
}
type DeviceCheckConfig struct {
Interval int `yaml:"interval"`
Message string `yaml:"message"`
}
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
Heartbeat HeartbeatConfig `yaml:"heartbeat"`
DeviceCheck DeviceCheckConfig `yaml:"device_check"`
} }
var ( var (

44
main.go
View File

@ -156,53 +156,9 @@ func startUDP() {
} }
} }
func startDeviceCheck() {
cfg := config.GetConfig()
ticker := time.NewTicker(time.Duration(cfg.DeviceCheck.Interval) * time.Minute)
defer ticker.Stop()
for range ticker.C {
devices := model.GetOnlineDevices()
log.Printf("当前在线设备数: %d", len(devices))
for _, device := range devices {
sendUDPMessage(device.IP, cfg.DeviceCheck.Message)
}
}
}
func sendUDPMessage(ip string, message string) {
cfg := config.GetConfig()
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ip, cfg.Server.UDPPort))
if err != nil {
log.Printf("解析UDP地址失败: %v", err)
return
}
log.Printf("尝试向 %s 发送消息...", addr.String())
conn, err := net.DialUDP("udp", nil, addr)
if err != nil {
log.Printf("连接UDP失败: %v", err)
return
}
defer conn.Close()
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Write([]byte(message))
if err != nil {
log.Printf("发送UDP消息失败: %v", err)
return
}
log.Printf("成功向 %s 发送 %d 字节消息: %s", ip, n, message)
}
func main() { func main() {
setupLogger() setupLogger()
go startUDP() go startUDP()
go startDeviceCheck()
r := gin.Default() r := gin.Default()
r.LoadHTMLGlob("templates/*") r.LoadHTMLGlob("templates/*")

View File

@ -3,6 +3,7 @@ package model
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"net"
"time" "time"
"weatherstation/config" "weatherstation/config"
@ -12,6 +13,14 @@ import (
var db *sql.DB var db *sql.DB
// DBDevice 数据库中的设备信息
type DBDevice struct {
StationID string
IP string
LastUpdate time.Time
DeviceType DeviceType
}
func InitDB() error { func InitDB() error {
cfg := config.GetConfig() cfg := config.GetConfig()
@ -39,7 +48,7 @@ func CloseDB() {
} }
} }
func ensureStationExists(stationID, password string) error { func ensureStationExists(stationID, password string, deviceType DeviceType) error {
if db == nil { if db == nil {
return fmt.Errorf("数据库未初始化") return fmt.Errorf("数据库未初始化")
} }
@ -51,13 +60,14 @@ func ensureStationExists(stationID, password string) error {
} }
if count == 0 { if count == 0 {
_, err = db.Exec("INSERT INTO stations (station_id, password) VALUES ($1, $2)", stationID, password) _, err = db.Exec("INSERT INTO stations (station_id, password, device_type) VALUES ($1, $2, $3)",
stationID, password, deviceType)
if err != nil { if err != nil {
return fmt.Errorf("添加站点失败: %v", err) return fmt.Errorf("添加站点失败: %v", err)
} }
} else { } else {
_, err = db.Exec("UPDATE stations SET password = $1, last_update = $2 WHERE station_id = $3", _, err = db.Exec("UPDATE stations SET password = $1, last_update = $2, device_type = $3 WHERE station_id = $4",
password, time.Now(), stationID) password, time.Now(), deviceType, stationID)
if err != nil { if err != nil {
return fmt.Errorf("更新站点失败: %v", err) return fmt.Errorf("更新站点失败: %v", err)
} }
@ -66,12 +76,74 @@ func ensureStationExists(stationID, password string) error {
return nil return nil
} }
// RegisterDeviceInDB 在数据库中注册设备
func RegisterDeviceInDB(stationID string, addr net.Addr) error {
if db == nil {
return fmt.Errorf("数据库未初始化")
}
ipStr := addr.String()
if ipStr == "" {
return fmt.Errorf("无效的IP地址")
}
ip, _, err := net.SplitHostPort(ipStr)
if err != nil {
return fmt.Errorf("解析IP地址失败: %v", err)
}
_, err = db.Exec(`
INSERT INTO device_ips (station_id, ip_address, last_update)
VALUES ($1, $2, $3)
ON CONFLICT (station_id)
DO UPDATE SET ip_address = $2, last_update = $3
`, stationID, ip, time.Now())
if err != nil {
return fmt.Errorf("注册设备IP失败: %v", err)
}
return nil
}
// GetOnlineDevicesFromDB 从数据库获取在线设备
func GetOnlineDevicesFromDB() []DBDevice {
if db == nil {
return nil
}
rows, err := db.Query(`
SELECT d.station_id, d.ip_address, d.last_update, s.device_type
FROM device_ips d
JOIN stations s ON d.station_id = s.station_id
WHERE d.last_update > $1
ORDER BY d.last_update DESC
`, time.Now().Add(-24*time.Hour))
if err != nil {
return nil
}
defer rows.Close()
var devices []DBDevice
for rows.Next() {
var d DBDevice
err := rows.Scan(&d.StationID, &d.IP, &d.LastUpdate, &d.DeviceType)
if err != nil {
continue
}
devices = append(devices, d)
}
return devices
}
func SaveWeatherData(data *WeatherData, rawData string) error { func SaveWeatherData(data *WeatherData, rawData string) error {
if db == nil { if db == nil {
return fmt.Errorf("数据库未初始化") return fmt.Errorf("数据库未初始化")
} }
err := ensureStationExists(data.StationID, data.Password) err := ensureStationExists(data.StationID, data.Password, DeviceTypeEcowitt)
if err != nil { if err != nil {
return err return err
} }
@ -101,3 +173,42 @@ func SaveWeatherData(data *WeatherData, rawData string) error {
return nil return nil
} }
func SaveWH65LPData(data *WH65LPData, rawData []byte) error {
if db == nil {
return fmt.Errorf("数据库未初始化")
}
// 确保设备存在WH65LP设备没有密码
err := ensureStationExists(data.StationID, "", DeviceTypeWH65LP)
if err != nil {
return err
}
// 插入数据
_, err = db.Exec(`
INSERT INTO wh65lp_data (
station_id, timestamp, temperature, humidity, wind_direction,
wind_speed, wind_gust, rain, uv_index, light, pressure,
low_battery, wsp_flag, raw_data
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
data.StationID, data.Timestamp,
int(data.Temperature*10), // 温度保存为整数精确到0.1
data.Humidity,
data.WindDirection,
int(data.WindSpeed*100), // 风速保存为整数精确到0.01
int(data.WindGust*100), // 阵风保存为整数精确到0.01
int(data.Rain*1000), // 降雨量保存为整数精确到0.001
data.UV,
int(data.Light*10), // 光照保存为整数精确到0.1
int(data.Pressure*100), // 气压保存为整数精确到0.01
data.LowBattery,
data.WSPFlag,
rawData)
if err != nil {
return fmt.Errorf("保存WH65LP数据失败: %v", err)
}
return nil
}

View File

@ -6,35 +6,49 @@ import (
"time" "time"
) )
type Device struct { // DeviceType 设备类型枚举
IP string type DeviceType string
LastSeen time.Time
StationID string const (
DeviceTypeEcowitt DeviceType = "ECOWITT"
DeviceTypeWH65LP DeviceType = "WH65LP"
DeviceTypeUnknown DeviceType = "UNKNOWN"
)
// MemoryDevice 内存中的设备信息
type MemoryDevice struct {
IP string
LastSeen time.Time
StationID string
DeviceType DeviceType
} }
var ( var (
devices = make(map[string]*Device) devices = make(map[string]*MemoryDevice)
deviceMutex sync.RWMutex deviceMutex sync.RWMutex
) )
func RegisterDevice(stationID string, addr net.Addr) { // UpdateDeviceInMemory 更新内存中的设备信息
func UpdateDeviceInMemory(stationID string, addr net.Addr, deviceType DeviceType) {
ip, _, _ := net.SplitHostPort(addr.String()) ip, _, _ := net.SplitHostPort(addr.String())
deviceMutex.Lock() deviceMutex.Lock()
defer deviceMutex.Unlock() defer deviceMutex.Unlock()
devices[stationID] = &Device{ devices[stationID] = &MemoryDevice{
IP: ip, IP: ip,
LastSeen: time.Now(), LastSeen: time.Now(),
StationID: stationID, StationID: stationID,
DeviceType: deviceType,
} }
} }
func GetOnlineDevices() []*Device { // GetOnlineDevicesFromMemory 获取内存中的在线设备
func GetOnlineDevicesFromMemory() []*MemoryDevice {
deviceMutex.RLock() deviceMutex.RLock()
defer deviceMutex.RUnlock() defer deviceMutex.RUnlock()
result := make([]*Device, 0, len(devices)) result := make([]*MemoryDevice, 0, len(devices))
for _, device := range devices { for _, device := range devices {
if time.Since(device.LastSeen) < 10*time.Minute { if time.Since(device.LastSeen) < 10*time.Minute {
result = append(result, device) result = append(result, device)
@ -42,3 +56,14 @@ func GetOnlineDevices() []*Device {
} }
return result return result
} }
// GetDeviceTypeFromMemory 从内存中获取设备类型
func GetDeviceTypeFromMemory(stationID string) DeviceType {
deviceMutex.RLock()
defer deviceMutex.RUnlock()
if device, ok := devices[stationID]; ok {
return device.DeviceType
}
return DeviceTypeUnknown
}

View File

@ -3,8 +3,9 @@ package model
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings"
"time"
) )
type WeatherData struct { type WeatherData struct {
@ -37,16 +38,28 @@ type WeatherData struct {
RTFreq int RTFreq int
} }
var urlRegex = regexp.MustCompile(`/weatherstation/updateweatherstation\.php\?([^&\s]+(&[^&\s]+)*)`)
func ParseWeatherData(data string) (*WeatherData, error) { func ParseWeatherData(data string) (*WeatherData, error) {
matches := urlRegex.FindStringSubmatch(data) if !strings.Contains(data, "GET /weatherstation/updateweatherstation.php") {
if len(matches) < 2 { return nil, fmt.Errorf("不是气象站数据")
return nil, fmt.Errorf("无法找到有效的气象站数据URL")
} }
queryString := matches[1] urlStart := strings.Index(data, "GET ")
if urlStart == -1 {
return nil, fmt.Errorf("无法找到URL开始位置")
}
httpVersionEnd := strings.Index(data, " HTTP")
if httpVersionEnd == -1 {
return nil, fmt.Errorf("无法找到URL结束位置")
}
urlString := data[urlStart+4 : httpVersionEnd]
queryStart := strings.Index(urlString, "?")
if queryStart == -1 {
return nil, fmt.Errorf("无法找到查询参数")
}
queryString := urlString[queryStart+1:]
values, err := url.ParseQuery(queryString) values, err := url.ParseQuery(queryString)
if err != nil { if err != nil {
return nil, fmt.Errorf("解析查询参数失败: %v", err) return nil, fmt.Errorf("解析查询参数失败: %v", err)
@ -161,12 +174,12 @@ func (w *WeatherData) String() string {
风向: %d° 风向: %d°
风速: %.2f mph (%.2f km/h) 风速: %.2f mph (%.2f km/h)
阵风: %.2f mph (%.2f km/h) 阵风: %.2f mph (%.2f km/h)
降雨量: %.3f 英寸 (%.2f mm) 降雨量: %.3f 英寸/小时 (%.2f mm/h) - 当前降雨率
日降雨量: %.3f 英寸 (%.2f mm) 日降雨量: %.3f 英寸 (%.2f mm) - 过去24小时累计
周降雨量: %.3f 英寸 (%.2f mm) 周降雨量: %.3f 英寸 (%.2f mm) - 本周累计
月降雨量: %.3f 英寸 (%.2f mm) 月降雨量: %.3f 英寸 (%.2f mm) - 本月累计
年降雨量: %.3f 英寸 (%.2f mm) 年降雨量: %.3f 英寸 (%.2f mm) - 本年累计
总降雨量: %.3f 英寸 (%.2f mm) 总降雨量: %.3f 英寸 (%.2f mm) - 自设备安装以来累计
太阳辐射: %.2f W/m² 太阳辐射: %.2f W/m²
紫外线指数: %d 紫外线指数: %d
室内温度: %.1f°F (%.1f°C) 室内温度: %.1f°F (%.1f°C)
@ -175,7 +188,8 @@ func (w *WeatherData) String() string {
相对气压: %.3f 英寸汞柱 (%.2f hPa) 相对气压: %.3f 英寸汞柱 (%.2f hPa)
低电量: %v 低电量: %v
软件类型: %s 软件类型: %s
日期UTC: %s`, 日期UTC: %s
`,
w.StationID, w.StationID,
w.TempF, (w.TempF-32)*5/9, w.TempF, (w.TempF-32)*5/9,
w.Humidity, w.Humidity,
@ -201,3 +215,242 @@ func (w *WeatherData) String() string {
w.DateUTC, w.DateUTC,
) )
} }
type WH65LPData struct {
StationID string // 设备ID (24位)
Timestamp time.Time // 数据时间戳
FamilyCode byte // 家族码 (0x24)
Temperature float64 // 温度 (°C)
Humidity int // 湿度 (%)
WindDirection int // 风向 (0-359°)
WindSpeed float64 // 风速 (m/s)
WindGust float64 // 阵风 (m/s)
Rain float64 // 降雨量 (mm)
UV int // 紫外线指数 (0-13)
Light float64 // 光照 (lux)
Pressure float64 // 气压 (hPa)
LowBattery bool // 低电量标志
WSPFlag bool // 风速标志位
}
// ParseWH65LPData 解析WH65LP设备的25字节数据
func ParseWH65LPData(data []byte) (*WH65LPData, error) {
if len(data) != 25 {
return nil, fmt.Errorf("数据长度错误期望25字节实际%d字节", len(data))
}
wd := &WH65LPData{
Timestamp: time.Now(),
}
// 1. 解析家族码 (第1字节bits 0-7)
wd.FamilyCode = data[0]
if wd.FamilyCode != 0x24 {
return nil, fmt.Errorf("无效的家族码0x%02X", wd.FamilyCode)
}
// 2. 解析设备ID (第2字节 + 第22-23字节)
idLSB := data[1]
idMSB := uint32(data[22])<<8 | uint32(data[21])
wd.StationID = fmt.Sprintf("%06X", (idMSB<<8)|uint32(idLSB))
// 3. 解析风向 (bits 16-24)
windDir := uint16(data[2]) | (uint16(data[3]&0x01) << 8)
wd.WindDirection = int(windDir)
// 4. 解析风速标志和温度
wd.WSPFlag = (data[3]>>1)&0x01 == 1
wd.LowBattery = (data[3]>>3)&0x01 == 1
// 温度 (11位bits 28-39)
tempRaw := uint16(data[4]) | (uint16(data[5]&0x07) << 8)
wd.Temperature = float64(tempRaw-400) / 10.0
// 5. 湿度 (bits 40-47)
wd.Humidity = int(data[5] >> 3)
// 6. 风速 (bits 48-55 + WSP_9,WSP_8)
windSpeedRaw := uint16(data[6])
if !wd.WSPFlag {
// 10位风速
windSpeedRaw |= uint16(data[3]>>6) << 8
}
wd.WindSpeed = float64(windSpeedRaw) / 8.0 * 0.51
// 7. 阵风 (bits 56-63)
wd.WindGust = float64(data[7]) * 0.51
// 8. 降雨量 (bits 64-79)
rainRaw := uint16(data[8]) | uint16(data[9])<<8
wd.Rain = float64(rainRaw) * 0.254
// 9. 紫外线 (bits 80-95)
uvRaw := uint16(data[10]) | uint16(data[11])<<8
wd.UV = getUVIndex(uvRaw)
// 10. 光照 (bits 96-119)
lightRaw := uint32(data[12]) | uint32(data[13])<<8 | uint32(data[14])<<16
wd.Light = float64(lightRaw) / 10.0
// 11. 气压 (bits 136-159)
pressureRaw := uint32(data[17]) | uint32(data[18])<<8 | uint32(data[19])<<16
pressureRaw &= 0x1FFFF // 只取17位
wd.Pressure = float64(pressureRaw) / 100.0
return wd, nil
}
// getUVIndex 根据UV原始值获取UV指数
func getUVIndex(uvRaw uint16) int {
switch {
case uvRaw <= 432:
return 0
case uvRaw <= 851:
return 1
case uvRaw <= 1210:
return 2
case uvRaw <= 1570:
return 3
case uvRaw <= 2017:
return 4
case uvRaw <= 2450:
return 5
case uvRaw <= 2761:
return 6
case uvRaw <= 3100:
return 7
case uvRaw <= 3512:
return 8
case uvRaw <= 3918:
return 9
case uvRaw <= 4277:
return 10
case uvRaw <= 4650:
return 11
case uvRaw <= 5029:
return 12
default:
return 13
}
}
// String 格式化输出WH65LP数据
func (w *WH65LPData) String() string {
return fmt.Sprintf(`
设备ID: %s
温度: %.1f°C
湿度: %d%%
风向: %d°
风速: %.2f m/s
阵风: %.2f m/s
降雨量: %.3f mm
UV指数: %d
光照: %.1f lux
气压: %.2f hPa
电池状态: %v
`,
w.StationID,
w.Temperature,
w.Humidity,
w.WindDirection,
w.WindSpeed,
w.WindGust,
w.Rain,
w.UV,
w.Light,
w.Pressure,
w.LowBattery,
)
}
// IsWH65LPData 检查数据是否是WH65LP的数据
func IsWH65LPData(data []byte) bool {
// 1. 检查数据长度
if len(data) != 25 {
return false
}
// 2. 检查家族码
if data[0] != 0x24 {
return false
}
// 3. 验证CRC (第16字节前15字节的CRC8)
if !ValidateCRC8(data[:15], data[15]) {
return false
}
// 4. 验证气压校验和 (第21字节)
pressureSum := uint8(data[17] + data[18] + data[19])
if pressureSum != data[20] {
return false
}
// 5. 验证CRC2 (第24字节前23字节的CRC8)
if !ValidateCRC8(data[:23], data[23]) {
return false
}
// 6. 验证最终校验和 (第25字节)
var sum uint8
for i := 0; i < 24; i++ {
sum += data[i]
}
if sum != data[24] {
return false
}
return true
}
// ValidateCRC8 验证CRC8校验
// 多项式0x31初始值0x00
func ValidateCRC8(data []byte, crc uint8) bool {
return crc == CalculateCRC8(data)
}
// CalculateCRC8 计算CRC8
func CalculateCRC8(data []byte) uint8 {
var crc uint8
for _, b := range data {
crc ^= b
for i := 0; i < 8; i++ {
if crc&0x80 != 0 {
crc = (crc << 1) ^ 0x31
} else {
crc <<= 1
}
}
}
return crc
}
// ParseUDP10006Data 解析UDP 10006端口的数据
func ParseUDP10006Data(data []byte) (*WH65LPData, error) {
// 1. 验证数据格式
if !IsWH65LPData(data) {
return nil, fmt.Errorf("无效的WH65LP数据格式")
}
// 2. 解析数据
return ParseWH65LPData(data)
}
// ConvertToMetric 将英制单位转换为公制单位
func ConvertToMetric(data *WeatherData) *WH65LPData {
// 用于将旧格式数据转换为WH65LP格式
return &WH65LPData{
StationID: data.StationID,
Timestamp: time.Now(),
Temperature: (data.TempF - 32) * 5 / 9,
Humidity: data.Humidity,
WindDirection: data.WindDir,
WindSpeed: data.WindSpeedMph * 0.44704, // mph转m/s
WindGust: data.WindGustMph * 0.44704, // mph转m/s
Rain: data.RainIn * 25.4, // inch转mm
UV: data.UV,
Light: data.SolarRadiation * 126.7, // W/m²转lux (近似转换)
Pressure: data.BarometerIn * 33.8639, // inHg转hPa
LowBattery: data.LowBattery,
}
}

61
server/udp_server.go Normal file
View File

@ -0,0 +1,61 @@
package server
import (
"log"
"net"
"weatherstation/model"
)
func StartUDPServer(port string) error {
addr, err := net.ResolveUDPAddr("udp", ":"+port)
if err != nil {
return err
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
return err
}
defer conn.Close()
log.Printf("UDP服务器已启动监听端口 %s", port)
buffer := make([]byte, 1024)
for {
n, remoteAddr, err := conn.ReadFromUDP(buffer)
if err != nil {
log.Printf("读取UDP数据失败: %v", err)
continue
}
// 处理接收到的数据
go handleUDPData(buffer[:n], remoteAddr)
}
}
func handleUDPData(data []byte, addr *net.UDPAddr) {
// 尝试解析为WH65LP数据
weatherData, err := model.ParseUDP10006Data(data)
if err != nil {
log.Printf("解析数据失败: %v", err)
return
}
// 更新内存中的设备信息
model.UpdateDeviceInMemory(weatherData.StationID, addr, model.DeviceTypeWH65LP)
// 注册设备到数据库
err = model.RegisterDeviceInDB(weatherData.StationID, addr)
if err != nil {
log.Printf("注册设备失败: %v", err)
}
// 保存数据
err = model.SaveWH65LPData(weatherData, data)
if err != nil {
log.Printf("保存数据失败: %v", err)
return
}
log.Printf("成功接收并保存来自 %s 的WH65LP数据:\n%s", addr.String(), weatherData.String())
}

1
参考页面.html Normal file
View File

@ -0,0 +1 @@
Hello