Files
silk/server-go/internal/service/mqtt.go
T

559 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"gorm.io/gorm"
"silk-server-go/internal/model"
)
// AlarmEvent 告警事件
type AlarmEvent struct {
Code string `json:"code"`
Title string `json:"title"`
Message string `json:"message"`
Severity string `json:"severity"`
DeviceKey string `json:"deviceKey,omitempty"`
Metric string `json:"metric,omitempty"`
Value float64 `json:"value,omitempty"`
ThresholdMin float64 `json:"thresholdMin,omitempty"`
ThresholdMax float64 `json:"thresholdMax,omitempty"`
}
// TelemetryEvent 遥测事件
type TelemetryEvent struct {
DeviceKey string `json:"deviceKey"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Timestamp time.Time `json:"timestamp"`
}
// EventHub 事件广播接口,由 ws 包实现
type EventHub interface {
BroadcastTelemetry(deviceKey string, data interface{})
BroadcastAlarm(event AlarmEvent)
BroadcastDeviceStatus(deviceKey string, status string)
}
// IROperationResult 红外操作结果
type IROperationResult struct {
Action string `json:"action"` // learn, emit, learnCancel, erase
Success bool `json:"success"` // 操作是否成功
No int `json:"no"` // 红外码编号(learn/emit 时有值)
Timestamp time.Time `json:"timestamp"` // 操作时间
}
// MQTTService MQTT 消息处理服务
type MQTTService struct {
client mqtt.Client
db *gorm.DB
iotdb *IoTDBService
hub EventHub
topic string
prefix string // 下行命令主题前缀
suffix string // 下行命令主题后缀
coolDown map[string]int64 // 告警冷却(内存防抖)
deviceTopics map[string]string // deviceKey -> 上行主题(用于推导下行主题)
irResults map[string]*IROperationResult // deviceKey -> 最新红外操作结果
irLearnedCodes map[string]map[int]bool // deviceKey -> 已学习的红外码编号集合
irHeartbeat map[string]time.Time // deviceKey -> 红外设备上次心跳发送时间
pendingLearnNo map[string]int // deviceKey -> 待学习编号(设备成功响应时 no=0,需用此映射还原)
}
// isIRController 判断设备是否为红外控制器(GSCU1B-4G,无定时上报,需主动心跳)
func isIRController(d *model.Device) bool {
if d.Model != nil && *d.Model == "GSCU1B-4G" {
return true
}
return strings.Contains(d.Name, "红外")
}
// NewMQTTService 创建 MQTT 服务
func NewMQTTService(mqttURL string, db *gorm.DB, iotdb *IoTDBService, hub EventHub) *MQTTService {
s := &MQTTService{
db: db,
iotdb: iotdb,
hub: hub,
topic: "silk/+/+/+/up/telemetry",
prefix: "silk",
suffix: "down/cmd",
coolDown: make(map[string]int64),
deviceTopics: make(map[string]string),
irResults: make(map[string]*IROperationResult),
irLearnedCodes: make(map[string]map[int]bool),
irHeartbeat: make(map[string]time.Time),
pendingLearnNo: make(map[string]int),
}
opts := mqtt.NewClientOptions()
opts.AddBroker(mqttURL)
opts.SetClientID("silk-server-go")
opts.SetAutoReconnect(true)
opts.OnConnect = func(c mqtt.Client) {
slog.Info("MQTT 已连接", "url", mqttURL)
// 订阅上行遥测主题(标准方向:设备 -> 后端)
if token := c.Subscribe(s.topic, 0, s.handleMessage); token.Wait() && token.Error() != nil {
slog.Warn("MQTT 订阅失败", "topic", s.topic, "err", token.Error())
} else {
slog.Info("MQTT 已订阅", "topic", s.topic)
}
// 也订阅 down/cmd 主题(部分设备 publish/subscribe 方向反配)
topic2 := "silk/+/+/+/down/cmd"
if token := c.Subscribe(topic2, 0, s.handleMessage); token.Wait() && token.Error() != nil {
slog.Warn("MQTT 订阅失败", "topic", topic2, "err", token.Error())
} else {
slog.Info("MQTT 已订阅", "topic", topic2)
}
}
opts.OnConnectionLost = func(c mqtt.Client, err error) {
slog.Warn("MQTT 连接断开", "err", err)
}
s.client = mqtt.NewClient(opts)
return s
}
// Start 启动 MQTT 服务
func (s *MQTTService) Start() error {
if token := s.client.Connect(); token.Wait() && token.Error() != nil {
return token.Error()
}
return nil
}
// StartOfflineChecker 启动设备离线检测定时任务
func (s *MQTTService) StartOfflineChecker(timeout time.Duration) {
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.checkOffline(timeout)
}
}()
}
// checkOffline 检查离线设备并更新状态
// 红外控制器(GSCU1B-4G)无定时上报功能,采用主动心跳:
// - 超时 5 分钟未收到数据 -> 下发 info 命令(不标记离线)
// - 心跳后 5 分钟仍未收到响应 -> 标记离线
func (s *MQTTService) checkOffline(timeout time.Duration) {
cutoff := time.Now().Add(-timeout)
var devices []model.Device
s.db.Where("online_status = ? AND last_seen IS NOT NULL AND last_seen < ?", "online", cutoff).Find(&devices)
for _, d := range devices {
// 红外控制器:先下发心跳,给宽限期等待响应
if isIRController(&d) {
lastHB, ok := s.irHeartbeat[d.DeviceKey]
// 未发过心跳 或 距上次心跳超过阈值 -> 下发 info
if !ok || time.Since(lastHB) >= timeout {
err := s.PublishToDevice(d.DeviceKey, map[string]interface{}{
"type": "info",
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
})
s.irHeartbeat[d.DeviceKey] = time.Now()
if err != nil {
slog.Warn("红外设备心跳发送失败", "deviceKey", d.DeviceKey, "err", err)
} else {
slog.Info("红外设备心跳", "deviceKey", d.DeviceKey, "action", "发送 info 查询")
}
continue // 不标记离线,等待响应
}
// 已发过心跳且距上次心跳超过阈值仍未收到响应 -> 标记离线
slog.Info("红外设备心跳超时,标记离线", "deviceKey", d.DeviceKey, "lastSeen", d.LastSeen, "lastHeartbeat", lastHB)
delete(s.irHeartbeat, d.DeviceKey) // 清理心跳记录
} else {
slog.Info("设备离线", "deviceKey", d.DeviceKey, "lastSeen", d.LastSeen)
}
s.db.Model(&model.Device{}).Where("id = ?", d.ID).Update("online_status", "offline")
if s.hub != nil {
s.hub.BroadcastDeviceStatus(d.DeviceKey, "offline")
}
}
}
// Stop 停止 MQTT 服务
func (s *MQTTService) Stop() {
if s.client.IsConnected() {
s.client.Unsubscribe(s.topic, "silk/+/+/+/down/cmd")
s.client.Disconnect(500)
}
}
// Publish 发布消息(用于控制命令下发)
func (s *MQTTService) Publish(topic string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
token := s.client.Publish(topic, 1, false, body)
token.Wait()
return token.Error()
}
// TopicOf 生成设备下行命令主题
func (s *MQTTService) TopicOf(deviceKey string) string {
return fmt.Sprintf("%s/%s/%s", s.prefix, deviceKey, s.suffix)
}
// GetIRResult 获取设备最新的红外操作结果
func (s *MQTTService) GetIRResult(deviceKey string) interface{} {
return s.irResults[deviceKey]
}
// GetIRLearnedCodes 获取设备已学习的红外码编号列表
func (s *MQTTService) GetIRLearnedCodes(deviceKey string) []int {
codes := s.irLearnedCodes[deviceKey]
if codes == nil {
return []int{}
}
result := make([]int, 0, len(codes))
for no := range codes {
result = append(result, no)
}
return result
}
// PublishToDevice 向设备发送命令(自动推导下行主题)
// deviceKey 用于查找设备的上行主题,根据主题方向推导命令主题
func (s *MQTTService) PublishToDevice(deviceKey string, payload interface{}) error {
// 检测 learn 命令时记录待学习编号(设备成功响应时 no=0,需用此映射还原)
if data, ok := payload.(map[string]interface{}); ok {
if t, _ := data["type"].(string); t == "infrared" {
if action, _ := data["action"].(string); action == "learn" {
if d, ok := data["data"].(map[string]int); ok {
if no, ok := d["no"]; ok && no > 0 {
s.pendingLearnNo[deviceKey] = no
}
}
}
}
}
topic, ok := s.deviceTopics[deviceKey]
if !ok {
// 内存未命中,从数据库查持久化的主题
var device model.Device
if err := s.db.Select("topic").Where("device_key = ?", deviceKey).First(&device).Error; err == nil {
if device.Topic != nil && *device.Topic != "" {
topic = *device.Topic
s.deviceTopics[deviceKey] = topic // 缓存到内存
}
}
}
if topic == "" {
return fmt.Errorf("设备 %s 的主题未知,等待设备上报后再发送命令", deviceKey)
}
// 根据设备上报主题推导命令主题
// 设备上报 /up/telemetry -> 命令发送到 /down/cmd
// 设备上报 /down/cmd -> 命令发送到 /up/telemetry(反配设备)
var downlinkTopic string
if strings.HasSuffix(topic, "/up/telemetry") {
downlinkTopic = strings.Replace(topic, "/up/telemetry", "/down/cmd", 1)
} else if strings.HasSuffix(topic, "/down/cmd") {
downlinkTopic = strings.Replace(topic, "/down/cmd", "/up/telemetry", 1)
} else {
downlinkTopic = topic // 无法推导,直接用原主题
}
return s.Publish(downlinkTopic, payload)
}
// handleMessage 处理 MQTT 消息
func (s *MQTTService) handleMessage(client mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
var body map[string]interface{}
if err := json.Unmarshal(payload, &body); err != nil {
slog.Warn("MQTT 消息解析失败", "topic", topic, "err", err)
return
}
parts := strings.Split(topic, "/")
deviceKey, _ := body["deviceKey"].(string)
if deviceKey == "" {
// GeekOpen 设备用 mac 或 imei 字段标识
if mac, ok := body["mac"].(string); ok && mac != "" {
deviceKey = mac
}
}
if deviceKey == "" {
// GSCW1M-4G 断路器用 imei 字段标识
if imei, ok := body["imei"].(string); ok && imei != "" {
// 规范化 IMEI:部分设备固件 bug 会将 "6" 误发为 "G"
deviceKey = strings.ReplaceAll(imei, "G", "6")
}
}
if deviceKey == "" && len(parts) > 3 {
deviceKey = parts[3]
}
if deviceKey == "" {
deviceKey = "unknown"
}
// 记录设备的上行主题(用于推导下行命令主题)
s.deviceTopics[deviceKey] = topic
now := time.Now()
// 更新设备在线状态(同时持久化上行主题,防止后端重启后丢失)
s.db.Model(&model.Device{}).Where("device_key = ?", deviceKey).
Updates(map[string]interface{}{"online_status": "online", "last_seen": now, "topic": topic})
// GeekOpen 设备命令响应处理:source=="command" 表示设备回复的命令结果
if source, _ := body["source"].(string); source == "command" {
commandName, _ := body["commandName"].(string)
success, _ := body["success"].(bool)
if !success {
message, _ := body["message"].(string)
slog.Warn("设备命令错误", "deviceKey", deviceKey, "commandName", commandName, "message", message)
return
}
slog.Info("设备命令响应", "deviceKey", deviceKey, "commandName", commandName)
// 包含遥测数据的命令响应
if commandName == "info-all" || commandName == "device-timer-interval" ||
commandName == "info-statistic" || commandName == "controller-event" {
s.extractGSTMB1Metrics(body, deviceKey, now)
}
return
}
// 红外控制器响应处理:type=="infrared" 表示红外操作结果
if msgType, _ := body["type"].(string); msgType == "infrared" {
action, _ := body["action"].(string)
success, _ := body["success"].(bool)
no := 0
if data, ok := body["data"].(map[string]interface{}); ok {
if n, ok := data["no"].(float64); ok {
no = int(n)
}
}
// 设备学习成功时返回 no=0,需用 pendingLearnNo 还原发送时的编号
effectiveNo := no
if action == "learn" && success && no == 0 {
if pendingNo, ok := s.pendingLearnNo[deviceKey]; ok && pendingNo > 0 {
effectiveNo = pendingNo
delete(s.pendingLearnNo, deviceKey)
}
}
result := &IROperationResult{
Action: action,
Success: success,
No: effectiveNo,
Timestamp: now,
}
s.irResults[deviceKey] = result
// 学习成功时记录红外码编号
if action == "learn" && success && effectiveNo > 0 {
if s.irLearnedCodes[deviceKey] == nil {
s.irLearnedCodes[deviceKey] = make(map[int]bool)
}
s.irLearnedCodes[deviceKey][effectiveNo] = true
}
// 擦除全部时清空记录
if action == "erase" && success {
s.irLearnedCodes[deviceKey] = make(map[int]bool)
}
slog.Info("红外操作结果", "deviceKey", deviceKey, "action", action, "success", success, "no", effectiveNo)
return
}
// 提取指标
var events []TelemetryEvent
if metrics, ok := body["metrics"].(map[string]interface{}); ok {
for metric, val := range metrics {
v := toFloat(val)
if !isNaN(v) {
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
}
}
} else if data, ok := body["data"].([]interface{}); ok {
for _, d := range data {
if item, ok := d.(map[string]interface{}); ok {
metric, _ := item["metric"].(string)
v := toFloat(item["value"])
if metric != "" && !isNaN(v) {
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
}
}
}
} else if val, ok := body["value"]; ok {
v := toFloat(val)
if !isNaN(v) {
metric, _ := body["metric"].(string)
if metric == "" {
metric = "value"
}
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
}
} else {
// GSTMB1 定时上报:温度/湿度等字段直接在 body 顶层
events = s.extractGSTMB1Events(body, deviceKey, now)
}
// 持久化 + 事件推送
for _, e := range events {
s.persistTelemetry(e.DeviceKey, e.Metric, e.Value, e.Timestamp)
// 推送到 WebSocket
if s.hub != nil {
s.hub.BroadcastTelemetry(e.DeviceKey, e)
}
// 阈值检测
s.evaluateThresholds(e)
}
}
// extractGSTMB1Metrics 从 GSTMB1 响应中提取遥测指标
func (s *MQTTService) extractGSTMB1Metrics(body map[string]interface{}, deviceKey string, now time.Time) {
events := s.extractGSTMB1Events(body, deviceKey, now)
for _, e := range events {
s.persistTelemetry(e.DeviceKey, e.Metric, e.Value, e.Timestamp)
if s.hub != nil {
s.hub.BroadcastTelemetry(e.DeviceKey, e)
}
s.evaluateThresholds(e)
}
}
// extractGSTMB1Events 从 body 顶层提取 GSTMB1 的温湿度等指标
func (s *MQTTService) extractGSTMB1Events(body map[string]interface{}, deviceKey string, now time.Time) []TelemetryEvent {
// GeekOpen 设备上报的数值型指标字段
metricFields := []string{
// GSTMB1 传感器
"temperature", "humidity", "lux", "co2",
"t_compensate", "h_compensate",
"timerInterval", "timerEnable",
// GSPE1B 智能插座 / GSCW1M-4G 断路器
"voltage", "current", "power", "energy",
"key", "onState", "signal",
"keyLock", "resetLock",
}
var events []TelemetryEvent
for _, field := range metricFields {
if val, ok := body[field]; ok {
v := toFloat(val)
if !isNaN(v) {
events = append(events, TelemetryEvent{
DeviceKey: deviceKey, Metric: field, Value: v, Timestamp: now,
})
}
}
}
return events
}
// persistTelemetry 持久化遥测数据:优先 IoTDB,降级 PG
func (s *MQTTService) persistTelemetry(deviceKey, metric string, value float64, ts time.Time) {
if s.iotdb != nil && s.iotdb.IsAvailable() {
if s.iotdb.InsertTelemetry(deviceKey, metric, value, ts) {
return
}
}
// 降级 PG
t := model.Telemetry{DeviceKey: deviceKey, Metric: metric, Value: value, Timestamp: ts}
if err := s.db.Create(&t).Error; err != nil {
slog.Warn("遥测数据写入 PG 失败", "deviceKey", deviceKey, "metric", metric, "err", err)
}
}
// evaluateThresholds 阈值检测
func (s *MQTTService) evaluateThresholds(e TelemetryEvent) {
// 查设备关联的传感器
var device model.Device
if err := s.db.Where("device_key = ?", e.DeviceKey).First(&device).Error; err != nil {
return
}
var sensor model.Sensor
if err := s.db.Where("device_id = ? AND metric = ?", device.ID, e.Metric).First(&sensor).Error; err != nil {
return
}
// 查启用的阈值
var thresholds []model.Threshold
s.db.Where("sensor_id = ? AND enabled = true", sensor.ID).Find(&thresholds)
for _, t := range thresholds {
isLo := e.Value < t.MinValue
isHi := e.Value > t.MaxValue
if !isLo && !isHi {
// 恢复正常
s.db.Model(&model.Alarm{}).
Where("device_key = ? AND metric = ? AND open = true", e.DeviceKey, e.Metric).
Updates(map[string]interface{}{"open": false, "resolved_at": time.Now()})
if s.hub != nil {
s.hub.BroadcastAlarm(AlarmEvent{
Code: "recovery", Title: e.Metric + " 恢复正常",
DeviceKey: e.DeviceKey, Metric: e.Metric,
})
}
continue
}
// 防抖检查
cooldownKey := t.ID
now := time.Now().UnixMilli()
if last, ok := s.coolDown[cooldownKey]; ok && now-last < int64(t.DebounceSeconds)*1000 {
continue
}
// 检查是否已有 open 告警
var count int64
s.db.Model(&model.Alarm{}).Where("code = ? AND device_key = ? AND open = true",
fmt.Sprintf("%s.%s.%s", e.DeviceKey, e.Metric, loHi(isLo)), e.DeviceKey).Count(&count)
if count > 0 {
continue
}
s.coolDown[cooldownKey] = now
// 创建告警
code := fmt.Sprintf("%s.%s.%s", e.DeviceKey, e.Metric, loHi(isLo))
title := fmt.Sprintf("%s %s阈值", e.Metric, loHiCN(isLo))
msg := fmt.Sprintf("设备 %s 的 %s = %g(阈值 [%g, %g]", e.DeviceKey, e.Metric, e.Value, t.MinValue, t.MaxValue)
severity := fmt.Sprintf("%d", t.Severity)
alarm := model.Alarm{
Code: &code,
Title: &title,
Message: &msg,
Severity: &severity,
Open: true,
Acknowledged: false,
TriggeredAt: time.Now(),
DeviceKey: &e.DeviceKey,
Metric: &e.Metric,
Value: &e.Value,
ThresholdMin: &t.MinValue,
ThresholdMax: &t.MaxValue,
}
if err := s.db.Create(&alarm).Error; err != nil {
slog.Warn("创建告警失败", "err", err)
continue
}
slog.Warn("🔔 ALARM: " + title + " (" + e.DeviceKey + ")")
// 推送到 WebSocket
if s.hub != nil {
s.hub.BroadcastAlarm(AlarmEvent{
Code: code, Title: title, Message: msg,
Severity: severity, DeviceKey: e.DeviceKey, Metric: e.Metric,
Value: e.Value, ThresholdMin: t.MinValue, ThresholdMax: t.MaxValue,
})
}
}
}
func loHi(isLo bool) string {
if isLo {
return "LOW"
}
return "HIGH"
}
func loHiCN(isLo bool) string {
if isLo {
return "低于"
}
return "高于"
}