559 lines
18 KiB
Go
559 lines
18 KiB
Go
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 "高于"
|
||
}
|