feat: 新增MQTT 推送
This commit is contained in:
parent
d879c76d07
commit
f4ba47d9f0
56
core/cmd/core-mqtt/main.go
Normal file
56
core/cmd/core-mqtt/main.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
broker := "wss://broker.emqx.io:8084/mqtt"
|
||||||
|
clientID := "Mqttx_07c4e9ed"
|
||||||
|
username := "1"
|
||||||
|
password := "1"
|
||||||
|
topic := "$dp"
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions()
|
||||||
|
opts.AddBroker(broker)
|
||||||
|
opts.SetClientID(clientID)
|
||||||
|
opts.SetUsername(username)
|
||||||
|
opts.SetPassword(password)
|
||||||
|
opts.SetProtocolVersion(4)
|
||||||
|
opts.SetKeepAlive(60 * time.Second)
|
||||||
|
opts.SetAutoReconnect(true)
|
||||||
|
opts.SetCleanSession(true)
|
||||||
|
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
|
||||||
|
c := mqtt.NewClient(opts)
|
||||||
|
if t := c.Connect(); t.Wait() && t.Error() != nil {
|
||||||
|
fmt.Printf("connect error: %v\n", t.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer c.Disconnect(250)
|
||||||
|
|
||||||
|
// 构造所需数据格式(仅设备与时间变更)
|
||||||
|
payload := map[string]any{
|
||||||
|
"type": "hws",
|
||||||
|
"device": "Z866",
|
||||||
|
"Dm": 0.0001,
|
||||||
|
"Pa": 976.7,
|
||||||
|
"Rc": 0,
|
||||||
|
"Sm": 0.0001,
|
||||||
|
"Ta": 39,
|
||||||
|
"Ua": 26.6,
|
||||||
|
"time": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
if t := c.Publish(topic, 1, false, b); t.Wait() && t.Error() != nil {
|
||||||
|
fmt.Printf("publish error: %v\n", t.Error())
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
fmt.Println("published to", topic)
|
||||||
|
}
|
||||||
228
core/cmd/service-mqtt-publisher/main.go
Normal file
228
core/cmd/service-mqtt-publisher/main.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
|
||||||
|
"weatherstation/core/internal/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 固定映射
|
||||||
|
deviceID = "Z866"
|
||||||
|
stationID = "RS485-002A6E"
|
||||||
|
|
||||||
|
// MQTT 测试配置(可替换为生产)
|
||||||
|
brokerURL = "wss://broker.emqx.io:8084/mqtt"
|
||||||
|
clientID = "core-publisher-Z866"
|
||||||
|
username = "1"
|
||||||
|
password = "1"
|
||||||
|
topic = "$dp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hwsPayload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Dm float64 `json:"Dm"`
|
||||||
|
Pa float64 `json:"Pa"`
|
||||||
|
Rc float64 `json:"Rc"`
|
||||||
|
Sm float64 `json:"Sm"`
|
||||||
|
Ta float64 `json:"Ta"`
|
||||||
|
Ua float64 `json:"Ua"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 初始化 MQTT 客户端
|
||||||
|
opts := mqtt.NewClientOptions().AddBroker(brokerURL)
|
||||||
|
opts.SetClientID(clientID)
|
||||||
|
opts.SetUsername(username)
|
||||||
|
opts.SetPassword(password)
|
||||||
|
opts.SetProtocolVersion(4)
|
||||||
|
opts.SetKeepAlive(60 * time.Second)
|
||||||
|
opts.SetAutoReconnect(true)
|
||||||
|
opts.SetCleanSession(true)
|
||||||
|
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
|
||||||
|
cli := mqtt.NewClient(opts)
|
||||||
|
if tok := cli.Connect(); tok.Wait() && tok.Error() != nil {
|
||||||
|
log.Fatalf("MQTT 连接失败: %v", tok.Error())
|
||||||
|
}
|
||||||
|
defer cli.Disconnect(250)
|
||||||
|
|
||||||
|
// 5分钟发布任务
|
||||||
|
go alignAndRun5m(func(tickEnd time.Time) { publishOnce(cli, tickEnd) })
|
||||||
|
// 1小时+10分钟发布预测任务
|
||||||
|
go alignAndRunHour10(func(tick time.Time) { publishPredict(cli, tick) })
|
||||||
|
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alignAndRun5m(fn func(tickEnd time.Time)) {
|
||||||
|
now := time.Now()
|
||||||
|
next := now.Truncate(5 * time.Minute).Add(5 * time.Minute)
|
||||||
|
time.Sleep(time.Until(next))
|
||||||
|
for {
|
||||||
|
tickEnd := time.Now().Truncate(5 * time.Minute)
|
||||||
|
fn(tickEnd)
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alignAndRunHour10(fn func(tick time.Time)) {
|
||||||
|
// 计算下一个 “整点+10分钟”
|
||||||
|
now := time.Now()
|
||||||
|
base := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 10, 0, 0, now.Location())
|
||||||
|
var next time.Time
|
||||||
|
if now.After(base) {
|
||||||
|
next = base.Add(1 * time.Hour)
|
||||||
|
} else {
|
||||||
|
next = base
|
||||||
|
}
|
||||||
|
time.Sleep(time.Until(next))
|
||||||
|
for {
|
||||||
|
tick := time.Now().Truncate(time.Minute)
|
||||||
|
fn(tick)
|
||||||
|
// 每小时执行一次
|
||||||
|
time.Sleep(1 * time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishOnce(cli mqtt.Client, tickEnd time.Time) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := tickEnd.Add(-5 * time.Minute)
|
||||||
|
// 聚合窗口
|
||||||
|
agg, err := data.WindowAverages(ctx, stationID, start, tickEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("聚合失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 当天累计雨量
|
||||||
|
rc, err := data.DailyRainSinceMidnight(ctx, stationID, tickEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("降雨查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理无效值:若无数据则用 0(<0.001 算无效)
|
||||||
|
dm := 0.0
|
||||||
|
if agg.Dm.Valid {
|
||||||
|
dm = agg.Dm.Float64
|
||||||
|
}
|
||||||
|
sm := 0.0
|
||||||
|
if agg.Sm.Valid {
|
||||||
|
sm = agg.Sm.Float64
|
||||||
|
}
|
||||||
|
ta := 0.0
|
||||||
|
if agg.Ta.Valid {
|
||||||
|
ta = agg.Ta.Float64
|
||||||
|
}
|
||||||
|
ua := 0.0
|
||||||
|
if agg.Ua.Valid {
|
||||||
|
ua = agg.Ua.Float64
|
||||||
|
}
|
||||||
|
pa := 0.0
|
||||||
|
if agg.Pa.Valid {
|
||||||
|
pa = agg.Pa.Float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四舍五入:风向取整数,其它保留两位小数
|
||||||
|
round2 := func(v float64) float64 { return math.Round(v*100) / 100 }
|
||||||
|
dmInt := math.Round(dm)
|
||||||
|
|
||||||
|
payload := hwsPayload{
|
||||||
|
Type: "hws",
|
||||||
|
Device: deviceID,
|
||||||
|
Dm: dmInt,
|
||||||
|
Pa: round2(pa),
|
||||||
|
Rc: round2(rc),
|
||||||
|
Sm: round2(sm),
|
||||||
|
Ta: round2(ta),
|
||||||
|
Ua: round2(ua),
|
||||||
|
Time: tickEnd.UnixMilli(),
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
tok := cli.Publish(topic, 1, false, b)
|
||||||
|
tok.Wait()
|
||||||
|
if tok.Error() != nil {
|
||||||
|
log.Printf("发布失败: %v", tok.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("发布成功 %s: %s", topic, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预测发布:每小时+10分钟,从 forecast_hourly 按 issued_at=整点 选取数据
|
||||||
|
func publishPredict(cli mqtt.Client, tick time.Time) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 以 CST 解析 issued_at 整点
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
now := tick.In(loc)
|
||||||
|
issued := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, loc)
|
||||||
|
|
||||||
|
const forecastStation = "RS485-002A6E"
|
||||||
|
const provider = "imdroid_mix"
|
||||||
|
points, err := data.ForecastRainAtIssued(ctx, forecastStation, provider, issued)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("预测查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(points) == 0 {
|
||||||
|
log.Printf("预测无数据 issued_at=%s", issued.Format("2006-01-02 15:04:05"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装 payload
|
||||||
|
type predictItem struct {
|
||||||
|
PredictTime int64 `json:"predict_time"`
|
||||||
|
PredictRainfall string `json:"predict_rainfall"`
|
||||||
|
}
|
||||||
|
var items []predictItem
|
||||||
|
for _, p := range points {
|
||||||
|
rf := float64(p.RainMMx1000) / 1000.0
|
||||||
|
items = append(items, predictItem{
|
||||||
|
PredictTime: p.ForecastTime.UnixMilli(),
|
||||||
|
PredictRainfall: format3(rf),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Data []predictItem `json:"data"`
|
||||||
|
}{
|
||||||
|
Type: "predict",
|
||||||
|
Device: deviceID,
|
||||||
|
Time: now.UnixMilli(), // 当前“整点+10分钟”的时间
|
||||||
|
Data: items,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
tok := cli.Publish(topic, 1, false, b)
|
||||||
|
tok.Wait()
|
||||||
|
if tok.Error() != nil {
|
||||||
|
log.Printf("发布预测失败: %v", tok.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("发布预测成功 %s: issued_at=%s, items=%d", topic, issued.Format("2006-01-02 15:04:05"), len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
func format3(v float64) string {
|
||||||
|
// 保留三位小数(字符串形式)
|
||||||
|
s := fmt.Sprintf("%.3f", v)
|
||||||
|
return s
|
||||||
|
}
|
||||||
40
core/internal/data/forecast.go
Normal file
40
core/internal/data/forecast.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PredictPoint struct {
|
||||||
|
ForecastTime time.Time
|
||||||
|
RainMMx1000 int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForecastRainAtIssued returns forecast hourly rows for a given station/provider at an exact issued_at time.
|
||||||
|
func ForecastRainAtIssued(ctx context.Context, stationID, provider string, issuedAt time.Time) ([]PredictPoint, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT forecast_time, rain_mm_x1000
|
||||||
|
FROM forecast_hourly
|
||||||
|
WHERE station_id=$1 AND provider=$2 AND issued_at=$3
|
||||||
|
ORDER BY forecast_time ASC`
|
||||||
|
rows, err := database.GetDB().QueryContext(ctx, q, stationID, provider, issuedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PredictPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var p PredictPoint
|
||||||
|
var rain sql.NullInt32
|
||||||
|
if err := rows.Scan(&p.ForecastTime, &rain); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rain.Valid {
|
||||||
|
p.RainMMx1000 = rain.Int32
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
85
core/internal/data/raw.go
Normal file
85
core/internal/data/raw.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindowAgg struct {
|
||||||
|
Ta sql.NullFloat64
|
||||||
|
Ua sql.NullFloat64
|
||||||
|
Pa sql.NullFloat64
|
||||||
|
Sm sql.NullFloat64
|
||||||
|
Dm sql.NullFloat64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowAverages computes averages on rs485_weather_data over [start,end) window.
|
||||||
|
// Filters values < 0.001 as invalid. Wind direction averaged vectorially.
|
||||||
|
func WindowAverages(ctx context.Context, stationID string, start, end time.Time) (WindowAgg, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
AVG(temperature) AS ta,
|
||||||
|
AVG(humidity) AS ua,
|
||||||
|
AVG(pressure) AS pa,
|
||||||
|
AVG(CASE WHEN wind_speed >= 0.001 THEN wind_speed END) AS sm,
|
||||||
|
DEGREES(
|
||||||
|
ATAN2(
|
||||||
|
AVG(CASE WHEN wind_speed >= 0.001 THEN SIN(RADIANS(wind_direction)) END),
|
||||||
|
AVG(CASE WHEN wind_speed >= 0.001 THEN COS(RADIANS(wind_direction)) END)
|
||||||
|
)
|
||||||
|
) AS dm
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE station_id = $1 AND timestamp >= $2 AND timestamp < $3`
|
||||||
|
var agg WindowAgg
|
||||||
|
err := database.GetDB().QueryRowContext(ctx, q, stationID, start, end).Scan(
|
||||||
|
&agg.Ta, &agg.Ua, &agg.Pa, &agg.Sm, &agg.Dm,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return WindowAgg{}, err
|
||||||
|
}
|
||||||
|
// Normalize Dm to [0,360)
|
||||||
|
if agg.Dm.Valid {
|
||||||
|
v := agg.Dm.Float64
|
||||||
|
if v < 0 {
|
||||||
|
v += 360.0
|
||||||
|
}
|
||||||
|
// handle NaN from no data
|
||||||
|
if math.IsNaN(v) {
|
||||||
|
agg.Dm.Valid = false
|
||||||
|
} else {
|
||||||
|
agg.Dm.Float64 = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyRainSinceMidnight computes current_day_rain = max(0, latest - baselineAtMidnight).
|
||||||
|
// If baseline is null, returns 0.
|
||||||
|
func DailyRainSinceMidnight(ctx context.Context, stationID string, now time.Time) (float64, error) {
|
||||||
|
// Midnight in Asia/Shanghai
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
dayStart := time.Date(now.In(loc).Year(), now.In(loc).Month(), now.In(loc).Day(), 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
var baseline sql.NullFloat64
|
||||||
|
const qBase = `SELECT rainfall FROM rs485_weather_data WHERE station_id=$1 AND timestamp <= $2 ORDER BY timestamp DESC LIMIT 1`
|
||||||
|
_ = database.GetDB().QueryRowContext(ctx, qBase, stationID, dayStart).Scan(&baseline)
|
||||||
|
|
||||||
|
var current sql.NullFloat64
|
||||||
|
const qCur = `SELECT rainfall FROM rs485_weather_data WHERE station_id=$1 ORDER BY timestamp DESC LIMIT 1`
|
||||||
|
_ = database.GetDB().QueryRowContext(ctx, qCur, stationID).Scan(¤t)
|
||||||
|
|
||||||
|
if !current.Valid || !baseline.Valid {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
v := current.Float64 - baseline.Float64
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user