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 "高于" }