diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/FireLeave_tool_windows.exe b/FireLeave_tool_windows.exe new file mode 100644 index 0000000..66c7f33 Binary files /dev/null and b/FireLeave_tool_windows.exe differ diff --git a/connect/device_storage.go b/connect/device_storage.go index 11a08dd..578782d 100644 --- a/connect/device_storage.go +++ b/connect/device_storage.go @@ -9,11 +9,191 @@ import ( "strings" ) +// Area 检测区域结构体 +type Area struct { + ID int `json:"id"` + Pts []Point `json:"pts"` +} + +// Point 坐标点结构体 +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// 新的配置结构体 + +// ServiceConfig 服务配置结构体 +type ServiceConfig struct { + HostUUID string `json:"host_uuid"` + DeviceUUID string `json:"device_uuid"` + Address string `json:"address"` + TaskID string `json:"task_id"` + CameraRTSP string `json:"camera_rtsp"` + CameraIP string `json:"camera_ip"` + Confidence int `json:"confidence"` + DetectArea DetectAreaConfig `json:"detect_area"` + Param ParamConfig `json:"param"` +} + +// DetectAreaConfig 检测区域配置 +type DetectAreaConfig struct { + FireLeave []Area `json:"fire_leave"` + FireCheck []Area `json:"fire_check"` +} + +// ParamConfig 参数配置 +type ParamConfig struct { + FireCheck FireCheckParam `json:"fire_check"` + FireLeave FireLeaveParam `json:"fire_leave"` +} + +// FireCheckParam 火检参数 +type FireCheckParam struct { + Confidence string `json:"confidence"` + DetectionTime string `json:"detection_time"` +} + +// FireLeaveParam 离岗参数 +type FireLeaveParam struct { + PersonCount string `json:"person_count"` + Confidence string `json:"confidence"` + DetectionTime string `json:"detection_time"` + TemperatureThreshold string `json:"temperature_threshold"` + AlarmTime string `json:"alarm_time"` +} + var ( devicesBaseDir = "/data/devices" deviceDataList []DeviceData + serviceConfigFile = "/usr/local/etc/service.conf" ) +// LoadServiceConfig loads multiple device data from service config file +func LoadServiceConfig() ([]DeviceData, error) { + if _, err := os.Stat(serviceConfigFile); os.IsNotExist(err) { + logger.Logger.Printf("Service config file does not exist: %s", serviceConfigFile) + return nil, fmt.Errorf("service config file does not exist") + } + + data, err := os.ReadFile(serviceConfigFile) + if err != nil { + logger.Logger.Printf("Failed to read service config file: %v", err) + return nil, err + } + + // Check if file content is empty + if len(data) == 0 { + logger.Logger.Printf("Service config file is empty") + return nil, fmt.Errorf("service config file is empty") + } + + // Parse as ServiceConfig array + var serviceConfigs []ServiceConfig + if err := json.Unmarshal(data, &serviceConfigs); err != nil { + logger.Logger.Printf("Failed to parse service config file: %v", err) + return nil, err + } + + // Convert ServiceConfig to DeviceData + var deviceDataList []DeviceData + const FixedUploadURL = "http://192.168.80.168/api/v1/upload/video" + + for _, config := range serviceConfigs { + // Create DeviceData from ServiceConfig + deviceData := DeviceData{ + DeviceUUID: config.DeviceUUID, + TaskID: config.TaskID, + CameraRTSP: config.CameraRTSP, + CameraIP: config.CameraIP, + Confidence: config.Confidence, + HostUUID: config.HostUUID, + Address: config.Address, + UploadURL: FixedUploadURL, + // Set new fields for new configuration format + FireLeaveDetectArea: config.DetectArea.FireLeave, + FireCheckDetectArea: config.DetectArea.FireCheck, + FireLeaveParam: config.Param.FireLeave, + FireCheckParam: config.Param.FireCheck, + } + + // Parse and set temperature threshold from FireLeaveParam + if tempThresholdStr := config.Param.FireLeave.TemperatureThreshold; tempThresholdStr != "" { + var tempThreshold float64 + if _, err := fmt.Sscanf(tempThresholdStr, "%f", &tempThreshold); err == nil && tempThreshold > 0 { + deviceData.TemperatureThreshold = tempThreshold + logger.Logger.Printf("Set temperature threshold from config: %.1f°C (DeviceUUID=%s)", tempThreshold, config.DeviceUUID) + } + } + + // Filter out configurations with empty DeviceUUID + if deviceData.DeviceUUID != "" { + deviceDataList = append(deviceDataList, deviceData) + } + } + + if len(deviceDataList) == 0 { + logger.Logger.Printf("No valid device configurations found in service config file") + return nil, fmt.Errorf("no valid device configurations") + } + + logger.Logger.Printf("Loaded %d device configurations from service config file", len(deviceDataList)) + return deviceDataList, nil +} + +// ProcessDetectAreaForInference processes detection area data, converts to format required by inference program +func ProcessDetectAreaForInference(areas []Area) string { + if len(areas) == 0 { + return "" + } + + // Only take the first detection area + area := areas[0] + var points []string + + for _, point := range area.Pts { + // Directly round, remove decimals + x := int(point.X) + y := int(point.Y) + points = append(points, fmt.Sprintf("%d,%d", x, y)) + } + + return strings.Join(points, ",") +} + +// ProcessDeviceDataForInference processes device data, converts to format required by inference program +func ProcessDeviceDataForInference(data DeviceData) DeviceData { + // Create a copy, only process fields that need conversion + processed := data + + // For backward compatibility, process original DetectArea if FireLeaveDetectArea is empty + if len(data.FireLeaveDetectArea) > 0 { + processed.DetectAreaStr = ProcessDetectAreaForInference(data.FireLeaveDetectArea) + } else { + processed.DetectAreaStr = ProcessDetectAreaForInference(data.DetectArea) + } + + return processed +} + +// ProcessFireLeaveDataForInference processes device data for fire_leave inference +func ProcessFireLeaveDataForInference(data DeviceData) DeviceData { + processed := data + if len(data.FireLeaveDetectArea) > 0 { + processed.DetectAreaStr = ProcessDetectAreaForInference(data.FireLeaveDetectArea) + } + return processed +} + +// ProcessFireCheckDataForInference processes device data for fire_check inference +func ProcessFireCheckDataForInference(data DeviceData) DeviceData { + processed := data + if len(data.FireCheckDetectArea) > 0 { + processed.DetectAreaStr = ProcessDetectAreaForInference(data.FireCheckDetectArea) + } + return processed +} + func InitializeDevicesFromServiceConfig() ([]DeviceData, error) { deviceDataMutex.Lock() defer deviceDataMutex.Unlock() @@ -94,8 +274,10 @@ func cleanDeviceData(data DeviceData) DeviceData { if cleaned.Temperature < 0 { cleaned.Temperature = 0 } - if cleaned.TemperatureThreshold <= 0 { + // 只有当温度阈值真正为0时才设置默认值45.0°C,避免覆盖用户配置的值 + if cleaned.TemperatureThreshold == 0 { cleaned.TemperatureThreshold = 45.0 + logger.Logger.Printf("Set default temperature threshold: 45.0°C (DeviceUUID=%s)", cleaned.DeviceUUID) } return cleaned @@ -195,3 +377,46 @@ func UpdateDeviceData(data DeviceData) error { logger.Logger.Printf("Device data update completed: DeviceUUID=%s", cleanedData.DeviceUUID) return nil } + +// SaveServiceConfig saves multiple device configurations to service config file +func SaveServiceConfig(deviceDataList []DeviceData) error { + configData, err := json.MarshalIndent(deviceDataList, "", " ") + if err != nil { + logger.Logger.Printf("Failed to serialize service configuration: %v", err) + return err + } + + if err := os.WriteFile(serviceConfigFile, configData, 0644); err != nil { + logger.Logger.Printf("Failed to write service config file: %v", err) + return err + } + + logger.Logger.Printf("Service configuration saved to: %s, %d devices", serviceConfigFile, len(deviceDataList)) + return nil +} + +// SaveSingleServiceConfig saves single device configuration (compatible with existing calls) +func SaveSingleServiceConfig(deviceData DeviceData) error { + // First read existing configuration + existingConfigs, err := LoadServiceConfig() + if err != nil { + // If reading fails, create new configuration list + existingConfigs = []DeviceData{deviceData} + } else { + // Find if configuration with same DeviceUUID already exists + found := false + for i, existing := range existingConfigs { + if existing.DeviceUUID == deviceData.DeviceUUID { + existingConfigs[i] = deviceData + found = true + break + } + } + // If not exists, add to list + if !found { + existingConfigs = append(existingConfigs, deviceData) + } + } + + return SaveServiceConfig(existingConfigs) +} diff --git a/connect/heartbeat.go b/connect/heartbeat.go index 99d0256..f8f3a41 100644 --- a/connect/heartbeat.go +++ b/connect/heartbeat.go @@ -4,10 +4,12 @@ import ( "fmt" "os" "os/exec" + "strings" "sync" "time" "FireLeave_tool/logger" + "FireLeave_tool/portmanager" ) var ( @@ -20,7 +22,12 @@ var ( // UpdateInferenceHeartbeat 每收到一帧 /video/post 就调用这个 func UpdateInferenceHeartbeat(deviceUUID string) { inferenceLastHeartbeat.Store(deviceUUID, time.Now()) - + // 输出心跳日志 + if strings.Contains(deviceUUID, "_fire_check") { + logger.Logger.Printf("♥ 推理心跳收到 | 时间: %s | UUID: %s | 类型: fire_check", time.Now().Format("2006/01/02 15:04:05.000"), deviceUUID) + } else { + logger.Logger.Printf("♥ 推理心跳收到 | 时间: %s | UUID: %s | 类型: fire_leave", time.Now().Format("2006/01/02 15:04:05.000"), deviceUUID) + } } // ShouldRestartInference 判断是否需要重启推理程序 @@ -48,41 +55,84 @@ func StopInferenceProcess(deviceUUID string) { // StartInferenceProcess 重启推理程序(保持原端口!) func StartInferenceProcess(deviceData *DeviceData) { + // 重启fire_leave推理程序 StopInferenceProcess(deviceData.DeviceUUID) - port, err := GlobalPortManager.GetPort(deviceData.DeviceUUID) + portFireLeave, err := portmanager.GlobalPortManager.GetPort(deviceData.DeviceUUID) if err != nil { - logger.Logger.Printf("获取端口失败,无法重启推理程序 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + logger.Logger.Printf("获取fire_leave端口失败,无法重启推理程序 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) return } - detectAreaStr := ProcessDetectAreaForInference(deviceData.DetectArea) + // 重启fire_check推理程序 + StopInferenceProcess(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) - inferenceCmd := exec.Command( + portFireCheck, err := portmanager.GlobalPortManager.GetPort(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) + if err != nil { + logger.Logger.Printf("获取fire_check端口失败,无法重启推理程序 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + return + } + + // 为fire_leave推理程序获取检测区域 + fireLeaveDetectAreaStr := ProcessDetectAreaForInference(deviceData.FireLeaveDetectArea) + + // 启动fire_leave推理程序 + inferenceCmdFireLeave := exec.Command( "./yolov5", "-s", deviceData.CameraRTSP, "-m", "model", "-c", deviceData.DeviceUUID, - "-p", fmt.Sprintf("%d", port), - "-w", detectAreaStr, + "-p", fmt.Sprintf("%d", portFireLeave), + "-w", fireLeaveDetectAreaStr, "-r", fmt.Sprintf("%d", deviceData.Confidence), ) - inferenceCmd.Stdout = os.Stdout - inferenceCmd.Stderr = os.Stderr + inferenceCmdFireLeave.Stdout = os.Stdout + inferenceCmdFireLeave.Stderr = os.Stderr - logger.Logger.Printf("正在重启推理程序 (使用原端口 %d): %v", port, inferenceCmd.Args) + logger.Logger.Printf("正在重启fire_leave推理程序 (使用原端口 %d): %v", portFireLeave, inferenceCmdFireLeave.Args) - if err := inferenceCmd.Start(); err != nil { - logger.Logger.Printf("推理程序重启失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + if err := inferenceCmdFireLeave.Start(); err != nil { + logger.Logger.Printf("fire_leave推理程序重启失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) return } - // 保存进程对象 - InferenceProcesses.Store(deviceData.DeviceUUID, inferenceCmd) - logger.Logger.Printf("推理程序已成功重启,PID=%d (DeviceUUID=%s, Port=%d)", - inferenceCmd.Process.Pid, deviceData.DeviceUUID, port) + // 保存fire_leave进程对象 + InferenceProcesses.Store(deviceData.DeviceUUID, inferenceCmdFireLeave) + logger.Logger.Printf("fire_leave推理程序已成功重启,PID=%d (DeviceUUID=%s, Port=%d)", + inferenceCmdFireLeave.Process.Pid, deviceData.DeviceUUID, portFireLeave) + + // 为fire_check推理程序获取检测区域 + fireCheckDetectAreaStr := ProcessDetectAreaForInference(deviceData.FireCheckDetectArea) + + // 启动fire_check推理程序 + inferenceCmdFireCheck := exec.Command( + "./yolov5", + "-s", deviceData.CameraRTSP, + "-m", "fire_check", + "-c", fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID), + "-p", fmt.Sprintf("%d", portFireCheck), + "-w", fireCheckDetectAreaStr, + "-r", fmt.Sprintf("%d", deviceData.Confidence), + "-R", + ) + + inferenceCmdFireCheck.Stdout = os.Stdout + inferenceCmdFireCheck.Stderr = os.Stderr + + logger.Logger.Printf("正在重启fire_check推理程序 (使用原端口 %d): %v", portFireCheck, inferenceCmdFireCheck.Args) + + if err := inferenceCmdFireCheck.Start(); err != nil { + logger.Logger.Printf("fire_check推理程序重启失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + return + } + + // 保存fire_check进程对象 + InferenceProcesses.Store(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID), inferenceCmdFireCheck) + logger.Logger.Printf("fire_check推理程序已成功重启,PID=%d (DeviceUUID=%s, Port=%d)", + inferenceCmdFireCheck.Process.Pid, deviceData.DeviceUUID, portFireCheck) // 重启后立即刷新一次心跳,防止 30s 检测又触发 UpdateInferenceHeartbeat(deviceData.DeviceUUID) + UpdateInferenceHeartbeat(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) } diff --git a/connect/ws_channel.go b/connect/ws_channel.go index 14c4674..45513fc 100644 --- a/connect/ws_channel.go +++ b/connect/ws_channel.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" - "strings" "sync" "time" @@ -49,9 +47,7 @@ func (c *WSClient) IsConnected() bool { return c.connected } -const ( - serviceConfigFile = "/usr/local/etc/service.conf" -) + // DeviceData struct type DeviceData struct { @@ -74,6 +70,11 @@ type DeviceData struct { AlarmPauseUntil int64 `json:"-"` // Pause alarm untilw HostUUID string `json:"host_uuid"` Address string `json:"address"` + // New fields for new configuration format + FireLeaveDetectArea []Area `json:"fire_leave_detect_area,omitempty"` + FireCheckDetectArea []Area `json:"fire_check_detect_area,omitempty"` + FireLeaveParam FireLeaveParam `json:"fire_leave_param,omitempty"` + FireCheckParam FireCheckParam `json:"fire_check_param,omitempty"` } type InferenceParams struct { @@ -85,16 +86,6 @@ type InferenceParams struct { Port int `json:"port"` // HTTP server port } -type Area struct { - ID int `json:"id"` - Pts []Point `json:"pts"` -} - -type Point struct { - X float64 `json:"x"` - Y float64 `json:"y"` -} - // WSMessage struct type WSMessage struct { ID int64 `json:"id,omitempty"` @@ -584,126 +575,11 @@ func (d *DeviceData) ToInferenceParams(port int) InferenceParams { } } -// SaveServiceConfig saves multiple device configurations to service config file -func SaveServiceConfig(deviceDataList []DeviceData) error { - configData, err := json.MarshalIndent(deviceDataList, "", " ") - if err != nil { - logger.Logger.Printf("Failed to serialize service configuration: %v", err) - return err - } - if err := os.WriteFile(serviceConfigFile, configData, 0644); err != nil { - logger.Logger.Printf("Failed to write service config file: %v", err) - return err - } - logger.Logger.Printf("Service configuration saved to: %s, %d devices", serviceConfigFile, len(deviceDataList)) - return nil -} -// SaveSingleServiceConfig saves single device configuration (compatible with existing calls) -func SaveSingleServiceConfig(deviceData DeviceData) error { - // First read existing configuration - existingConfigs, err := LoadServiceConfig() - if err != nil { - // If reading fails, create new configuration list - existingConfigs = []DeviceData{deviceData} - } else { - // Find if configuration with same DeviceUUID already exists - found := false - for i, existing := range existingConfigs { - if existing.DeviceUUID == deviceData.DeviceUUID { - existingConfigs[i] = deviceData - found = true - break - } - } - // If not exists, add to list - if !found { - existingConfigs = append(existingConfigs, deviceData) - } - } - return SaveServiceConfig(existingConfigs) -} -// LoadServiceConfig loads multiple device data from service config file -func LoadServiceConfig() ([]DeviceData, error) { - if _, err := os.Stat(serviceConfigFile); os.IsNotExist(err) { - logger.Logger.Printf("Service config file does not exist: %s", serviceConfigFile) - return nil, fmt.Errorf("service config file does not exist") - } - - data, err := os.ReadFile(serviceConfigFile) - if err != nil { - logger.Logger.Printf("Failed to read service config file: %v", err) - return nil, err - } - - // Check if file content is empty - if len(data) == 0 { - logger.Logger.Printf("Service config file is empty") - return nil, fmt.Errorf("service config file is empty") - } - - // Parse as DeviceData array - var deviceDataList []DeviceData - if err := json.Unmarshal(data, &deviceDataList); err != nil { - logger.Logger.Printf("Failed to parse service config file: %v", err) - return nil, err - } - - // Filter out configurations with empty DeviceUUID - var validDeviceDataList []DeviceData - for _, deviceData := range deviceDataList { - if deviceData.DeviceUUID != "" { - validDeviceDataList = append(validDeviceDataList, deviceData) - } - } - const FixedUploadURL = "http://192.168.80.168/api/v1/upload/video" - for i := range validDeviceDataList { - if validDeviceDataList[i].UploadURL == "" { - validDeviceDataList[i].UploadURL = FixedUploadURL - logger.Logger.Printf("Auto-completed UploadURL: %s (DeviceUUID=%s)", - FixedUploadURL, validDeviceDataList[i].DeviceUUID) - } - } - if len(validDeviceDataList) == 0 { - logger.Logger.Printf("No valid device configurations found in service config file") - return nil, fmt.Errorf("no valid device configurations") - } - - logger.Logger.Printf("Loaded %d device configurations from service config file", len(validDeviceDataList)) - return validDeviceDataList, nil - -} - -// ProcessDetectAreaForInference processes detection area data, converts to format required by inference program -func ProcessDetectAreaForInference(areas []Area) string { - if len(areas) == 0 { - return "" - } - - // Only take the first detection area - area := areas[0] - var points []string - - for _, point := range area.Pts { - // Directly round, remove decimals - x := int(point.X) - y := int(point.Y) - points = append(points, fmt.Sprintf("%d,%d", x, y)) - } - - return strings.Join(points, ",") -} - -func ProcessDeviceDataForInference(data DeviceData) DeviceData { - // Create a copy, only process fields that need conversion - processed := data - processed.DetectAreaStr = ProcessDetectAreaForInference(data.DetectArea) - return processed -} func (c *WSClient) SendAsync(message interface{}) { go func() { diff --git a/docs/2026-05-11_review_and_ws_migration/ws_protocol_migration.md b/docs/2026-05-11_review_and_ws_migration/ws_protocol_migration.md new file mode 100644 index 0000000..413744e --- /dev/null +++ b/docs/2026-05-11_review_and_ws_migration/ws_protocol_migration.md @@ -0,0 +1,355 @@ +# WebSocket 协议改造分析 + +日期:2026-05-11 + +参考文档: +- `https://gitea.s5s8.com/anlicheng/ekfa/raw/branch/protobuf_mst/docs/service_websocket_protocol.md` + +## 1. 当前项目的通讯方式 + +当前实现文件: +- `connect/ws_channel.go` +- `reporter/reporter.go` +- `main.go` + +当前协议特征: + +1. 传输层 +- WebSocket 文本帧 +- 使用 JSON 编码 + +2. 请求响应模型 +- 统一消息结构:`{id, method, params, result, error}` +- 当前请求方法主要有: + - `register` + - `subscribe` +- 当前推送/上报主要有: + - `metric_data` + - `publish` + +3. 注册逻辑 +- 客户端连接到 `ws://172.17.0.1:18080/ws` +- 发送 `register` +- 参数包含: + - `container_name` + - `service_id` + - `meta_data` + +4. 订阅逻辑 +- 发送 `subscribe` +- 参数为: + - `topic` + +5. 下行消息 +- 服务端下发 `publish` +- `params` 中当前代码按如下字段解析: + - `topic` + - `data` + - `content` + +6. 上行数据上报 +- 业务侧通过: + - `method = "metric_data"` + - `params.route_key` + - `params.metric` +- `reporter` 中事件与数据快照上报都走这个逻辑 + +7. 心跳与重连 +- 客户端主动发 WebSocket ping frame +- 自己维护重连、重注册、重订阅 + +## 2. 目标协议的通讯方式 + +根据新文档,目标协议特征如下: + +1. 传输层 +- WebSocket binary frame +- 每帧格式: + - `1 byte frame_type` + - `N bytes protobuf payload` + +2. 帧类型 +- `0x01`:`REQUEST` +- `0x02`:`REPLY` +- `0x03`:`CAST` + +3. protobuf 消息 +- `ServiceRequest` +- `ServiceReply` +- `ServiceCast` + +4. request/reply +- `register` + - 参数只有 `service_id` +- `subscribe` + - 参数只有 `topic` +- `reply` + - 成功:`result = "ok"` + - 失败:`error.code = -1` + +5. cast +- service -> efka + - `metric_data` + - 字段: + - `route_key: bytes` + - `metric: bytes` +- efka -> service + - `topic_event` + - 字段: + - `topic: string` + - `content: bytes` + +6. 注册前约束 +- 未注册前只允许 `register` +- 其它 request 返回 `invalid request` +- cast 会被忽略 + +7. ping/pong +- Cowboy 直接处理 ping/pong +- service 侧无需再设计额外业务层心跳协议 + +## 3. 当前与目标协议的核心差异 + +## 编码层差异 + +当前: +- JSON +- 文本帧 + +目标: +- protobuf +- 二进制帧 + +影响: +- `connect/ws_channel.go` 的读写逻辑要整体重写,不能局部修补 + +## 消息模型差异 + +当前: +- 通用 JSON RPC 风格:`id + method + params/result/error` + +目标: +- 分离为 `REQUEST / REPLY / CAST` +- 且由帧头字节决定消息类型 + +影响: +- 现有 `WSMessage` 结构体要废弃或降级为兼容层 + +## 注册参数差异 + +当前: +- `register(container_name, service_id, meta_data)` + +目标: +- `register(service_id)` + +影响: +- `RegisterParams` 需要改 +- `container_name`、`meta_data` 不应继续按当前协议发送 + +## 下行消息差异 + +当前: +- 服务端下发 `publish` +- 业务代码把 `publish.params` 再反序列化 + +目标: +- 服务端下发 `CAST topic_event` + +影响: +- `handlePublishMessage()` 这类 JSON `publish` 解析流程要删除 +- 新增 `topic_event` 的 protobuf 解码与路由 + +## 上行上报差异 + +当前: +- `method = metric_data` +- `params.route_key` +- `params.metric` + +目标: +- `CAST metric_data` +- `route_key` 和 `metric` 都是 `bytes` + +影响: +- `reporter` 发消息时不再构造 JSON RPC +- 要构造 `ServiceCast.metric_data` + +## reply 相关差异 + +当前: +- 请求响应靠 JSON 的 `id` + +目标: +- `packet_id` +- 且只存在于 `ServiceRequest / ServiceReply` + +影响: +- 当前 `callbacks map[int64]chan *WSMessage` 机制可以保留思路 +- 但要替换成 protobuf `packet_id` 和 `ServiceReply` + +## 4. 当前项目中需要改动的代码点 + +## A. `connect/ws_channel.go` 需要整体重构 + +需要改动: + +1. 连接保持不变 +- 仍然连 `/ws` + +2. 写消息逻辑 +- 现在 `SendRawMessage()` 直接 `json.Marshal()` 后发 `TextMessage` +- 需要改成: + - protobuf 序列化 + - 前面拼接 `frame_type` + - 发 `BinaryMessage` + +3. 请求发送逻辑 +- 现在 `sendRequest(method, params, waitResponse)` 是 JSON 风格 +- 需要改成: + - 组 `ServiceRequest` + - 填 `packet_id` + - body 选择 `register` 或 `subscribe` + - 发 `0x01 + protobuf(ServiceRequest)` + +4. 读消息逻辑 +- 现在 `readLoop()` 直接把整帧当 JSON 解码 +- 需要改成: + - 读取二进制帧 + - 取第一个字节判断帧类型 + - `0x02` 解码为 `ServiceReply` + - `0x03` 解码为 `ServiceCast` + +5. 下行消息分发 +- 现在按 `message.Method == "publish"` 处理 +- 需要改成按 `ServiceCast.body` + - `topic_event` + - 后续如协议扩展再加别的 cast + +6. 回调等待机制 +- 现在按 `id` 关联回调 +- 需要改成按 `packet_id` + +## B. 注册逻辑需要调整 + +当前: +- `RegisterService(containerName string)` +- 发送 `container_name + service_id + meta_data` + +目标: +- 仅发送 `service_id` + +建议: +- 把 `container_name` 从协议层移除 +- `service_id` 建议继续使用当前生成逻辑,但需确认上层是否有固定命名要求 + +## C. 订阅逻辑需要调整 + +当前: +- 发送 JSON `subscribe` + +目标: +- 发送 protobuf `ServiceRequest.Subscribe` + +建议: +- `SubscribeTopic()` 对外接口可以保留 +- 内部实现改为 protobuf request + +## D. 上报逻辑需要调整 + +当前: +- `reporter.ReportEvent()` +- `reporter.ReportDataSnapshot()` +- `GenerateEventMessage()` +- `GenerateDataMessage()` +- 最终都构造成 JSON `metric_data` + +目标: +- 最终发送 protobuf `ServiceCast.metric_data` + +建议: +- `GenerateEventMessage()` / `GenerateDataMessage()` 不一定保留 map 结构 +- 更合理的是: + - 先生成业务 payload JSON 字符串 + - 再把该字符串作为 `metric_data.metric` 的 bytes 内容 + - `route_key` 也按 bytes 写入 protobuf + +## E. 下行配置/告警处理需要调整 + +当前: +- 依赖 `publish.params.topic/data/content` +- 并把 `data` 当 `DeviceData` 解析 + +目标: +- 依赖 `topic_event.topic/content` +- `content` 是 bytes + +建议: +- 重新梳理每个 topic 的 payload 格式 +- 尤其要确认: + - `/dhlr/alert` + - `/dhlr/forbid_time` + - 设备配置下发 topic +- 不能再默认存在 `publish.data` + +## 5. 改造时需要确认的外部问题 + +这些问题代码里无法自行推断,需要和上层协议一起确认: + +1. 设备配置下发现在走哪个 topic +- 当前代码默认任何 `publish.data` 都可能是 `DeviceData` +- 新协议文档只定义了 `topic_event(topic, content)`,没定义具体业务 topic 的 payload schema + +2. `content` 的业务内容格式是什么 +- 是 JSON bytes +- 还是 protobuf bytes +- 还是普通字符串 + +3. `service_id` 的命名规则 +- 现有代码自动生成 `fireleave-service-` +- 新链路是否要求固定 `service_id` + +4. topic 命名是否需要改 +- 当前项目使用: + - `/dhlr/alert` + - `/dhlr/forbid_time` + - `/dhlr/device//event` + - `/dhlr/device//data` +- 文档中的示例 topic 风格更像: + - `device/a/event` + - `device/*/event` +- 需要确认上层真实路由规范 + +5. 是否还需要客户端主动 ping +- 文档说明服务端会直接回复 ping/pong +- 但没有明确要求客户端是否必须主动发 ping +- 当前保留主动 ping 一般没问题,但需要确认是否有重复机制 + +## 6. 建议的改造顺序 + +1. 先生成 `service.proto` 的 Go 代码 +2. 新建独立的 protobuf WebSocket 客户端层 +3. 保留现有上层接口: + - `RegisterService` + - `SubscribeTopic` + - `SendAsync` +4. 先把协议层替换掉,不动业务层调用点 +5. 再改下行 `topic_event` 到业务 topic 的解析分发 +6. 最后联调配置下发、事件上报、重连重订阅 + +## 7. 对当前项目的直接结论 + +这次协议改造不是小修小补,而是通讯层整体替换,最核心的改动文件会是: + +- `connect/ws_channel.go` +- `reporter/reporter.go` +- 可能新增: + - `connect/protocol/` + - `connect/pb/` + +同时,当前项目里“配置更新不生效”的问题建议和这次协议改造一起处理,否则即使新协议打通,设备配置下发链路也还是不完整。 + + +1.我的业务逻辑是这样的,我是末端的微服务,我负责采集数据然后上报数据,我的业务是安防的,我这边分析发现危险情况然后上报给上层,而forbid_time和alert是我联动的硬件侧的声光报警器,他们分别区分的是报警间隔时间和报警信息。我对你这句话的理解,你认为topic是对多个微服务或者设备的一个共同网络,如果改动了topic_event(topic, content)我订阅这个topic的所有设备都会触发声光报警是吗? +2.对的,主要是外层传输协议的改动。 +3.可能是你不清楚现在的运行环境,我先描述一下现在的业务环境, \ No newline at end of file diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..e4ee38a --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,70 @@ +# TODO + +日期:2026-05-11 + +## 第一阶段 + +1. 统一端口管理器 +- 明确保留哪一套端口管理器作为唯一实现 +- 替换重启链路里的端口获取逻辑 +- 校验 `fire_leave`、`fire_check`、测温端口关系 +- 清理废弃实现和重复状态 + +2. 修复 `service.conf` schema +- 明确 `service.conf` 的唯一 schema +- 增加 `DeviceData -> ServiceConfig` 转换 +- 重写 `SaveServiceConfig()` / `SaveSingleServiceConfig()` +- 检查已有启动流程兼容性 + +3. 修复 `fire_leave` 参数映射 +- 明确 `fire_leave` 运行时依赖字段 +- 补齐 `param.fire_leave` 到运行字段的映射 +- 清理主流程里对默认值的隐式依赖 +- 复核 `confidence` 是否要区分双模型 + +## 第二阶段 + +1. 接入 `/dhlr/forbid_time` 到 `fire_leave` 暂停上报 +- 明确 `/dhlr/forbid_time` 最终字段语义 +- 增加 `fire_leave` 暂停状态存储 +- 在 `fire_leave` 主判断前接入暂停窗口检查 +- 清理或降级 `reporter` 中不再适用的事件周期逻辑 + +2. 调整 GPIO 按钮逻辑 +- 确认按钮最终由谁决定暂停时长 +- 确认按钮是本地触发还是必须等待上层下发 +- 移除与“事件周期切换”强耦合的旧逻辑 + +## 第三阶段 + +1. 改造 WebSocket 协议层为 protobuf binary frame +- 引入 `service.proto` 对应的 Go 类型 +- 重写 websocket 读写帧逻辑 +- 重写 request/reply 关联逻辑 +- 重写下行 `topic_event` 分发逻辑 +- 重写上行 `metric_data` 发送逻辑 + +2. 保留主动 ping 和失败重连 +- 在新协议层中保留 ping loop +- 保留 ping 失败即重连逻辑 +- 复核重连后 register 和 subscribe 恢复是否完整 + +## 暂不处理 + +1. 业务上报 ACK +- 暂不增加业务 ACK +- 协议改造时保留现有单向上报模式 +- 作为已知风险保留在 `整改计划.md` + +2. 运行中配置热更新链路 +- 判断当前业务是否真的需要运行中热更新 +- 如果需要,后续再设计完整消费链路 +- 如果不需要,后续再移除误导性代码 + +## 收尾 + +1. 增加最小验证 +- 增加协议层编解码基础测试 +- 增加配置映射测试 +- 增加报警暂停窗口逻辑测试 +- 条件允许时补最小集成测试 diff --git a/docs/业务逻辑梳理.md b/docs/业务逻辑梳理.md new file mode 100644 index 0000000..e5a7d15 --- /dev/null +++ b/docs/业务逻辑梳理.md @@ -0,0 +1,528 @@ +# 动火离人微服务业务逻辑梳理 + +本文档基于当前代码整理,目标是帮助快速回忆这个微服务现在“实际在做什么”,以及哪些逻辑是设计上存在但当前代码里已经弱化或未真正生效的。 + +## 1. 项目定位 + +当前仓库是“动火离人”项目中的微服务管理程序,职责不是前端展示,也不是后端总控,而是: + +- 接收后端/平台下发的设备配置 +- 为每个设备启动并管理本地推理程序 +- 接收推理结果和测温结果 +- 按规则判断是否触发报警 +- 回溯报警前后视频,拼接并上传 +- 通过 WebSocket 将报警事件和数据快照发给后端,再由后端转给前端 + +整体链路可以理解为: + +`前端配置 -> 后端下发配置 -> 微服务启动/管理推理 -> 推理结果回流微服务 -> 微服务判定报警 -> 上传视频 + websocket上报 -> 后端转发前端展示` + +## 2. 当前代码里的三类核心进程 + +每个设备启动时,实际上会拉起 3 条本地能力: + +1. `fire_leave` 推理程序 + - 模型参数:`./yolov5 -m model` + - 主要用途:统计人员数量 + - 结果通过本地 HTTP 服务回传 + +2. `fire_check` 推理程序 + - 模型参数:`./yolov5 -m fire_check -R` + - 主要用途:检测火焰 + - 结果通过另一套本地 HTTP 服务回传 + +3. 测温程序 + - 启动脚本:`./release/run_thermometry.sh` + - 结果通过本地 TCP 服务回传 + +也就是说,当前“动火离人”并不是单模型,而是: + +- 一套模型做人数量判断 +- 一套模型做火焰检测 +- 一套测温程序提供温度值 + +## 3. 配置来源与初始化 + +### 3.1 启动时配置来源 + +程序启动后先从 `/usr/local/etc/service.conf` 读取设备配置。 + +配置结构已经是多设备形式,核心字段包括: + +- `device_uuid` +- `task_id` +- `camera_rtsp` +- `camera_ip` +- `confidence` +- `detect_area.fire_leave` +- `detect_area.fire_check` +- `param.fire_leave` +- `param.fire_check` + +其中: + +- `fire_leave` 检测区给人员检测使用 +- `fire_check` 检测区给火焰检测使用 +- 两套检测区彼此独立 + +### 3.2 配置落盘 + +初始化后,每个设备还会落盘到: + +- `/data/devices/device_{deviceUUID}.json` + +用于后续读取和状态持久化。 + +## 4. 每个设备启动时的完整流程 + +`main.go` 中每个设备会进入 `processDevice()`,当前流程如下: + +1. 设备加入全局 `DeviceManager` +2. 为 `fire_leave` 分配一个端口 +3. 为 `fire_check` 再分配一个端口 +4. 分别启动两个本地 HTTP 接收服务 +5. 等待 HTTP `/health` 就绪 +6. 把前端配置的检测区域转换成推理程序需要的点串格式 +7. 启动 `fire_leave` 推理进程 +8. 启动 `fire_check` 推理进程 +9. 启动测温程序 +10. 等待测温 TCP 服务就绪 +11. 创建 TCP 客户端读取温度 +12. 进入主循环,持续处理: + - `fire_leave` 推理结果 + - `fire_check` 推理结果 + - 周期性温度读取和报警判定 + +## 5. 两条检测链路的当前职责 + +## 5.1 fire_leave 链路 + +当前代码里,`fire_leave` 推理结果主要只负责更新人数。 + +处理逻辑: + +- 微服务接收 `/video/post` +- 解析 `params` +- 找 `ClassIdx == 1` +- 将 `Number` 作为当前人数 `PersonCount` +- 写回设备状态 + +也就是说,当前“离人”判断本身不是由模型直接报“报警/不报警”,而是: + +- 模型只提供人数 +- 微服务结合温度再做二次判定 + +## 5.2 fire_check 链路 + +当前代码里,`fire_check` 推理结果专门负责火焰报警。 + +处理逻辑: + +- 微服务接收 `fire_check` 的 `/video/post` +- 遍历结果 +- 当 `ClassIdx == 0 && Number > 0` 时,认为检测到火焰 +- 连续满足 `detection_time` 秒后触发火焰报警 + +火焰报警完全独立于“无人 + 高温”逻辑,不依赖温度和人数。 + +## 6. 动火离人报警的当前判定逻辑 + +这是你现在代码里的主报警逻辑,运行在定时器分支里,每 2 秒检查一次。 + +### 6.1 输入条件 + +判定使用两个实时量: + +- `PersonCount == 0` +- `Temperature > TemperatureThreshold` + +其中温度还做了一个简化的防抖: + +- 高于阈值 `+0.5`:稳定超温 +- 低于阈值 `-0.5`:稳定未超温 +- 落在中间:按当前是否大于阈值判断 + +### 6.2 触发条件 + +只有同时满足以下条件才会计时: + +- 无人 +- 温度超阈值 + +满足后开始累计持续时间,达到 `DetectionTime` 后触发报警。 + +默认值: + +- `DetectionTime` 默认 `20` 秒 +- `AlarmTime` 默认 `300` 秒 + +### 6.3 冷却时间 + +报警触发后会进入冷却: + +- `AlarmPauseUntil = now + AlarmTime` + +冷却期内,即使条件继续满足,也不会重复触发。 + +### 6.4 当前本质 + +当前“动火离人报警”实际规则可以概括成一句话: + +`人员检测结果为 0 + 测温超过阈值 + 持续达到设定秒数 -> 触发 fire_leave 报警` + +## 7. 火焰检测报警的当前判定逻辑 + +火焰检测走另一套状态机。 + +### 7.1 输入条件 + +- `fire_check` 结果中出现 `ClassIdx == 0 && Number > 0` + +### 7.2 持续时间 + +持续时间取自: + +- `deviceData.FireCheckParam.DetectionTime` + +默认值: + +- 如果没配,默认 `2` 秒 + +### 7.3 触发结果 + +持续检测到火焰达到时长后,触发: + +- `GlobalReporter.TriggerFireCheckAlarm(deviceData, alarmTime)` + +### 7.4 当前本质 + +当前“火焰检测报警”可以概括成: + +`火焰模型连续检测到火焰达到设定秒数 -> 触发 fire_check 报警` + +## 8. 报警后做了什么 + +无论是 `fire_leave` 还是 `fire_check`,触发后总体都会走这几步: + +1. 防止同一告警重复处理 +2. 去录像目录找报警前后视频 +3. 拼接成一个完整 MP4 +4. 复制到 `/data/upload` +5. 上传到 Nginx/后端文件接口 +6. 通过 WebSocket 上报事件消息 +7. 再上报一份数据快照消息 + +## 9. 视频回溯与拼接逻辑 + +当前录像目录: + +- 普通动火离人视频:`/usr/data/camera/{deviceUUID}` +- 火焰检测视频:`/usr/data/camera/{deviceUUID}_fire_check` + +拼接逻辑: + +1. 先把报警时间对齐到最近的 10 秒片段 +2. 取 `前10秒`、`当前10秒`、`后10秒` 三段视频 +3. 如果缺段,会从同目录找最接近的 mp4 作为替代 +4. 至少要找到 2 段视频才继续 +5. 用 `mp4_merge` 进行拼接 +6. 输出到 `/data/upload/{deviceUUID}-{日期-时间}.MP4` + +所以当前上报视频本质上是“报警时刻前后约 30 秒的回溯片段”。 + +## 10. WebSocket 在当前系统里的作用 + +WebSocket 地址写死为: + +- `ws://172.17.0.1:18080/ws` + +主要承担 4 件事: + +1. 服务注册 +2. 订阅平台 topic +3. 接收配置更新 +4. 上报事件与数据 + +### 10.1 启动后注册 + +启动后会注册一个服务: + +- `containerName = fireleave-container` +- `serviceID = fireleave-service-{timestamp}` + +### 10.2 订阅的 topic + +当前代码里会订阅: + +- `/dhlr/alert` +- `/dhlr/forbid_time` + +### 10.3 当前业务上真正需要接收的不是设备配置 + +这里要和“设备配置来源”明确区分。 + +设备基础配置当前应以本地文件为准: + +- `/usr/local/etc/service.conf` + +也就是说: + +- 摄像头 +- 检测区域 +- 置信度 +- 各类基础参数 + +这些设备配置不是依赖 WebSocket 实时下发来生效,而是直接读取本地 `service.conf`。 + +WebSocket 在这块更应该承担的是“控制类消息”的接收,例如: + +- 报警暂停时长 +- 延迟报警/允许离岗时长 +- 声光报警控制 + +补充说明: + +- 当前代码里 `ws_channel.go` 仍保留了把 `publish.data` 解析成 `DeviceData` 并写回本地的逻辑 +- 但按你确认后的业务,这部分不应视为当前核心业务链路 +- 也就是说,代码现状和业务认知这里存在偏差,文档以你刚确认的业务为准 + +## 11. 按钮状态控制的当前真实逻辑 + +这是你提到“有点忘了”的重点之一。 + +当前 GPIO 按钮监控在 `gpio_monitor` 包里,监听: + +- 按钮 GPIO:`111` +- 蜂鸣器 GPIO:`109` + +### 11.1 按钮不是启动/停止按钮 + +当前业务里,按钮按下后不会: + +- 启动推理 +- 停止推理 +- 直接触发报警 + +按钮的真实业务含义是: + +- 临时关闭“人员离开”这一类报警的上报 +- 但火焰检测报警仍然保持正常上报 + +### 11.2 为什么要有这个按钮 + +这是为了解决现场的特殊用工场景。 + +例如后厨煲汤: + +- 厨师需要很早开始生火 +- 但不可能在整个过程中始终守在灶台旁 +- 现场管理允许短时间离开 +- 如果仍严格套用“动火离人”规则,就会不断产生不符合现场管理实际的报警 + +所以按钮的业务意义是: + +- 在允许短暂离岗的场景下,临时屏蔽人员离开报警 +- 同时保留火焰检测能力,避免真正的火焰风险漏报 + +### 11.3 按钮按下后的目标行为 + +按你的描述,正确业务应该是: + +- `fire_leave` 报警在暂停窗口内不再上报 +- `fire_check` 报警仍然是每次检测到火焰后都正常上报 + +### 11.4 当前代码和目标业务的偏差 + +当前代码里,按钮实际上是在切换 `reporter` 里的事件周期状态: + +- `fire_leave` +- `fire_check` + +并带有: + +- 200ms 去抖 +- 5 秒冷却 + +但这和你描述的真实业务目标并不一致。 + +也就是说: + +- 现在代码实现的是“事件周期切换” +- 你要的业务其实是“人员离开报警进入暂停期” + +这部分应视为待修正逻辑。 + +## 12. 蜂鸣器逻辑 + +蜂鸣器不由本地报警自动触发,而是来自平台 topic: + +- 订阅 `/dhlr/alert` + +收到消息后,如果内容里的: + +- `type == 1` + +则触发本地蜂鸣器响指定秒数,默认 3 秒。 + +所以当前蜂鸣器逻辑是: + +- 平台推送声光报警命令 +- 微服务本地点响 GPIO109 + +不是“本地一检测到报警就必然自己响”。 + +## 13. `/dhlr/forbid_time` 的真实业务目标与当前问题 + +按你刚补充的业务,这个 topic 的核心作用不是“事件周期切换”,而是: + +- 当前端在按钮按下后,下发一个“允许人员短暂离开”的暂停时长 +- 微服务收到后,进入一个人员离开报警暂停窗口 +- 在这个窗口内,即使检测到人员离开,也不要触发 `fire_leave` 上报 +- 但 `fire_check` 火焰报警仍然要照常上报 + +### 13.1 真实业务目标 + +`/dhlr/forbid_time` 应该承载的是: + +- 暂停人员离开报警的时长 +- 或与之等价的延迟/禁止上报控制 + +### 13.2 当前代码现状 + +当前代码收到 `/dhlr/forbid_time` 后,主要做的是: + +1. 保存 `minute` 到 `/data/cache/forbid_time_config.json` +2. 保存 `fire_leave_timeout` / `fire_check_timeout` 到 `/data/cache/event_cycle_timeout.json` +3. 继续服务于 `reporter` 里的事件周期逻辑 + +### 13.3 当前存在的问题 + +这和你描述的真实业务不一致,问题点在于: + +- 订阅逻辑没有真正控制 `fire_leave` 报警暂停窗口 +- 没有把“按钮触发的允许离岗时长”落到主报警判定里 +- 暂停期内是否跳过 `fire_leave` 上报,当前主流程没有真正接进去 +- `fire_check` 与 `fire_leave` 的差异化处理没有按这个业务目标落地 + +### 13.4 应有的目标行为 + +这部分后续应改成: + +1. 前端按钮按下 +2. 后端通过 `/dhlr/forbid_time` 下发暂停时长 +3. 微服务记录 `fire_leave` 报警暂停截止时间 +4. 在暂停窗口内: + - 人员离开检测继续运行 + - 但不触发 `fire_leave` 报警上报 +5. 火焰检测逻辑不受影响,继续正常上报 + +## 14. 推理程序保活与异常恢复 + +代码里做了基础保活。 + +### 14.1 心跳来源 + +每次推理程序向本地 `/video/post` 发结果时,都会更新一次心跳时间。 + +也就是说: + +- 心跳不是单独接口 +- 心跳就是“推理结果持续回传” + +### 14.2 超时判断 + +如果超过 `85` 秒没有收到某个推理程序的数据,就认为需要重启。 + +主循环每 `30` 秒检查一次: + +- `fire_leave` +- `fire_check` + +如果超时,则: + +1. 杀掉旧进程 +2. 复用原端口重启 `yolov5` +3. 立即刷新心跳 + +## 15. 当前代码里值得特别记住的几个事实 + +### 15.1 动火离人报警不是“火焰+离人” + +当前代码里 `fire_leave` 报警依赖的是: + +- 无人 +- 温度超阈值 + +不是火焰检测结果。 + +### 15.2 火焰报警是完全独立的第二条链路 + +`fire_check` 只看火焰检测结果,单独触发 `fire_check` 级别事件。 + +### 15.3 两套检测区域是独立配置 + +- `FireLeaveDetectArea` +- `FireCheckDetectArea` + +前端可以分别配置。 + +### 15.4 前端参数里真正参与当前判定的关键值 + +动火离人链路: + +- `param.fire_leave.detection_time` +- `param.fire_leave.temperature_threshold` +- `param.fire_leave.alarm_time` +- 人数结果来自 `fire_leave` 模型 + +火焰链路: + +- `param.fire_check.detection_time` +- 火焰结果来自 `fire_check` 模型 + +### 15.5 置信度当前是共用的 + +当前两个 `yolov5` 进程启动时都使用: + +- `deviceData.Confidence` + +没有看到两套模型分别使用不同置信度字段的实现。 + +## 16. 结合当前代码后的业务总结 + +如果用最贴近当前实现的话来概括: + +- 前端负责配置设备、检测区和阈值 +- 后端负责配置转发、消息汇总和给前端展示 +- 微服务负责拉起两套推理和一套测温程序 +- `fire_leave` 模型负责人数统计 +- 测温程序负责温度 +- 微服务把“无人 + 超温 + 持续时长”判定为动火离人报警 +- `fire_check` 模型负责火焰检测 +- 微服务把“持续检测到火焰”判定为火焰报警 +- 两类报警都会回溯视频、上传视频、再通过 WebSocket 上报后端 + +## 17. 当前代码里可能需要你后续再确认的点 + +这几项不是我猜测,而是从代码现状看出来“可能存在设计与实现不完全一致”的地方: + +1. `ConfigUpdateChan` 已有生产者,但未看到消费者 + - 说明配置热更新链路可能没做完,或在当前仓库外 + +2. GPIO 按钮当前实现的是事件周期切换,但真实业务需要的是“暂停 fire_leave 上报” + - 说明这部分现在是实现偏差,不只是保留能力 + +3. `/dhlr/forbid_time` 当前没有真正接入 `fire_leave` 报警暂停控制 + - 说明“按钮按下后允许短时离岗”的核心业务还没真正落地 + +4. `fire_leave` 的 `person_count` 参数没有直接用于报警判断 + - 当前代码写死判断 `PersonCount == 0` + - 如果业务上原本支持“少于 N 人也报警”,那这部分目前没有实现 + +5. `param.fire_leave.confidence` 与 `param.fire_check.confidence` + - 配置结构里有 + - 但实际启动命令仍统一使用 `deviceData.Confidence` + +--- + +如果后面你继续描述前端和后端的预期业务,我可以再基于这份文档继续补一版“设计业务逻辑 vs 当前代码实现差异”文档,专门帮你找现在代码和你脑中业务之间的偏差。 diff --git a/docs/整改计划.md b/docs/整改计划.md new file mode 100644 index 0000000..b848f07 --- /dev/null +++ b/docs/整改计划.md @@ -0,0 +1,332 @@ +# 整改计划 + +日期:2026-05-11 + +本文档只保留以下内容: + +- 当前确认存在的问题和风险 +- 对应的修改方案、影响范围和代码落点 + +执行项已单独整理到 `docs/todo.md`。 + +## 1. 推理进程重启链路失效 + +### 问题 + +当前启动和重启使用了两套不同的端口管理器: + +- 启动阶段:`connect.GlobalPortManager` +- 心跳重启阶段:`portmanager.GlobalPortManager` + +结果是: + +- 初次启动能分到端口 +- 心跳超时后重启拿不到原端口 +- `yolov5` 重启流程会失败 + +### 风险 + +- 推理进程挂掉后无法自恢复 +- 设备会长期失去 `fire_leave` / `fire_check` 检测能力 + +### 准备怎么改 + +统一端口管理实现,只保留一套端口管理器。 + +建议方案: + +1. 以当前 `main.go` 正在使用的 `connect.GlobalPortManager` 为准,统一所有调用 +2. 重写或合并 `connect/heartbeat.go` 中的端口获取逻辑 +3. 清理重复的 `portmanager` 包引用,避免两套状态并存 + +### 需要修改的代码 + +- `main.go` +- `connect/heartbeat.go` +- `connect/port_manager.go` +- `portmanager/portmanager.go` +- `portmanager/types.go` + +## 2. `service.conf` 读写 schema 不一致 + +### 问题 + +当前配置读取和写回不是同一套结构: + +- `LoadServiceConfig()` 按 `[]ServiceConfig` 读取 +- `SaveServiceConfig()` / `SaveSingleServiceConfig()` 按 `[]DeviceData` 写回 + +### 风险 + +- WebSocket 或其它更新路径一旦写回配置 +- 下次启动可能无法按原 schema 正确读取 +- 配置字段可能丢失或错位 + +### 准备怎么改 + +统一配置文件模型,明确: + +- `service.conf` 是“部署输入配置” +- `device_*.json` 是“运行态持久化” + +建议方案: + +1. `service.conf` 始终只用 `ServiceConfig` 读写 +2. 运行态状态继续写入 `/data/devices/device_{uuid}.json` +3. 如果确实需要从 WebSocket 更新配置,先把 `DeviceData` 映射回 `ServiceConfig` 再写入 + +### 需要修改的代码 + +- `connect/device_storage.go` +- `connect/ws_channel.go` + +## 3. `fire_leave` 参数映射不完整 + +### 问题 + +当前 `fire_leave` 主判定读的是: + +- `deviceData.DetectionTime` +- `deviceData.AlarmTime` + +但新配置的真实值在: + +- `deviceData.FireLeaveParam.DetectionTime` +- `deviceData.FireLeaveParam.AlarmTime` + +另外 `person_count`、双模型 `confidence` 也存在未完整映射的问题。 + +### 风险 + +- 离岗报警持续时间可能一直用默认值 +- 冷却时间可能一直用默认值 +- 前端/平台配置和实际行为不一致 + +### 准备怎么改 + +在配置初始化阶段统一完成映射,不在运行主循环里到处兜底。 + +建议方案: + +1. `LoadServiceConfig()` 时完成 `param.fire_leave` 到运行字段的转换 +2. `ProcessDeviceDataForInference()` 或专门的 normalize 逻辑里统一补齐 +3. 后续主循环尽量只读规范化后的字段 + +### 需要修改的代码 + +- `connect/device_storage.go` +- `main.go` + +## 4. `/dhlr/forbid_time` 还没真正接入 `fire_leave` 暂停上报 + +### 问题 + +按当前确认后的业务: + +- `/dhlr/forbid_time` 是控制 `fire_leave` 暂停上报窗口 +- `fire_check` 仍要继续正常上报 + +但当前代码主要做的是: + +- 缓存 `minute` +- 缓存 `fire_leave_timeout` / `fire_check_timeout` +- 服务于 `reporter` 里的事件周期逻辑 + +没有真正控制 `fire_leave` 主报警判定。 + +### 风险 + +- 按钮按下后,现场期待的“暂时允许离岗”不会真正生效 +- 现场仍可能连续收到不符合预期的 `fire_leave` 报警 + +### 准备怎么改 + +把 `/dhlr/forbid_time` 从“事件周期配置”改为“主报警暂停窗口控制”。 + +建议方案: + +1. 在设备级状态里增加 `fire_leave` 暂停截止时间 +2. 收到 `/dhlr/forbid_time` 后更新这个截止时间 +3. 在 `fire_leave` 报警判定前先检查暂停窗口 +4. 暂停期内继续更新人数、温度,但跳过 `fire_leave` 上报 +5. `fire_check` 保持独立 + +### 需要修改的代码 + +- `main.go` +- `reporter/reporter.go` +- `gpio_monitor/gpio_monitor.go` +- 可能涉及 `connect/ws_channel.go` 的控制消息分发 + +## 5. GPIO 按钮逻辑和真实业务存在偏差 + +### 问题 + +当前按钮逻辑更接近“切换事件周期”,而不是“暂停 `fire_leave` 上报”。 + +### 风险 + +- 代码行为和现场业务理解不一致 +- 后续维护时容易误判 + +### 准备怎么改 + +将 GPIO 按钮逻辑收敛为: + +- 触发本地允许离岗窗口 +- 或配合 `/dhlr/forbid_time` 完成暂停控制 + +### 需要修改的代码 + +- `gpio_monitor/gpio_monitor.go` +- `reporter/reporter.go` +- `main.go` + +## 6. WebSocket 协议需要整体切换到 protobuf binary frame + +### 问题 + +当前协议层是: + +- JSON +- 文本帧 +- `method + params + result + error` + +目标协议是: + +- protobuf +- binary frame +- `REQUEST / REPLY / CAST` + +### 风险 + +- 现有 `ws_channel.go` 不能局部修补 +- 如果只改部分字段,会造成协议不兼容 + +### 准备怎么改 + +按“先替换协议层,尽量少动业务层接口”的原则改。 + +建议方案: + +1. 保留上层调用接口: + - `RegisterService` + - `SubscribeTopic` + - `SendAsync` +2. 内部把 JSON 封装替换为 protobuf 编解码 +3. 下行从 `publish` 改为 `topic_event` +4. 上行从 JSON `metric_data` 改为 `ServiceCast.metric_data` +5. 内层业务 payload 继续保留当前 JSON 结构 + +### 需要修改的代码 + +- `connect/ws_channel.go` +- `reporter/reporter.go` +- 可能新增 `connect/pb/` 或 `connect/protocol/` + +## 7. 主动 ping 建议保留,失败立即重连 + +### 问题 + +如果不主动 ping: + +- 死连接发现会变慢 +- 空闲连接可能被网络设备静默回收 +- 只有下次读写时才发现断连 + +### 风险 + +- `/dhlr/alert` +- `/dhlr/forbid_time` +- 事件上报链路 + +以上三类能力恢复变慢。 + +### 准备怎么改 + +继续保留主动 ping。 + +建议方案: + +1. 定时发送 websocket ping frame +2. ping 失败时立即标记断连 +3. 触发重连 +4. 重连后重新 register / subscribe + +### 需要修改的代码 + +- `connect/ws_channel.go` + +## 8. 业务上报仍然是“只发不确认” + +### 问题 + +当前: + +- `register` +- `subscribe` + +这类请求有协议层确认。 + +但: + +- 报警事件上报 +- 数据快照上报 + +仍然是单向发送。 + +### 风险 + +- 只能确认本地是否成功写出 websocket 消息 +- 不能确认 efka 是否真正处理成功 +- 不能确认上层平台是否真正收到、入库、展示 + +### 当前处理决定 + +这一项先不解决,只记录风险。 + +### 相关代码 + +- `connect/ws_channel.go` +- `reporter/reporter.go` + +## 9. 运行中配置更新链路未闭环 + +### 问题 + +当前有: + +- `ConfigUpdateChan` 生产者 + +但没有看到清晰的消费者和热更新执行链路。 + +### 风险 + +- 代码让人以为支持实时热更新 +- 实际运行中可能并不会生效 + +### 准备怎么改 + +先做一次取舍: + +- 要么补齐热更新 +- 要么明确去掉这条未完成链路 + +### 需要修改的代码 + +- `connect/ws_channel.go` +- `main.go` + +## 10. 自动化验证不足 + +### 问题 + +当前环境没有 `go`,也没有看到覆盖关键链路的自动化测试。 + +### 风险 + +- 协议层改造、重启链路改造、报警暂停逻辑改造容易引入回归 + +### 准备怎么改 + +至少补最小化验证路径。 diff --git a/error.log b/error.log new file mode 100644 index 0000000..cd50803 --- /dev/null +++ b/error.log @@ -0,0 +1,149 @@ +2026/02/11 17:01:05 [1770800465] ALARM TRIGGERED (after 20s): PersonCount=0, Temp=27.8°C > 20.0°C (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [1770800465] 更新报警状态: 冷却时间设置为 300 秒 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [1770800465] 更新设备数据 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 Device data saved successfully: /data/devices/device_3eeba8usb86c0gs448oktxay41a0rm1v.json +2026/02/11 17:01:05 Device data update completed: DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v +2026/02/11 17:01:05 [1770800465] 设备数据更新完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [1770800465] 开始异步处理报警 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, alarmTime=1770800445, cooldown=300) +2026/02/11 17:01:05 [0.000] 开始处理报警事件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, alarmTime=1770800445, cooldown=300) +2026/02/11 17:01:05 [0.000] 检查报警事件是否已在处理中 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, key=3eeba8usb86c0gs448oktxay41a0rm1v_1770800445) +2026/02/11 17:01:05 [0.000] 成功获取处理锁 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, key=3eeba8usb86c0gs448oktxay41a0rm1v_1770800445) +2026/02/11 17:01:05 [0.001] 开始合并视频 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, alarmTime=1770800445) +2026/02/11 17:01:05 [0.000] 开始合并视频 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, alarmTime=1770800445) +2026/02/11 17:01:05 [0.000] 检查视频目录: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.000] 视频目录检查成功: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.000] 开始时间对齐处理 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, alarmTime=1770800445) +2026/02/11 17:01:05 [0.000] 时间对齐结果: alarmTime=1770800445 → aligned=1770800440 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.000] 开始查找视频文件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.000] 检查文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170030.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] Found video: 20260211170030.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 检查文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] Found video: 20260211170040.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 检查文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170050.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] Found video: 20260211170050.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 当前找到 3 个视频文件,开始补充替代文件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 最终收集到 3 个视频文件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 开始排序视频文件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] Merging 3 videos (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v): +2026/02/11 17:01:05 [0.001] [0] 20260211170030.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] [1] 20260211170040.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] [2] 20260211170050.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 输出文件路径: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 清理旧的输出文件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.001] 开始执行视频合并命令: [mp4_merge /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170030.mp4 /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4 /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170050.mp4 --out /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4] (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +Merging file "/usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170030.mp4" +Merging file "/usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4" +Merging file "/usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170050.mp4" +Output file "/usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4" +Done in 0.029s +2026/02/11 17:01:05 [0.041] 视频合并命令执行成功 (耗时:0.040s) (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.042] 验证输出文件是否存在: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.042] Merged file created: 20260211170040.mp4_joined.mp4 (size: 1715493 bytes) (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.042] 开始移动文件到 /data/upload/ 目录 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.042] 最终文件路径: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.042] 确保 /data/upload 目录存在 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.043] 开始复制文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 → /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.000] 开始复制文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 → /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 [0.000] 打开源文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 +2026/02/11 17:01:05 [0.001] 创建目标文件: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 [0.002] 源文件大小: 1715493 字节 +2026/02/11 17:01:05 [0.003] 开始执行文件复制操作 +2026/02/11 17:01:05 [0.037] 文件复制成功: 复制 1715493 字节, 耗时 33.610332ms +2026/02/11 17:01:05 [0.042] 开始同步文件到磁盘 +2026/02/11 17:01:05 [0.081] 文件同步成功, 耗时 38.810767ms +2026/02/11 17:01:05 [0.082] 文件复制操作完成 +2026/02/11 17:01:05 [0.125] 文件复制成功 (耗时:0.083s) (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.125] 删除临时文件: /usr/data/camera/3eeba8usb86c0gs448oktxay41a0rm1v/20260211170040.mp4_joined.mp4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.126] 临时文件删除成功 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 [0.126] Video merged and moved successfully: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 总耗时:0.126s) +2026/02/11 17:01:05 [0.126] 视频合并成功,生成文件: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 耗时:0.126s) +2026/02/11 17:01:05 [0.126] 开始上传视频并上报事件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 文件=/data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4) +2026/02/11 17:01:05 [0.127] 报警上报成功 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 耗时:0.000s) +2026/02/11 17:01:05 [0.127] 报警事件处理完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 总耗时:0.127s) +2026/02/11 17:01:05 [1770800465] 报警处理完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 耗时:0.127s) +2026/02/11 17:01:05 开始异步处理视频上传和事件上报 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, 文件=/data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4) +2026/02/11 17:01:05 检查事件周期状态 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 事件周期超时,切换回默认周期 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 当前事件周期: fire_leave (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 Forbid time config is 0, will report both event types (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 开始上报fire_leave事件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 开始上报fire_check事件 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 双事件上报完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 Set temperature threshold from config: 20.0°C (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 Loaded 1 device configurations from service config file +2026/02/11 17:01:05 开始上传视频到 Nginx: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 → http://192.168.80.168/api/v1/upload/video +2026/02/11 17:01:05 打开文件: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 文件打开成功, 耗时: 55.417µs +2026/02/11 17:01:05 创建多部分表单 +2026/02/11 17:01:05 创建表单文件字段: 3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 开始复制文件内容到表单 +2026/02/11 17:01:05 Set temperature threshold from config: 20.0°C (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:05 Loaded 1 device configurations from service config file +2026/02/11 17:01:05 开始上传视频到 Nginx: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 → http://192.168.80.168/api/v1/upload/video +2026/02/11 17:01:05 打开文件: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 文件打开成功, 耗时: 72.917µs +2026/02/11 17:01:05 创建多部分表单 +2026/02/11 17:01:05 创建表单文件字段: 3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:05 开始复制文件内容到表单 +2026/02/11 17:01:05 文件内容复制成功, 耗时: 50.381519ms +2026/02/11 17:01:05 序列化事件参数 +2026/02/11 17:01:05 [Upload Event parameters]eventParams: map[alarm_time:1770800445 description:检测到异常火苗 device_UUID:3eeba8usb86c0gs448oktxay41a0rm1v host_uuid:qbxmjyzrkpntfgswaevodhluicqzxplkm level:fire_check person_count:0] +2026/02/11 17:01:05 [Upload Event parameters]eventJSON: {"alarm_time":1770800445,"description":"检测到异常火苗","device_UUID":"3eeba8usb86c0gs448oktxay41a0rm1v","host_uuid":"qbxmjyzrkpntfgswaevodhluicqzxplkm","level":"fire_check","person_count":0} +2026/02/11 17:01:05 写入事件字段到表单 +2026/02/11 17:01:05 关闭表单写入器 +2026/02/11 17:01:05 [HTTP Request]URL: http://192.168.80.168/api/v1/upload/video +2026/02/11 17:01:05 [HTTP Request]Content-Type: multipart/form-data; boundary=ea8f2517f1310dab1e3f4a8cdb6eecd24b54e96a950b34d7163021c79279 +2026/02/11 17:01:05 [HTTP Request]Body 长度: 1716094 bytes +2026/02/11 17:01:05 创建HTTP请求 +2026/02/11 17:01:05 发送HTTP请求到 Nginx +2026/02/11 17:01:05 文件内容复制成功, 耗时: 50.004684ms +2026/02/11 17:01:05 序列化事件参数 +2026/02/11 17:01:05 [Upload Event parameters]eventParams: map[alarm_time:1770800445 description:无人工作区且温度超阈值报警 device_UUID:3eeba8usb86c0gs448oktxay41a0rm1v host_uuid:qbxmjyzrkpntfgswaevodhluicqzxplkm level:fire_leave person_count:0 temperature:27.800003] +2026/02/11 17:01:05 [Upload Event parameters]eventJSON: {"alarm_time":1770800445,"description":"无人工作区且温度超阈值报警","device_UUID":"3eeba8usb86c0gs448oktxay41a0rm1v","host_uuid":"qbxmjyzrkpntfgswaevodhluicqzxplkm","level":"fire_leave","person_count":0,"temperature":27.800003} +2026/02/11 17:01:05 写入事件字段到表单 +2026/02/11 17:01:05 关闭表单写入器 +2026/02/11 17:01:05 [HTTP Request]URL: http://192.168.80.168/api/v1/upload/video +2026/02/11 17:01:05 [HTTP Request]Content-Type: multipart/form-data; boundary=29d6eedc2272d36e33f6fe82d928288f7e1b9db50a4b2f8c6cb67af9b185 +2026/02/11 17:01:05 [HTTP Request]Body 长度: 1716136 bytes +2026/02/11 17:01:05 创建HTTP请求 +2026/02/11 17:01:05 发送HTTP请求到 Nginx +2026/02/11 17:01:05 === Global Device Status monitoring === Currently running 1 +2026/02/11 17:01:05 [1] DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v | person:0 | temp:27.8°C | threshold value:20.0°C | cooling time:300s +2026/02/11 17:01:05 Device file status: A total of 1 device files +2026/02/11 17:01:05 device file[0]: device_3eeba8usb86c0gs448oktxay41a0rm1v.json +2026/02/11 17:01:05 Operating equipment 1 +2026/02/11 17:01:06 HTTP请求发送成功, 耗时: 445.145637ms +2026/02/11 17:01:06 读取HTTP响应 +2026/02/11 17:01:06 响应读取完成, 耗时: 357.877µs +2026/02/11 17:01:06 [nginx response]Status: 200 +2026/02/11 17:01:06 [nginx response]Body: {"code":0,"error":"","result":"\/upload\/2026\/2\/11\/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4"} +2026/02/11 17:01:06 解析Nginx响应 +2026/02/11 17:01:06 提取视频URL +2026/02/11 17:01:06 [ Upload Successful ] Local file: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 → Remote URL: /upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:06 视频上传完成, 总耗时: 505.346151ms +2026/02/11 17:01:06 Nginx上传成功: /upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:06 HTTP请求发送成功, 耗时: 452.366747ms +2026/02/11 17:01:06 读取HTTP响应 +2026/02/11 17:01:06 Set temperature threshold from config: 20.0°C (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:06 响应读取完成, 耗时: 319.085µs +2026/02/11 17:01:06 [nginx response]Status: 200 +2026/02/11 17:01:06 [nginx response]Body: {"code":0,"error":"","result":"\/upload\/2026\/2\/11\/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4"} +2026/02/11 17:01:06 解析Nginx响应 +2026/02/11 17:01:06 提取视频URL +2026/02/11 17:01:06 [ Upload Successful ] Local file: /data/upload/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 → Remote URL: /upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:06 视频上传完成, 总耗时: 522.634464ms +2026/02/11 17:01:06 Nginx上传成功: /upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4 +2026/02/11 17:01:06 Loaded 1 device configurations from service config file +2026/02/11 17:01:06 报警事件已上报 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, Level=fire_leave) +2026/02/11 17:01:06 Set temperature threshold from config: 20.0°C (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:06 Loaded 1 device configurations from service config file +2026/02/11 17:01:06 Sending WebSocket message: {"method":"metric_data","params":{"metric":"{\"type\":\"event\",\"args\":{\"address\":\"地址2\",\"alarm_time\":1770800445,\"description\":\"无人工作区且温度超阈值报警\",\"device_uuid\":\"3eeba8usb86c0gs448oktxay41a0rm1v\",\"host_uuid\":\"qbxmjyzrkpntfgswaevodhluicqzxplkm\",\"level\":\"fire_leave\",\"video_url\":\"/upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4\"}}","route_key":"/dhlr/device/3eeba8usb86c0gs448oktxay41a0rm1v/event"}} +2026/02/11 17:01:06 报警事件已上报 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, Level=fire_check) +2026/02/11 17:01:06 Asynchronous reporting successful +2026/02/11 17:01:06 数据快照已上报 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:06 按指定级别上报事件完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, Level=fire_leave) +2026/02/11 17:01:06 Sending WebSocket message: {"method":"metric_data","params":{"metric":"{\"type\":\"data\",\"args\":{\"address\":\"地址2\",\"alarm_time\":1770800445,\"camera_ip\":\"192.168.80.64\",\"camera_rtsp\":\"rtsp://admin:JH11223344@192.168.80.64:554\",\"confidence\":70,\"person_count\":0,\"task_id\":\"3319\",\"temperature\":27.800003}}","route_key":"/dhlr/device/3eeba8usb86c0gs448oktxay41a0rm1v/data"}} +2026/02/11 17:01:06 Sending WebSocket message: {"method":"metric_data","params":{"metric":"{\"type\":\"event\",\"args\":{\"address\":\"地址2\",\"alarm_time\":1770800445,\"description\":\"检测到异常火苗\",\"device_uuid\":\"3eeba8usb86c0gs448oktxay41a0rm1v\",\"host_uuid\":\"qbxmjyzrkpntfgswaevodhluicqzxplkm\",\"level\":\"fire_check\",\"video_url\":\"/upload/2026/2/11/3eeba8usb86c0gs448oktxay41a0rm1v-2026-2-11-1770800440.MP4\"}}","route_key":"/dhlr/device/3eeba8usb86c0gs448oktxay41a0rm1v/event"}} +2026/02/11 17:01:06 数据快照已上报 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v) +2026/02/11 17:01:06 按指定级别上报事件完成 (DeviceUUID=3eeba8usb86c0gs448oktxay41a0rm1v, Level=fire_check) +2026/02/11 17:01:06 Sending WebSocket message: {"method":"metric_data","params":{"metric":"{\"type\":\"data\",\"args\":{\"address\":\"地址2\",\"alarm_time\":1770800445,\"camera_ip\":\"192.168.80.64\",\"camera_rtsp\":\"rtsp://admin:JH11223344@192.168.80.64:554\",\"confidence\":70,\"person_count\":0,\"task_id\":\"3319\",\"temperature\":27.800003}}","route_key":"/dhlr/device/3eeba8usb86c0gs448oktxay41a0rm1v/data"}} \ No newline at end of file diff --git a/fireleave_tool b/fireleave_tool index 5da733e..7dddf43 100644 Binary files a/fireleave_tool and b/fireleave_tool differ diff --git a/gpio_monitor/gpio_monitor.go b/gpio_monitor/gpio_monitor.go new file mode 100644 index 0000000..5a998ca --- /dev/null +++ b/gpio_monitor/gpio_monitor.go @@ -0,0 +1,234 @@ +package gpio_monitor + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "FireLeave_tool/logger" + "FireLeave_tool/reporter" +) + +const ( + // ButtonGPIO GPIO引脚编号 + ButtonGPIO = 111 + // DebounceTime 去抖动时间 + DebounceTime = 200 * time.Millisecond +) + +// GPIOButtonMonitor GPIO按钮监视器 +type GPIOButtonMonitor struct { + gpioPin int + isRunning bool + lastState bool + lastChange time.Time + lastAlertTime time.Time + cooldownTime time.Duration + cooldownActive bool +} + +// NewGPIOButtonMonitor 创建GPIO按钮监视器 +func NewGPIOButtonMonitor(gpioPin int) *GPIOButtonMonitor { + return &GPIOButtonMonitor{ + gpioPin: gpioPin, + isRunning: false, + lastState: false, + lastChange: time.Now(), + lastAlertTime: time.Now().Add(-6 * time.Second), // 初始化为6秒前,确保第一次可以触发 + cooldownTime: 5 * time.Second, // 5秒冷却时间 + cooldownActive: false, // 冷却期状态 + } +} + +// InitGPIO 初始化GPIO +func (m *GPIOButtonMonitor) InitGPIO() error { + gpioPath := fmt.Sprintf("/sys/class/gpio/gpio%d", m.gpioPin) + + // 检查GPIO是否已导出 + if _, err := os.Stat(gpioPath); os.IsNotExist(err) { + // 导出GPIO + if err := os.WriteFile("/sys/class/gpio/export", []byte(strconv.Itoa(m.gpioPin)), 0666); err != nil { + return fmt.Errorf("failed to export GPIO %d: %v", m.gpioPin, err) + } + // 等待导出完成 + time.Sleep(50 * time.Millisecond) + } + + // 检查并设置方向为输入 + directionPath := fmt.Sprintf("%s/direction", gpioPath) + directionBytes, err := os.ReadFile(directionPath) + if err != nil { + return fmt.Errorf("failed to read GPIO direction: %v", err) + } + + direction := string(directionBytes) + if direction != "in\n" && direction != "in" { + if err := os.WriteFile(directionPath, []byte("in"), 0666); err != nil { + return fmt.Errorf("failed to set GPIO direction to in: %v", err) + } + // 等待设置完成 + time.Sleep(10 * time.Millisecond) + } + + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d initialized successfully as input", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d initialized successfully as input", m.gpioPin) + } + return nil +} + +// ReadGPIOValue 读取GPIO值 +func (m *GPIOButtonMonitor) ReadGPIOValue() (bool, error) { + valuePath := fmt.Sprintf("/sys/class/gpio/gpio%d/value", m.gpioPin) + valueBytes, err := os.ReadFile(valuePath) + if err != nil { + return false, fmt.Errorf("failed to read GPIO value: %v", err) + } + + value := string(valueBytes) + return value == "1\n" || value == "1", nil +} + +// Start 启动GPIO监控 +func (m *GPIOButtonMonitor) Start(ctx context.Context) error { + // 初始化GPIO + if err := m.InitGPIO(); err != nil { + return fmt.Errorf("failed to initialize GPIO: %v", err) + } + + m.isRunning = true + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("Started monitoring GPIO %d for button alerts", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("Started monitoring GPIO %d for button alerts", m.gpioPin) + } + + go func() { + defer func() { + m.isRunning = false + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("Stopped monitoring GPIO %d", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("Stopped monitoring GPIO %d", m.gpioPin) + } + }() + + for { + select { + case <-ctx.Done(): + return + default: + // 读取GPIO值 + currentState, err := m.ReadGPIOValue() + if err != nil { + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("Error reading GPIO %d value: %v", m.gpioPin, err) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("Error reading GPIO %d value: %v", m.gpioPin, err) + } + time.Sleep(100 * time.Millisecond) + continue + } + + // 检查状态是否变化 + if currentState != m.lastState { + // 记录状态变化 + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d state changed: %t → %t", m.gpioPin, m.lastState, currentState) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d state changed: %t → %t", m.gpioPin, m.lastState, currentState) + } + + // 状态变化时更新lastChange + m.lastChange = time.Now() + + // 立即更新lastState,避免重复触发状态变化日志 + m.lastState = currentState + + // 只输出true状态 + if currentState { + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d current state: %t", m.gpioPin, currentState) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d current state: %t", m.gpioPin, currentState) + } + } + } + + // 检查冷却期状态 + if time.Since(m.lastAlertTime) < m.cooldownTime { + if !m.cooldownActive { + m.cooldownActive = true + // 只记录一次冷却期开始 + } + } else { + if m.cooldownActive { + // 只记录一次冷却期结束 + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d cooldown period ended", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d cooldown period ended", m.gpioPin) + } + m.cooldownActive = false + } + } + + // 检查是否稳定,且当前状态为true(高电平) + if currentState && time.Since(m.lastChange) > DebounceTime { + // 检查冷却期 + if time.Since(m.lastAlertTime) >= m.cooldownTime { + // 检测到稳定的高电平,切换事件周期 + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d detected stable high level after debounce, switching event cycle...", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d detected stable high level after debounce, switching event cycle...", m.gpioPin) + } + + // 切换事件周期 + var targetCycle reporter.EventCycle + switch reporter.GetCurrentEventCycle() { + case reporter.EventCycleFireLeave: + targetCycle = reporter.EventCycleFireCheck + case reporter.EventCycleFireCheck: + targetCycle = reporter.EventCycleFireLeave + default: + targetCycle = reporter.EventCycleFireCheck + } + + if err := reporter.SwitchEventCycle(targetCycle); err != nil { + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("Failed to switch event cycle: %v", err) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("Failed to switch event cycle: %v", err) + } + } + + // 更新lastAlertTime,开始冷却期 + m.lastAlertTime = time.Now() + if log := logger.GlobalLoggerManager.GetLogger("gpio"); log != nil { + log.Printf("GPIO %d alert sent, cooldown period started (5s)", m.gpioPin) + } else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil { + log.Printf("GPIO %d alert sent, cooldown period started (5s)", m.gpioPin) + } + + // 重置lastChange,避免重复触发 + m.lastChange = time.Now() + } + } + + // 增加休眠时间,减少CPU占用和日志频率 + time.Sleep(100 * time.Millisecond) + } + } + }() + + return nil +} + +// IsRunning 检查监视器是否正在运行 +func (m *GPIOButtonMonitor) IsRunning() bool { + return m.isRunning +} diff --git a/logger/logger.go b/logger/logger.go index 5cb8500..e30b21c 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -14,19 +14,114 @@ var logFile *os.File var logFileName string var mu sync.Mutex -// InitLogger 初始化日志,创建 /data/logs 目录并按天命名文件 +// LoggerManager 日志管理器 +type LoggerManager struct { + loggers map[string]*log.Logger + files map[string]*os.File + mu sync.RWMutex +} + +// GlobalLoggerManager 全局日志管理器 +var GlobalLoggerManager = &LoggerManager{ + loggers: make(map[string]*log.Logger), + files: make(map[string]*os.File), +} + +// CreateLogger 创建带有指定路径的日志实例 +func (lm *LoggerManager) CreateLogger(name string, logDir string) (*log.Logger, error) { + lm.mu.Lock() + defer lm.mu.Unlock() + + // 确保日志目录存在 + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, err + } + + // 按天生成日志文件名 + logFileName := filepath.Join(logDir, name+"_"+time.Now().Format("20060102")+".log") + file, err := os.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + logger := log.New(file, "", log.LstdFlags) + lm.loggers[name] = logger + lm.files[name] = file + + // 每天凌晨检查是否需要切换日志文件 + go func() { + for { + // 每天检查一次(凌晨 00:05 执行) + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 5, 0, 0, now.Location()) + time.Sleep(time.Until(next)) + + lm.mu.Lock() + newFileName := filepath.Join(logDir, name+"_"+time.Now().Format("20060102")+".log") + if newFileName != logFileName { + newFile, err := os.OpenFile(newFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("Failed to switch the log file: %v", err) + lm.mu.Unlock() + continue + } + file.Close() + file = newFile + logFileName = newFileName + logger = log.New(newFile, "", log.LstdFlags) + lm.loggers[name] = logger + lm.files[name] = newFile + logger.Println("The log file has been switched:", newFileName) + } + lm.mu.Unlock() + } + }() + + return logger, nil +} + +// GetLogger 获取指定名称的日志实例 +func (lm *LoggerManager) GetLogger(name string) *log.Logger { + lm.mu.RLock() + defer lm.mu.RUnlock() + return lm.loggers[name] +} + +// CloseLogger 关闭指定名称的日志文件 +func (lm *LoggerManager) CloseLogger(name string) { + lm.mu.Lock() + defer lm.mu.Unlock() + if file, exists := lm.files[name]; exists { + file.Close() + delete(lm.files, name) + delete(lm.loggers, name) + } +} + +// CloseAllLoggers 关闭所有日志文件 +func (lm *LoggerManager) CloseAllLoggers() { + lm.mu.Lock() + defer lm.mu.Unlock() + for name, file := range lm.files { + file.Close() + delete(lm.files, name) + delete(lm.loggers, name) + } +} + +// InitLogger 初始化系统日志,创建 /data/logs/system 目录并按天命名文件 func InitLogger() error { mu.Lock() defer mu.Unlock() - // 创建 /data/logs 目录 - logDir := "/data/logs" + // 创建 /data/logs/system 目录 + logDir := "/data/logs/system" if err := os.MkdirAll(logDir, 0755); err != nil { return err } - // 按天生成日志文件名:20260102.log - logFileName = filepath.Join(logDir, time.Now().Format("20060102")+".log") + // 按天生成日志文件名:system_20260102.log + logFileName = filepath.Join(logDir, "system_"+time.Now().Format("20060102")+".log") file, err := os.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err @@ -43,7 +138,74 @@ func InitLogger() error { time.Sleep(time.Until(next)) mu.Lock() - newFileName := filepath.Join(logDir, time.Now().Format("20060102")+".log") + newFileName := filepath.Join(logDir, "system_"+time.Now().Format("20060102")+".log") + if newFileName != logFileName { + newFile, err := os.OpenFile(newFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("Failed to switch the log file: %v", err) + mu.Unlock() + continue + } + logFile.Close() + logFile = newFile + logFileName = newFileName + Logger = log.New(newFile, "", log.LstdFlags) + Logger.Println("The log file has been switched:", newFileName) + } + mu.Unlock() + } + }() + return nil +} + +// InitReporterLogger 初始化上报管理器专用日志,创建 /data/logs/reporter 目录并按天命名文件 +func InitReporterLogger() error { + // 创建上报管理器专用日志 + _, err := GlobalLoggerManager.CreateLogger("reporter", "/data/logs/reporter") + return err +} + +// InitGPILogger 初始化GPIO监控专用日志,创建 /data/logs/reporter 目录并按天命名文件 +func InitGPILogger() error { + // 创建GPIO监控专用日志 + _, err := GlobalLoggerManager.CreateLogger("gpio", "/data/logs/reporter") + return err +} + +// InitLoggerWithPath 初始化日志,使用指定的日志文件路径 +func InitLoggerWithPath(logPath string) error { + mu.Lock() + defer mu.Unlock() + + // 创建日志目录 + logDir := filepath.Dir(logPath) + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + // 打开指定的日志文件 + logFileName = logPath + file, err := os.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + logFile = file + Logger = log.New(file, "", log.LstdFlags) + + // 每天凌晨检查是否需要切换日志文件 + go func() { + for { + // 每天检查一次(凌晨 00:05 执行) + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 5, 0, 0, now.Location()) + time.Sleep(time.Until(next)) + + mu.Lock() + // 生成新的日志文件名,保留原有文件名的前缀和添加日期后缀 + baseName := filepath.Base(logPath) + ext := filepath.Ext(baseName) + nameWithoutExt := baseName[:len(baseName)-len(ext)] + newFileName := filepath.Join(logDir, nameWithoutExt+"_"+time.Now().Format("20060102")+ext) if newFileName != logFileName { newFile, err := os.OpenFile(newFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { @@ -75,7 +237,15 @@ func CloseLogger() { // CleanupOldLogs 删除超过30天的日志文件 func CleanupOldLogs() { - logDir := "/data/logs" + // 清理system目录的日志文件 + cleanupLogDirectory("/data/logs/system") + + // 清理reporter目录的日志文件 + cleanupLogDirectory("/data/logs/reporter") +} + +// cleanupLogDirectory 清理指定目录下超过30天的日志文件 +func cleanupLogDirectory(logDir string) { files, err := os.ReadDir(logDir) if err != nil { if Logger != nil { diff --git a/main b/main new file mode 100644 index 0000000..2811f39 Binary files /dev/null and b/main differ diff --git a/main.go b/main.go index 659411c..007857e 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,10 @@ package main import ( "FireLeave_tool/connect" + "FireLeave_tool/gpio_monitor" "FireLeave_tool/httpreader" "FireLeave_tool/logger" + "FireLeave_tool/reporter" "FireLeave_tool/tcpreader" "FireLeave_tool/video_server" "context" @@ -18,6 +20,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "strconv" "sync" "syscall" "time" @@ -27,12 +30,18 @@ type deviceAlarmState struct { InCondition bool StartTime int64 AlarmPauseUntil int64 + // fire_check火焰检测状态 + FireCheckInCondition bool + FireCheckStartTime int64 } // 全局 map:每个设备独立状态 var deviceStates sync.Map // map[string]*deviceAlarmState var mergingAlarms sync.Map +// 全局上报管理器 +var GlobalReporter = reporter.NewReporter() + // DeviceManager 设备管理器 type DeviceManager struct { mu sync.Mutex @@ -193,91 +202,172 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { deviceCtx, cancel := context.WithCancel(ctx) defer cancel() - // 分配端口 - port, err := connect.GlobalPortManager.AllocatePort(deviceData.DeviceUUID) + // 分配端口 - fire_leave推理程序 + portFireLeave, err := connect.GlobalPortManager.AllocatePort(deviceData.DeviceUUID) if err != nil { logger.Logger.Printf(" DeviceUUID=%s Port allocation failed: %v", deviceData.DeviceUUID, err) return } defer connect.GlobalPortManager.ReleasePort(deviceData.DeviceUUID) - logger.Logger.Printf("Port allocation successful: DeviceUUID=%s, Port=%d", deviceData.DeviceUUID, port) - inferenceServer := httpreader.NewInferenceServer(deviceData.DeviceUUID, port) - if err := inferenceServer.Start(); err != nil { - logger.Logger.Printf("Failed to start the inference data receiving server (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + // 分配端口 - fire_check推理程序 + portFireCheck, err := connect.GlobalPortManager.AllocatePort(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) + if err != nil { + logger.Logger.Printf(" DeviceUUID=%s Fire check port allocation failed: %v", deviceData.DeviceUUID, err) + return + } + defer connect.GlobalPortManager.ReleasePort(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) + + logger.Logger.Printf("Port allocation successful: DeviceUUID=%s, FireLeavePort=%d, FireCheckPort=%d", deviceData.DeviceUUID, portFireLeave, portFireCheck) + + // 创建fire_leave推理数据接收服务器 + inferenceServerFireLeave := httpreader.NewInferenceServer(deviceData.DeviceUUID, portFireLeave) + if err := inferenceServerFireLeave.Start(); err != nil { + logger.Logger.Printf("Failed to start fire_leave inference server (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + return + } + + // 创建fire_check推理数据接收服务器 + inferenceServerFireCheck := httpreader.NewInferenceServer(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID), portFireCheck) + if err := inferenceServerFireCheck.Start(); err != nil { + logger.Logger.Printf("Failed to start fire_check inference server (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) return } // 使用带上下文的停止 - defer inferenceServer.StopWithContext(deviceCtx) - logger.Logger.Printf("Wait for the inference data receiving server to start... (DeviceUUID=%s, Port=%d)", deviceData.DeviceUUID, port) + defer inferenceServerFireLeave.StopWithContext(deviceCtx) + defer inferenceServerFireCheck.StopWithContext(deviceCtx) // 等待HTTP服务器启动 - if !waitForHTTPService(port, 10*time.Second) { - logger.Logger.Printf("The inference data receiving server has timed out during startup (DeviceUUID=%s, Port=%d)", deviceData.DeviceUUID, port) + if !waitForHTTPService(portFireLeave, 10*time.Second) { + logger.Logger.Printf("Fire_leave inference server startup timeout (DeviceUUID=%s, Port=%d)", deviceData.DeviceUUID, portFireLeave) return } - logger.Logger.Printf("The inference data receiving server has been successfully started (DeviceUUID=%s, Port=%d)", deviceData.DeviceUUID, port) + if !waitForHTTPService(portFireCheck, 10*time.Second) { + logger.Logger.Printf("Fire_check inference server startup timeout (DeviceUUID=%s, Port=%d)", deviceData.DeviceUUID, portFireCheck) + return + } + + logger.Logger.Printf("Inference servers started successfully: DeviceUUID=%s, FireLeavePort=%d, FireCheckPort=%d", deviceData.DeviceUUID, portFireLeave, portFireCheck) // 处理检测区域为推理程序需要的格式 - detectAreaStr := connect.ProcessDetectAreaForInference(deviceData.DetectArea) - logger.Logger.Printf("The processed detection area: %s", detectAreaStr) + // 为fire_leave推理程序获取检测区域 + fireLeaveDetectAreaStr := connect.ProcessDetectAreaForInference(deviceData.FireLeaveDetectArea) + logger.Logger.Printf("The processed fire_leave detection area: %s", fireLeaveDetectAreaStr) - // 启动推理程序 - inferenceCmd := exec.Command( + // 为fire_check推理程序获取检测区域 + fireCheckDetectAreaStr := connect.ProcessDetectAreaForInference(deviceData.FireCheckDetectArea) + logger.Logger.Printf("The processed fire_check detection area: %s", fireCheckDetectAreaStr) + + // 启动fire_leave推理程序 + inferenceCmdFireLeave := exec.Command( "./yolov5", "-s", deviceData.CameraRTSP, "-m", "model", "-c", deviceData.DeviceUUID, - "-p", fmt.Sprintf("%d", port), - "-w", detectAreaStr, + "-p", fmt.Sprintf("%d", portFireLeave), + "-w", fireLeaveDetectAreaStr, "-r", fmt.Sprintf("%d", deviceData.Confidence), ) - inferenceCmd.Stdout = os.Stdout - inferenceCmd.Stderr = os.Stderr - //logger.Logger.Printf("推理程序输出仅显示在控制台") + inferenceCmdFireLeave.Stdout = os.Stdout + inferenceCmdFireLeave.Stderr = os.Stderr - logger.Logger.Printf("Start the inference program command: %v", inferenceCmd.Args) + logger.Logger.Printf("Start fire_leave inference program command: %v", inferenceCmdFireLeave.Args) - if err := inferenceCmd.Start(); err != nil { - logger.Logger.Printf("推理程序启动失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + if err := inferenceCmdFireLeave.Start(); err != nil { + logger.Logger.Printf("Fire_leave inference program startup failed (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) return } - // 关键:保存进程对象到全局 map - connect.InferenceProcesses.Store(deviceData.DeviceUUID, inferenceCmd) + // 保存fire_leave进程对象到全局 map + connect.InferenceProcesses.Store(deviceData.DeviceUUID, inferenceCmdFireLeave) - logger.Logger.Printf("推理程序已启动,PID=%d (DeviceUUID=%s)", inferenceCmd.Process.Pid, deviceData.DeviceUUID) + logger.Logger.Printf("Fire_leave inference program started, PID=%d (DeviceUUID=%s)", inferenceCmdFireLeave.Process.Pid, deviceData.DeviceUUID) + + // 启动fire_check推理程序 + inferenceCmdFireCheck := exec.Command( + "./yolov5", + "-s", deviceData.CameraRTSP, + "-m", "fire_check", + "-c", fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID), + "-p", fmt.Sprintf("%d", portFireCheck), + "-w", fireCheckDetectAreaStr, + "-r", fmt.Sprintf("%d", deviceData.Confidence), + "-R", + ) + inferenceCmdFireCheck.Stdout = os.Stdout + inferenceCmdFireCheck.Stderr = os.Stderr + + logger.Logger.Printf("Start fire_check inference program command: %v", inferenceCmdFireCheck.Args) + + if err := inferenceCmdFireCheck.Start(); err != nil { + logger.Logger.Printf("Fire_check inference program startup failed (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + return + } + + // 保存fire_check进程对象到全局 map + connect.InferenceProcesses.Store(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID), inferenceCmdFireCheck) + + logger.Logger.Printf("Fire_check inference program started, PID=%d (DeviceUUID=%s)", inferenceCmdFireCheck.Process.Pid, deviceData.DeviceUUID) // 初始化心跳 connect.UpdateInferenceHeartbeat(deviceData.DeviceUUID) + connect.UpdateInferenceHeartbeat(fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)) // 检查推理程序是否启动成功 time.Sleep(2 * time.Second) - if inferenceCmd.Process == nil { - logger.Logger.Printf("The reasoning program process has not been created (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 检查fire_leave推理程序 + if inferenceCmdFireLeave.Process == nil { + logger.Logger.Printf("Fire_leave inference program process not created (DeviceUUID=%s)", deviceData.DeviceUUID) return } - if err := inferenceCmd.Process.Signal(syscall.Signal(0)); err != nil { - logger.Logger.Printf("The reasoning program has exited (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + if err := inferenceCmdFireLeave.Process.Signal(syscall.Signal(0)); err != nil { + logger.Logger.Printf("Fire_leave inference program exited (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) return } - logger.Logger.Printf("The reasoning program process has been started,PID: %d (DeviceUUID=%s)", inferenceCmd.Process.Pid, deviceData.DeviceUUID) + // 检查fire_check推理程序 + if inferenceCmdFireCheck.Process == nil { + logger.Logger.Printf("Fire_check inference program process not created (DeviceUUID=%s)", deviceData.DeviceUUID) + return + } - if err := logProcessInfo(inferenceCmd.Process.Pid, deviceData.DeviceUUID); err != nil { - logger.Logger.Printf("Failed to record process information: %v", err) + if err := inferenceCmdFireCheck.Process.Signal(syscall.Signal(0)); err != nil { + logger.Logger.Printf("Fire_check inference program exited (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + return + } + + logger.Logger.Printf("Both inference programs started successfully: DeviceUUID=%s", deviceData.DeviceUUID) + + if err := logProcessInfo(inferenceCmdFireLeave.Process.Pid, deviceData.DeviceUUID); err != nil { + logger.Logger.Printf("Failed to record fire_leave process information: %v", err) + } + + if err := logProcessInfo(inferenceCmdFireCheck.Process.Pid, fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)); err != nil { + logger.Logger.Printf("Failed to record fire_check process information: %v", err) } defer func() { - logger.Logger.Printf("Start to stop the reasoning program (DeviceUUID=%s)", deviceData.DeviceUUID) - if inferenceCmd.Process != nil { - if err := inferenceCmd.Process.Kill(); err != nil { - logger.Logger.Printf("The termination of the reasoning program failed (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + logger.Logger.Printf("Start to stop inference programs (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 停止fire_leave推理程序 + if inferenceCmdFireLeave.Process != nil { + if err := inferenceCmdFireLeave.Process.Kill(); err != nil { + logger.Logger.Printf("Fire_leave inference program termination failed (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) } else { - logger.Logger.Printf("The reasoning program has stopped (DeviceUUID=%s)", deviceData.DeviceUUID) + logger.Logger.Printf("Fire_leave inference program stopped (DeviceUUID=%s)", deviceData.DeviceUUID) + } + } + + // 停止fire_check推理程序 + if inferenceCmdFireCheck.Process != nil { + if err := inferenceCmdFireCheck.Process.Kill(); err != nil { + logger.Logger.Printf("Fire_check inference program termination failed (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) + } else { + logger.Logger.Printf("Fire_check inference program stopped (DeviceUUID=%s)", deviceData.DeviceUUID) } } }() @@ -285,7 +375,7 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { // 启动温度程序 // 在 processDevice 函数中修改温度程序启动部分 // 启动温度程序 - tempPort := port + 1000 + tempPort := portFireLeave + 1000 tempCmdArgs := []string{ "-s", deviceData.CameraRTSP, "-t", fmt.Sprintf("%d", tempPort), @@ -371,8 +461,11 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { case <-deviceCtx.Done(): logger.Logger.Printf("Equipment processing has been cancelled: DeviceUUID=%s", deviceData.DeviceUUID) // 强制停止所有子进程 - if inferenceCmd.Process != nil { - inferenceCmd.Process.Kill() + if inferenceCmdFireLeave.Process != nil { + inferenceCmdFireLeave.Process.Kill() + } + if inferenceCmdFireCheck.Process != nil { + inferenceCmdFireCheck.Process.Kill() } if tempCmd.Process != nil { tempCmd.Process.Kill() @@ -380,9 +473,9 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { tempClient.Close() return - case inferenceResult, ok := <-inferenceServer.GetDataChan(): + case inferenceResult, ok := <-inferenceServerFireLeave.GetDataChan(): if !ok { - logger.Logger.Printf("The inference data channel is closed: DeviceUUID=%s", deviceData.DeviceUUID) + logger.Logger.Printf("The fire_leave inference data channel is closed: DeviceUUID=%s", deviceData.DeviceUUID) return } currentPersonCount := 0 @@ -399,6 +492,62 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { logger.Logger.Printf("Personnel quantity update: %d (DeviceUUID=%s)", currentPersonCount, deviceData.DeviceUUID) } + case fireCheckResult, ok := <-inferenceServerFireCheck.GetDataChan(): + if !ok { + logger.Logger.Printf("The fire_check inference data channel is closed: DeviceUUID=%s", deviceData.DeviceUUID) + return + } + // 处理fire_check推理结果 + logger.Logger.Printf("Received fire_check inference data: DeviceUUID=%s", deviceData.DeviceUUID) + + // 检查是否检测到火焰 + fireDetected := false + for _, param := range fireCheckResult.Params { + if param.ClassIdx == 0 && param.Number > 0 { // 假设ClassIdx=0表示火焰 + fireDetected = true + logger.Logger.Printf("Fire detected! DeviceUUID=%s, Number=%d", deviceData.DeviceUUID, param.Number) + break + } + } + + // 处理火焰检测的连续时间判断 + state := getDeviceState(deviceData.DeviceUUID) + now := time.Now().Unix() + + // 解析detection_time配置 + detectionSec := 2 // 默认2秒 + if deviceData.FireCheckParam.DetectionTime != "" { + if sec, err := strconv.Atoi(deviceData.FireCheckParam.DetectionTime); err == nil && sec > 0 { + detectionSec = sec + } + } + + if fireDetected { + if !state.FireCheckInCondition { + // 开始计时 + state.FireCheckStartTime = now + state.FireCheckInCondition = true + logger.Logger.Printf("Fire detection started, timing for %ds: DeviceUUID=%s", detectionSec, deviceData.DeviceUUID) + } else if now-state.FireCheckStartTime >= int64(detectionSec) { + // 连续检测到火焰达到指定时间,触发上报 + logger.Logger.Printf("Fire detected continuously for %ds, triggering alarm: DeviceUUID=%s", detectionSec, deviceData.DeviceUUID) + go func() { + alarmTime := time.Now().Unix() + logger.Logger.Printf("[%.3f] FIRE DETECTED ALARM TRIGGERED (DeviceUUID=%s)", time.Since(time.Now()).Seconds(), deviceData.DeviceUUID) + // 调用专门的火焰检测上报函数 + GlobalReporter.TriggerFireCheckAlarm(deviceData, alarmTime) + }() + // 重置状态 + state.FireCheckInCondition = false + } + } else { + // 没有检测到火焰,重置状态 + if state.FireCheckInCondition { + logger.Logger.Printf("Fire detection interrupted: lasted %ds < %ds (DeviceUUID=%s)", now-state.FireCheckStartTime, detectionSec, deviceData.DeviceUUID) + state.FireCheckInCondition = false + } + } + case <-monitorTicker.C: now := time.Now().Unix() @@ -428,6 +577,7 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { // 温度防抖 var tempStableHigh bool threshold := deviceData.TemperatureThreshold + if deviceData.Temperature > threshold+0.5 { tempStableHigh = true } else if deviceData.Temperature < threshold-0.5 { @@ -456,17 +606,42 @@ func processDevice(ctx context.Context, deviceData connect.DeviceData) { logger.Logger.Printf("Alarm condition met, start timing (%ds required): PersonCount=%d, Temp=%.1f°C > %.1f°C (DeviceUUID=%s)", detectionSec, deviceData.PersonCount, deviceData.Temperature, threshold, deviceData.DeviceUUID) } else if now-state.StartTime >= int64(detectionSec) { - logger.Logger.Printf("ALARM TRIGGERED (after %ds): PersonCount=%d, Temp=%.1f°C > %.1f°C (DeviceUUID=%s)", - detectionSec, deviceData.PersonCount, deviceData.Temperature, threshold, deviceData.DeviceUUID) + currentTime := time.Now() + logger.Logger.Printf("[%d] ALARM TRIGGERED (after %ds): PersonCount=%d, Temp=%.1f°C > %.1f°C (DeviceUUID=%s)", + currentTime.Unix(), detectionSec, deviceData.PersonCount, deviceData.Temperature, threshold, deviceData.DeviceUUID) - go triggerAlarmWithDelay(deviceData, state.StartTime, alarmCooldown) + // 异步处理报警,避免阻塞主线程 + go func() { + alarmStartTime := time.Now() + logger.Logger.Printf("[%d] 开始异步处理报警 (DeviceUUID=%s, alarmTime=%d, cooldown=%d)", + alarmStartTime.Unix(), deviceData.DeviceUUID, state.StartTime, alarmCooldown) + // 捕获所有异常,确保goroutine不会因为panic而崩溃 + defer func() { + if err := recover(); err != nil { + logger.Logger.Printf("[%d] 处理报警发生panic: %v (DeviceUUID=%s)", + time.Now().Unix(), err, deviceData.DeviceUUID) + } + }() + + // 调用报警处理函数 + GlobalReporter.TriggerAlarmWithDelay(deviceData, state.StartTime, alarmCooldown) + logger.Logger.Printf("[%d] 报警处理完成 (DeviceUUID=%s, 耗时:%.3fs)", + time.Now().Unix(), deviceData.DeviceUUID, time.Since(alarmStartTime).Seconds()) + }() + + // 更新状态 state.AlarmPauseUntil = now + int64(alarmCooldown) state.InCondition = false + logger.Logger.Printf("[%d] 更新报警状态: 冷却时间设置为 %d 秒 (DeviceUUID=%s)", + time.Now().Unix(), alarmCooldown, deviceData.DeviceUUID) + // 更新设备数据 deviceDataCopy := deviceData deviceDataCopy.AlarmPauseUntil = state.AlarmPauseUntil + logger.Logger.Printf("[%d] 更新设备数据 (DeviceUUID=%s)", time.Now().Unix(), deviceData.DeviceUUID) connect.UpdateDeviceData(deviceDataCopy) + logger.Logger.Printf("[%d] 设备数据更新完成 (DeviceUUID=%s)", time.Now().Unix(), deviceData.DeviceUUID) } } else { if state.InCondition { @@ -485,10 +660,13 @@ func waitForShutdown(ctx context.Context, cancel context.CancelFunc, wg *sync.Wa signal.Notify(stop, os.Interrupt, syscall.SIGTERM) select { - case <-stop: - logger.Logger.Printf("Upon receiving the stop signal, start cleaning...") + case sig := <-stop: + logger.Logger.Printf("Received stop signal: %v, start cleaning...", sig) + // 立即取消上下文,通知所有goroutine停止 cancel() + + // 等待所有goroutine退出 done := make(chan struct{}) go func() { wg.Wait() @@ -498,14 +676,16 @@ func waitForShutdown(ctx context.Context, cancel context.CancelFunc, wg *sync.Wa select { case <-done: logger.Logger.Printf("All goroutines exited normally") - case <-time.After(10 * time.Second): - logger.Logger.Printf("When the waiting time exceeds the limit, force exit") - // Log status before force exit - activeDevices := GlobalDeviceManager.GetAllDevices() - logger.Logger.Printf("It still exists when forced to exit %d An active device", len(activeDevices)) - for uuid := range activeDevices { - logger.Logger.Printf("Forcibly stop the equipment: UUID=%s", uuid) - } + case <-time.After(15 * time.Second): + logger.Logger.Printf("Waiting time exceeded, force exit") + // 强制终止所有推理进程 + connect.InferenceProcesses.Range(func(key, value interface{}) bool { + if cmd, ok := value.(*exec.Cmd); ok && cmd.Process != nil { + logger.Logger.Printf("Force kill process: %v", key) + cmd.Process.Kill() + } + return true + }) } case <-ctx.Done(): @@ -553,12 +733,24 @@ register: logger.Logger.Printf("Service registration successful: ServiceID=%s", serviceID) } + // 更新GlobalReporter的WebSocket客户端 + wsClient := connect.GetWSClient() + GlobalReporter.SetWSClient(wsClient) + logger.Logger.Printf("Updated GlobalReporter with WebSocket client: %v", wsClient != nil) + // 新增:订阅本地告警 topic if err := connect.SubscribeTopic("/dhlr/alert", connect.HandleBuzzerAlert); err != nil { logger.Logger.Printf("订阅告警topic失败 (/dhlr/alert): %v", err) } else { logger.Logger.Printf("成功订阅本地告警topic: /dhlr/alert") } + + // 新增:订阅禁止时间 topic + if err := connect.SubscribeTopic("/dhlr/forbid_time", handleForbidTimeMessage); err != nil { + logger.Logger.Printf("订阅禁止时间topic失败 (/dhlr/forbid_time): %v", err) + } else { + logger.Logger.Printf("成功订阅禁止时间topic: /dhlr/forbid_time") + } } func (dm *DeviceManager) GetAllDevices() map[string]*connect.DeviceData { @@ -571,124 +763,7 @@ func (dm *DeviceManager) GetAllDevices() map[string]*connect.DeviceData { } return result } -func jsonString(v any) string { - b, err := json.Marshal(v) - if err != nil { - logger.Logger.Printf("JSON编码失败: %v", err) - return "" - } - return string(b) -} -// main.go → triggerAlarmWithDelay -func triggerAlarmWithDelay(deviceData connect.DeviceData, alarmTime int64, cooldown int) { - key := fmt.Sprintf("%s_%d", deviceData.DeviceUUID, alarmTime) - if _, loaded := mergingAlarms.LoadOrStore(key, true); loaded { - return - } - defer mergingAlarms.Delete(key) - - // === 1. 合并视频 === - mergedFile, err := video_server.MergeVideo(deviceData.DeviceUUID, alarmTime) - if err != nil { - logger.Logger.Printf("视频合并失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err) - return - } - - // === 2. 上传 Nginx === - var hostUUID string - configs, err := connect.LoadServiceConfig() - if err == nil { - for _, cfg := range configs { - if cfg.DeviceUUID == deviceData.DeviceUUID { - hostUUID = cfg.HostUUID - break - } - } - } - - eventParamsForNginx := map[string]any{ - "alarm_time": alarmTime, - "description": "无人工作区且温度超阈值报警", - "device_UUID": deviceData.DeviceUUID, - "host_uuid": hostUUID, - "person_count": deviceData.PersonCount, - "temperature": deviceData.Temperature, - } - - videoURL, err := video_server.UploadToNginx(mergedFile, eventParamsForNginx, deviceData.UploadURL) - videoURLStr := "" - if err != nil { - logger.Logger.Printf("Nginx上传失败: %v", err) - } else { - videoURLStr = videoURL - logger.Logger.Printf("Nginx上传成功: %s", videoURL) - } - - client := connect.GetWSClient() - if client == nil || !client.IsConnected() { - logger.Logger.Printf("WS未就绪,延迟上报 (DeviceUUID=%s)", deviceData.DeviceUUID) - os.Remove(mergedFile) - return - } - - // === 3. 上报事件 (event) === - eventPayload := map[string]any{ - "type": "event", - "args": map[string]any{ - "level": "alarm", - "description": "无人工作区且温度超阈值报警", - "device_uuid": deviceData.DeviceUUID, - "host_uuid": hostUUID, - "video_url": videoURLStr, - "alarm_time": alarmTime, - "address": deviceData.Address, - }, - } - - eventMsg := map[string]any{ - "method": "metric_data", - "params": map[string]any{ - "route_key": fmt.Sprintf("/dhlr/device/%s/event", deviceData.DeviceUUID), - "metric": jsonString(eventPayload), - }, - } - - client.SendAsync(eventMsg) - logger.Logger.Printf("The alarm incident has been reported (DeviceUUID=%s)", deviceData.DeviceUUID) - - // === 4. 上报数据快照 (data) === - dataPayload := map[string]any{ - "type": "data", - "args": map[string]any{ - "person_count": deviceData.PersonCount, - "temperature": deviceData.Temperature, - "camera_rtsp": deviceData.CameraRTSP, - "task_id": deviceData.TaskID, - "camera_ip": deviceData.CameraIP, - "confidence": deviceData.Confidence, - "alarm_time": alarmTime, - "address": deviceData.Address, - }, - } - - dataMsg := map[string]any{ - "method": "metric_data", - "params": map[string]any{ - "route_key": fmt.Sprintf("/dhlr/device/%s/data", deviceData.DeviceUUID), - "metric": jsonString(dataPayload), - }, - } - - client.SendAsync(dataMsg) - logger.Logger.Printf("指标快照已上报 (DeviceUUID=%s, Temp=%.1f°C, 人:%d)", - deviceData.DeviceUUID, deviceData.Temperature, deviceData.PersonCount) - - // === 5. 清理本地文件 === - os.Remove(mergedFile) - video_server.CleanupOldRawVideos(deviceData.DeviceUUID) - video_server.CleanupOldVideos(deviceData.DeviceUUID) -} func (dm *DeviceManager) RangeDevices(fn func(uuid string, device *connect.DeviceData) bool) { dm.mu.Lock() defer dm.mu.Unlock() @@ -710,8 +785,19 @@ func main() { if err := logger.InitLogger(); err != nil { log.Fatalf("Logger initialization failed: %v", err) } + defer logger.CloseLogger() + // 初始化上报管理器专用日志 + if err := logger.InitReporterLogger(); err != nil { + logger.Logger.Printf("Reporter logger initialization failed: %v", err) + } + + // 初始化GPIO监控专用日志 + if err := logger.InitGPILogger(); err != nil { + logger.Logger.Printf("GPIO logger initialization failed: %v", err) + } + go func() { for { logger.CleanupOldLogs() @@ -752,6 +838,18 @@ func main() { logger.Logger.Printf("Step 4: Asynchronously initialize WebSocket connection") go initWebSocketAsync(ctx, &wg) + logger.Logger.Printf("Step 5: Start GPIO button monitoring") + go func() { + // 创建GPIO按钮监视器 + monitor := gpio_monitor.NewGPIOButtonMonitor(gpio_monitor.ButtonGPIO) + // 启动GPIO监控 + if err := monitor.Start(ctx); err != nil { + logger.Logger.Printf("Failed to start GPIO button monitor: %v", err) + } else { + logger.Logger.Printf("GPIO button monitor started successfully") + } + }() + logger.Logger.Printf("=== Main program startup completed ===") logger.Logger.Printf("System status: Running devices=%d", len(deviceDataList)) @@ -763,13 +861,24 @@ func main() { defer ticker.Stop() for range ticker.C { GlobalDeviceManager.RangeDevices(func(uuid string, device *connect.DeviceData) bool { + // 检查fire_leave推理程序心跳 if connect.ShouldRestartInference(uuid) { - logger.Logger.Printf("推理程序心跳超时(>90s),准备重启: DeviceUUID=%s", uuid) + logger.Logger.Printf("fire_leave推理程序心跳超时(>90s),准备重启: DeviceUUID=%s", uuid) connect.StopInferenceProcess(uuid) time.Sleep(6 * time.Second) connect.StartInferenceProcess(device) connect.UpdateInferenceHeartbeat(uuid) } + + // 检查fire_check推理程序心跳 + fireCheckUUID := fmt.Sprintf("%s_fire_check", uuid) + if connect.ShouldRestartInference(fireCheckUUID) { + logger.Logger.Printf("fire_check推理程序心跳超时(>90s),准备重启: DeviceUUID=%s", uuid) + connect.StopInferenceProcess(fireCheckUUID) + time.Sleep(6 * time.Second) + connect.StartInferenceProcess(device) + connect.UpdateInferenceHeartbeat(fireCheckUUID) + } return true }) } @@ -855,3 +964,72 @@ func debugDeviceData(deviceData connect.DeviceData) { logger.Logger.Printf(" PersonCount: %d", deviceData.PersonCount) logger.Logger.Printf(" Temperature: %f", deviceData.Temperature) } + +// handleForbidTimeMessage 处理禁止时间配置消息 +func handleForbidTimeMessage(message []byte) { + logger.Logger.Printf("Received forbid time message: %s", string(message)) + + // 解析消息 + var msg struct { + UUID string `json:"uuid"` + Topic string `json:"topic"` + QoS int `json:"qos"` + Content json.RawMessage `json:"content"` + Timeout int `json:"timeout"` + } + + if err := json.Unmarshal(message, &msg); err != nil { + logger.Logger.Printf("Failed to parse forbid time message: %v", err) + return + } + + // 解析content字段 + var content struct { + Minute string `json:"minute"` + FireLeaveTimeout string `json:"fire_leave_timeout,omitempty"` + FireCheckTimeout string `json:"fire_check_timeout,omitempty"` + } + + if err := json.Unmarshal(msg.Content, &content); err != nil { + logger.Logger.Printf("Failed to parse content: %v", err) + return + } + + // 处理禁止时间配置 + if content.Minute != "" { + logger.Logger.Printf("Forbid time config received: Minute=%s", content.Minute) + + // 保存到缓存文件 + if err := reporter.SaveForbidTimeConfig(content.Minute); err != nil { + logger.Logger.Printf("Failed to save forbid time config: %v", err) + } else { + logger.Logger.Printf("Forbid time config saved successfully: Minute=%s", content.Minute) + } + } + + // 处理事件周期时限配置 + if content.FireLeaveTimeout != "" || content.FireCheckTimeout != "" { + // 解析时间 + fireLeaveTimeout, err1 := time.ParseDuration(content.FireLeaveTimeout) + fireCheckTimeout, err2 := time.ParseDuration(content.FireCheckTimeout) + + // 如果解析失败,使用默认值 + if err1 != nil { + fireLeaveTimeout = 30 * time.Minute + logger.Logger.Printf("Invalid fire_leave_timeout, using default: 30m0s") + } + if err2 != nil { + fireCheckTimeout = 30 * time.Minute + logger.Logger.Printf("Invalid fire_check_timeout, using default: 30m0s") + } + + logger.Logger.Printf("Event cycle timeout config received: FireLeaveTimeout=%v, FireCheckTimeout=%v", fireLeaveTimeout, fireCheckTimeout) + + // 保存到缓存文件 + if err := reporter.SaveEventCycleTimeoutConfig(fireLeaveTimeout, fireCheckTimeout); err != nil { + logger.Logger.Printf("Failed to save event cycle timeout config: %v", err) + } else { + logger.Logger.Printf("Event cycle timeout config saved successfully: FireLeaveTimeout=%v, FireCheckTimeout=%v", fireLeaveTimeout, fireCheckTimeout) + } + } +} diff --git a/mermaid-diagram.png b/mermaid-diagram.png new file mode 100644 index 0000000..fd29e8b Binary files /dev/null and b/mermaid-diagram.png differ diff --git a/output.log b/output.log new file mode 100644 index 0000000..e69de29 diff --git a/portmanager/portmanager.go b/portmanager/portmanager.go new file mode 100644 index 0000000..65c4dff --- /dev/null +++ b/portmanager/portmanager.go @@ -0,0 +1,278 @@ +package portmanager + +import ( + "errors" + "fmt" + "net" + "sync" + "time" + + "FireLeave_tool/logger" +) + +// PortManager 管理端口分配 +type PortManager struct { + mu sync.Mutex + allocated map[string]DevicePorts // DeviceUUID 到端口映射的映射 + portPool []int // 推理端口池(HTTP) + available map[int]bool // 可用端口标记 +} + +// NewPortManager 创建端口管理器 +func NewPortManager() *PortManager { + // 初始化端口池 + var ports []int + for port := BasePortStart; port <= BasePortEnd; port++ { + ports = append(ports, port) + } + + // 初始化可用端口映射 + available := make(map[int]bool) + for _, port := range ports { + available[port] = true + } + + return &PortManager{ + allocated: make(map[string]DevicePorts), + portPool: ports, + available: available, + } +} + +// AllocatePort 为设备分配基础端口 +func (pm *PortManager) AllocatePort(deviceUUID string) (int, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + // 检查设备是否已分配端口 + if ports, exists := pm.allocated[deviceUUID]; exists { + logger.Logger.Printf("Device %s already allocated port: %d", deviceUUID, ports.Inference) + return ports.Inference, nil + } + + // 查找可用端口 + for _, port := range pm.portPool { + if !pm.available[port] { + continue // 端口已被分配 + } + + tempPort := port + TemperaturePortOffset // 温度端口(TCP) + + // 检查推理端口(HTTP)是否可用 + inferenceLn, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + logger.Logger.Printf("Port %d (inference) is not available: %v", port, err) + continue + } + inferenceLn.Close() + + // 检查温度端口(TCP)是否可用 + tempLn, err := net.Listen("tcp", fmt.Sprintf(":%d", tempPort)) + if err != nil { + logger.Logger.Printf("Port %d (temperature) is not available: %v", tempPort, err) + continue + } + tempLn.Close() + + // 端口可用,记录分配 + pm.allocated[deviceUUID] = DevicePorts{ + Inference: port, + Temperature: tempPort, + } + pm.available[port] = false + + logger.Logger.Printf("Port allocated for device %s: inference=%d, temperature=%d", + deviceUUID, port, tempPort) + return port, nil + } + + return 0, errors.New("no available ports for inference and temperature") +} + +// AllocateFireCheckPort 为火焰检测程序分配端口 +func (pm *PortManager) AllocateFireCheckPort(deviceUUID string) (int, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + // 检查设备是否已分配端口 + ports, exists := pm.allocated[deviceUUID] + if !exists { + // 先分配基础端口 + _, err := pm.AllocatePort(deviceUUID) + if err != nil { + return 0, fmt.Errorf("failed to allocate base port: %v", err) + } + // 重新获取更新后的端口映射 + ports = pm.allocated[deviceUUID] + } + + // 计算火焰检测端口 + fireCheckPort := ports.Inference + FireCheckPortOffset + + // 检查火焰检测端口是否可用 + fireCheckLn, err := net.Listen("tcp", fmt.Sprintf(":%d", fireCheckPort)) + if err != nil { + logger.Logger.Printf("Port %d (fire_check) is not available: %v", fireCheckPort, err) + return 0, fmt.Errorf("fire check port %d not available: %v", fireCheckPort, err) + } + fireCheckLn.Close() + + // 更新端口映射 + ports.FireCheck = fireCheckPort + pm.allocated[deviceUUID] = ports + + logger.Logger.Printf("Fire check port allocated for device %s: %d", deviceUUID, fireCheckPort) + return fireCheckPort, nil +} + +// ReleasePort 释放设备端口 +func (pm *PortManager) ReleasePort(deviceUUID string) { + pm.mu.Lock() + defer pm.mu.Unlock() + + // 删除设备端口映射 + if ports, exists := pm.allocated[deviceUUID]; exists { + delete(pm.allocated, deviceUUID) + pm.available[ports.Inference] = true // 标记基础端口为可用 + logger.Logger.Printf("Port released for device %s: inference=%d, temperature=%d, fire_check=%d", + deviceUUID, ports.Inference, ports.Temperature, ports.FireCheck) + } +} + +// GetPort 获取设备基础端口 +func (pm *PortManager) GetPort(deviceUUID string) (int, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if ports, exists := pm.allocated[deviceUUID]; exists { + return ports.Inference, nil + } + + return 0, fmt.Errorf("device %s has no allocated port", deviceUUID) +} + +// GetFireCheckPort 获取火焰检测程序端口 +func (pm *PortManager) GetFireCheckPort(deviceUUID string) (int, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if ports, exists := pm.allocated[deviceUUID]; exists { + if ports.FireCheck > 0 { + return ports.FireCheck, nil + } + return 0, fmt.Errorf("device %s has no allocated fire check port", deviceUUID) + } + + return 0, fmt.Errorf("device %s has no allocated ports", deviceUUID) +} + +// GetTemperaturePort 获取温度程序端口 +func (pm *PortManager) GetTemperaturePort(deviceUUID string) (int, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if ports, exists := pm.allocated[deviceUUID]; exists { + return ports.Temperature, nil + } + + return 0, fmt.Errorf("device %s has no allocated ports", deviceUUID) +} + +// GetAllocatedPorts 获取所有已分配的端口信息 +func (pm *PortManager) GetAllocatedPorts() []PortInfo { + pm.mu.Lock() + defer pm.mu.Unlock() + + var portInfos []PortInfo + now := time.Now().Unix() + + for deviceUUID, ports := range pm.allocated { + // 基础推理端口 + portInfos = append(portInfos, PortInfo{ + DeviceUUID: deviceUUID, + PortType: PortTypeInference, + Port: ports.Inference, + AllocatedAt: now, + }) + + // 温度端口 + portInfos = append(portInfos, PortInfo{ + DeviceUUID: deviceUUID, + PortType: PortTypeTemperature, + Port: ports.Temperature, + AllocatedAt: now, + }) + + // 火焰检测端口 + if ports.FireCheck > 0 { + portInfos = append(portInfos, PortInfo{ + DeviceUUID: deviceUUID, + PortType: PortTypeFireCheck, + Port: ports.FireCheck, + AllocatedAt: now, + }) + } + + // 新推理端口 + if ports.NewInference > 0 { + portInfos = append(portInfos, PortInfo{ + DeviceUUID: deviceUUID, + PortType: PortTypeNewInference, + Port: ports.NewInference, + AllocatedAt: now, + }) + } + } + + return portInfos +} + +// GetDevicePorts 获取设备的所有端口 +func (pm *PortManager) GetDevicePorts(deviceUUID string) (DevicePorts, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + if ports, exists := pm.allocated[deviceUUID]; exists { + return ports, nil + } + + return DevicePorts{}, fmt.Errorf("device %s has no allocated ports", deviceUUID) +} + +// IsPortAvailable 检查端口是否可用 +func (pm *PortManager) IsPortAvailable(port int) bool { + pm.mu.Lock() + defer pm.mu.Unlock() + + // 检查是否在端口池中 + for _, p := range pm.portPool { + if p == port { + return pm.available[port] + } + } + + // 对于非端口池中的端口,直接检查可用性 + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + ln.Close() + return true +} + +// GetAvailablePortCount 获取可用端口数量 +func (pm *PortManager) GetAvailablePortCount() int { + pm.mu.Lock() + defer pm.mu.Unlock() + + count := 0 + for _, available := range pm.available { + if available { + count++ + } + } + return count +} + +// GlobalPortManager 全局端口管理器 +var GlobalPortManager = NewPortManager() diff --git a/portmanager/types.go b/portmanager/types.go new file mode 100644 index 0000000..041341f --- /dev/null +++ b/portmanager/types.go @@ -0,0 +1,47 @@ +package portmanager + +// PortType 端口类型 +type PortType string + +// 端口类型常量 +const ( + PortTypeInference PortType = "inference" + PortTypeTemperature PortType = "temperature" + PortTypeFireCheck PortType = "fire_check" + PortTypeNewInference PortType = "new_inference" +) + +// DevicePorts 设备端口映射 +type DevicePorts struct { + Inference int `json:"inference"` + Temperature int `json:"temperature"` + FireCheck int `json:"fire_check,omitempty"` + NewInference int `json:"new_inference,omitempty"` +} + +// PortInfo 端口信息 +type PortInfo struct { + DeviceUUID string `json:"device_uuid"` + PortType PortType `json:"port_type"` + Port int `json:"port"` + AllocatedAt int64 `json:"allocated_at"` +} + +// PortAllocation 端口分配结构 +type PortAllocation struct { + DeviceUUID string `json:"device_uuid"` + Ports DevicePorts `json:"ports"` + AllocatedAt int64 `json:"allocated_at"` +} + +// 端口常量 +const ( + // 基础端口池 + BasePortStart = 58881 + BasePortEnd = 58890 + + // 端口偏移 + TemperaturePortOffset = 1000 + FireCheckPortOffset = 2000 + NewInferenceOffset = 3000 +) diff --git a/reporter/reporter.go b/reporter/reporter.go new file mode 100644 index 0000000..828b8b1 --- /dev/null +++ b/reporter/reporter.go @@ -0,0 +1,870 @@ +package reporter + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sync" + "time" + + "FireLeave_tool/connect" + "FireLeave_tool/logger" + "FireLeave_tool/video_server" +) + +var ( + // mergingAlarms 用于合并报警,避免重复处理 + mergingAlarms sync.Map + // 事件周期状态 + eventCycleMutex sync.RWMutex + currentEventCycle EventCycle + cycleTimeout time.Time +) + +// EventCycle 事件周期类型 +type EventCycle string + +const ( + // EventCycleFireLeave 动火离人上报周期 + EventCycleFireLeave EventCycle = "fire_leave" + // EventCycleFireCheck 火焰检测上报周期 + EventCycleFireCheck EventCycle = "fire_check" + // 周期时限缓存文件 + CycleTimeoutFile = "/data/cache/event_cycle_timeout.json" +) + +// Reporter 上报管理器 +type Reporter struct { + wsClient *connect.WSClient + uploadURL string + logger *Logger +} + +// Logger 上报管理器专用日志 +type Logger struct { + *log.Logger +} + +// NewLogger 创建上报管理器专用日志 +func NewLogger() *Logger { + return &Logger{ + Logger: logger.GlobalLoggerManager.GetLogger("reporter"), + } +} + +// NewReporter 创建上报管理器 +func NewReporter() *Reporter { + // 初始化默认事件周期 + eventCycleMutex.Lock() + currentEventCycle = EventCycleFireLeave + eventCycleMutex.Unlock() + + return &Reporter{ + wsClient: connect.GetWSClient(), + uploadURL: "", + logger: NewLogger(), + } +} + +// getLogger 获取日志实例,确保不为nil +func (r *Reporter) getLogger() *log.Logger { + if r.logger != nil && r.logger.Logger != nil { + return r.logger.Logger + } + // 如果logger未初始化,使用全局Logger + return logger.Logger +} + +// GetCurrentEventCycle 获取当前事件周期 +func GetCurrentEventCycle() EventCycle { + eventCycleMutex.RLock() + defer eventCycleMutex.RUnlock() + return currentEventCycle +} + +// IsCycleTimeout 检查事件周期是否超时 +func IsCycleTimeout() bool { + eventCycleMutex.RLock() + defer eventCycleMutex.RUnlock() + return time.Now().After(cycleTimeout) +} + +// SwitchEventCycle 切换事件周期 +func SwitchEventCycle(cycle EventCycle) error { + eventCycleMutex.Lock() + defer eventCycleMutex.Unlock() + + // 读取周期时限 + timeout, err := loadCycleTimeout() + if err != nil { + // 使用默认值 + timeout = 30 * time.Minute + } + + // 更新事件周期和超时时间 + oldCycle := currentEventCycle + currentEventCycle = cycle + cycleTimeout = time.Now().Add(timeout) + + // 记录日志 + if logger := logger.GlobalLoggerManager.GetLogger("reporter"); logger != nil { + logger.Printf("事件周期切换: %s → %s, 时限: %v", oldCycle, cycle, timeout) + } + + return nil +} + +// CycleTimeoutConfig 周期时限配置 +type CycleTimeoutConfig struct { + FireLeaveTimeout time.Duration `json:"fire_leave_timeout"` + FireCheckTimeout time.Duration `json:"fire_check_timeout"` +} + +// loadCycleTimeout 加载周期时限 +func loadCycleTimeout() (time.Duration, error) { + data, err := os.ReadFile(CycleTimeoutFile) + if err != nil { + return 0, err + } + + var config CycleTimeoutConfig + if err := json.Unmarshal(data, &config); err != nil { + return 0, err + } + + // 根据当前周期返回对应时限 + switch currentEventCycle { + case EventCycleFireLeave: + return config.FireLeaveTimeout, nil + case EventCycleFireCheck: + return config.FireCheckTimeout, nil + default: + return 30 * time.Minute, nil + } +} + +// SaveCycleTimeout 保存周期时限 +func SaveCycleTimeout(config CycleTimeoutConfig) error { + data, err := json.Marshal(config) + if err != nil { + return err + } + + // 确保目录存在 + if err := os.MkdirAll("/data/cache", 0755); err != nil { + return err + } + + return os.WriteFile(CycleTimeoutFile, data, 0644) +} + +// ForbidTimeConfig 禁止时间配置 +var ( + // ForbidTimeFile 禁止时间缓存文件 + ForbidTimeFile = "/data/cache/forbid_time_config.json" +) + +// SaveForbidTimeConfig 保存禁止时间配置 +func SaveForbidTimeConfig(minutr string) error { + config := struct { + Minutr string `json:"minutr"` + }{ + Minutr: minutr, + } + + data, err := json.Marshal(config) + if err != nil { + return err + } + + // 确保目录存在 + if err := os.MkdirAll("/data/cache", 0755); err != nil { + return err + } + + return os.WriteFile(ForbidTimeFile, data, 0644) +} + +// SaveEventCycleTimeoutConfig 保存事件周期时限配置 +func SaveEventCycleTimeoutConfig(fireLeaveTimeout, fireCheckTimeout time.Duration) error { + config := CycleTimeoutConfig{ + FireLeaveTimeout: fireLeaveTimeout, + FireCheckTimeout: fireCheckTimeout, + } + + data, err := json.Marshal(config) + if err != nil { + return err + } + + // 确保目录存在 + if err := os.MkdirAll("/data/cache", 0755); err != nil { + return err + } + + return os.WriteFile(CycleTimeoutFile, data, 0644) +} + +// GetForbidTimeConfig 获取禁止时间配置 +func GetForbidTimeConfig() string { + // 读取缓存文件 + data, err := os.ReadFile(ForbidTimeFile) + if err != nil { + // 缓存中没有值时,默认返回0 + return "0" + } + + var config struct { + Minutr string `json:"minutr"` + } + + if err := json.Unmarshal(data, &config); err != nil { + // 解析失败时,默认返回0 + return "0" + } + + return config.Minutr +} + +// ShouldReportBothEvents 判断是否应该同时上报两种事件 +func ShouldReportBothEvents() bool { + // 注意:此逻辑已被修改,现在只在特定情况下使用 + // 正常情况下,应根据当前事件周期上报对应类型的事件 + return false +} + +// jsonString 将任意类型转换为JSON字符串 +func jsonString(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + if logger := logger.GlobalLoggerManager.GetLogger("reporter"); logger != nil { + logger.Printf("JSON编码失败: %v", err) + } + return "" + } + return string(b) +} + +// UploadVideoAndReport 上传视频并上报事件 +func (r *Reporter) UploadVideoAndReport(deviceData connect.DeviceData, alarmTime int64, mergedFile string) error { + // 异步执行视频上传和上报,避免阻塞主线程 + go func() { + logger := r.getLogger() + logger.Printf("开始异步处理视频上传和事件上报 (DeviceUUID=%s, 文件=%s)", deviceData.DeviceUUID, mergedFile) + defer func() { + if err := recover(); err != nil { + logger.Printf("上传视频和上报事件发生 panic: %v", err) + // 确保临时文件被清理 + os.Remove(mergedFile) + } + }() + + // 检查事件周期是否超时 + logger.Printf("检查事件周期状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if IsCycleTimeout() { + // 超时后切换回默认周期 + logger.Printf("事件周期超时,切换回默认周期 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := SwitchEventCycle(EventCycleFireLeave); err != nil { + logger.Printf("事件周期超时切换失败: %v", err) + } + } + + // 获取当前事件周期 + currentCycle := GetCurrentEventCycle() + logger.Printf("当前事件周期: %s (DeviceUUID=%s)", currentCycle, deviceData.DeviceUUID) + + // 检查是否应该同时上报两种事件 + if ShouldReportBothEvents() { + logger.Printf("Forbid time config is 0, will report both event types (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 复制文件路径,避免在goroutine中使用同一个变量 + fileToClean := mergedFile + + // 上报fire_leave事件 + logger.Printf("开始上报fire_leave事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + r.reportEventWithLevel(deviceData, alarmTime, mergedFile, ReportLevelFireLeave) + + // 上报fire_check事件 + logger.Printf("开始上报fire_check事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + r.reportEventWithLevel(deviceData, alarmTime, mergedFile, ReportLevelFireCheck) + + // 延迟清理临时文件,确保上传完成 + go func() { + // 等待一段时间,确保上传操作有足够时间完成 + time.Sleep(5 * time.Second) + logger.Printf("清理临时文件: %s (DeviceUUID=%s)", fileToClean, deviceData.DeviceUUID) + os.Remove(fileToClean) + }() + + logger.Printf("双事件上报完成 (DeviceUUID=%s)", deviceData.DeviceUUID) + return + } + + // 根据当前事件周期确定上报级别 + var reportLevel ReportLevel + switch currentCycle { + case EventCycleFireLeave: + reportLevel = ReportLevelFireLeave + case EventCycleFireCheck: + reportLevel = ReportLevelFireCheck + default: + reportLevel = ReportLevelFireLeave + } + logger.Printf("确定上报级别: %s (DeviceUUID=%s)", reportLevel, deviceData.DeviceUUID) + + // 生成Nginx上传参数 + logger.Printf("生成Nginx上传参数 (DeviceUUID=%s)", deviceData.DeviceUUID) + nginxParams := r.GenerateNginxParams(deviceData, alarmTime, reportLevel) + + // 上传视频到Nginx + logger.Printf("开始上传视频到Nginx (DeviceUUID=%s, 文件=%s, URL=%s)", deviceData.DeviceUUID, mergedFile, deviceData.UploadURL) + videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL) + videoURLStr := "" + if err != nil { + logger.Printf("Nginx上传失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + videoURLStr = videoURL + logger.Printf("Nginx上传成功: %s (DeviceUUID=%s)", videoURL, deviceData.DeviceUUID) + } + + // 检查WebSocket客户端 + logger.Printf("检查WebSocket客户端连接状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if r.wsClient == nil { + logger.Printf("WebSocket客户端未初始化,尝试重新获取 (DeviceUUID=%s)", deviceData.DeviceUUID) + // 尝试重新获取WebSocket客户端 + r.wsClient = connect.GetWSClient() + if r.wsClient == nil { + logger.Printf("WebSocket客户端仍然为nil,无法上报事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + } + + if !r.wsClient.IsConnected() { + logger.Printf("WS未就绪,延迟上报 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + logger.Printf("WebSocket客户端连接状态正常 (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 上报事件 + logger.Printf("开始上报事件 (DeviceUUID=%s, Level=%s, VideoURL=%s)", deviceData.DeviceUUID, reportLevel, videoURLStr) + if err := r.ReportEvent(deviceData, alarmTime, videoURLStr, string(reportLevel)); err != nil { + logger.Printf("事件上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("事件上报成功 (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, reportLevel) + } + + // 上报数据快照 + logger.Printf("开始上报数据快照 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := r.ReportDataSnapshot(deviceData, alarmTime); err != nil { + logger.Printf("数据快照上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("数据快照上报成功 (DeviceUUID=%s)", deviceData.DeviceUUID) + } + + // 清理临时文件 + logger.Printf("清理临时文件: %s (DeviceUUID=%s)", mergedFile, deviceData.DeviceUUID) + os.Remove(mergedFile) + logger.Printf("视频上传和事件上报完成 (DeviceUUID=%s)", deviceData.DeviceUUID) + }() + + return nil +} + +// UploadVideoAndReportFireLeave 上传视频并上报温度报警事件(只上报fire_leave类型) +func (r *Reporter) UploadVideoAndReportFireLeave(deviceData connect.DeviceData, alarmTime int64, mergedFile string) error { + // 异步执行视频上传和上报,避免阻塞主线程 + go func() { + logger := r.getLogger() + logger.Printf("开始异步处理温度报警视频上传和事件上报 (DeviceUUID=%s, 文件=%s)", deviceData.DeviceUUID, mergedFile) + defer func() { + if err := recover(); err != nil { + logger.Printf("上传温度报警视频和上报事件发生 panic: %v", err) + // 确保临时文件被清理 + os.Remove(mergedFile) + } + }() + + // 检查事件周期是否超时 + logger.Printf("检查事件周期状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if IsCycleTimeout() { + // 超时后切换回默认周期 + logger.Printf("事件周期超时,切换回默认周期 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := SwitchEventCycle(EventCycleFireLeave); err != nil { + logger.Printf("事件周期超时切换失败: %v", err) + } + } + + // 生成Nginx上传参数 + logger.Printf("生成Nginx上传参数 (DeviceUUID=%s)", deviceData.DeviceUUID) + nginxParams := r.GenerateNginxParams(deviceData, alarmTime, ReportLevelFireLeave) + + // 上传视频到Nginx + logger.Printf("开始上传视频到Nginx (DeviceUUID=%s, 文件=%s, URL=%s)", deviceData.DeviceUUID, mergedFile, deviceData.UploadURL) + videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL) + videoURLStr := "" + if err != nil { + logger.Printf("Nginx上传失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + videoURLStr = videoURL + logger.Printf("Nginx上传成功: %s (DeviceUUID=%s)", videoURL, deviceData.DeviceUUID) + } + + // 检查WebSocket客户端 + logger.Printf("检查WebSocket客户端连接状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if r.wsClient == nil { + logger.Printf("WebSocket客户端未初始化,尝试重新获取 (DeviceUUID=%s)", deviceData.DeviceUUID) + // 尝试重新获取WebSocket客户端 + r.wsClient = connect.GetWSClient() + if r.wsClient == nil { + logger.Printf("WebSocket客户端仍然为nil,无法上报事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + } + + if !r.wsClient.IsConnected() { + logger.Printf("WS未就绪,延迟上报 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + logger.Printf("WebSocket客户端连接状态正常 (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 上报事件(只上报fire_leave类型) + logger.Printf("开始上报温度报警事件 (DeviceUUID=%s, Level=%s, VideoURL=%s)", deviceData.DeviceUUID, ReportLevelFireLeave, videoURLStr) + if err := r.ReportEvent(deviceData, alarmTime, videoURLStr, string(ReportLevelFireLeave)); err != nil { + logger.Printf("温度报警事件上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("温度报警事件上报成功 (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, ReportLevelFireLeave) + } + + // 上报数据快照 + logger.Printf("开始上报数据快照 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := r.ReportDataSnapshot(deviceData, alarmTime); err != nil { + logger.Printf("数据快照上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("数据快照上报成功 (DeviceUUID=%s)", deviceData.DeviceUUID) + } + + // 清理临时文件 + logger.Printf("清理临时文件: %s (DeviceUUID=%s)", mergedFile, deviceData.DeviceUUID) + os.Remove(mergedFile) + logger.Printf("温度报警视频上传和事件上报完成 (DeviceUUID=%s)", deviceData.DeviceUUID) + }() + + return nil +} + +// UploadVideoAndReportFireCheck 上传视频并上报火焰检测报警事件(只上报fire_check类型) +func (r *Reporter) UploadVideoAndReportFireCheck(deviceData connect.DeviceData, alarmTime int64, mergedFile string) error { + // 异步执行视频上传和上报,避免阻塞主线程 + go func() { + logger := r.getLogger() + logger.Printf("开始异步处理火焰检测报警视频上传和事件上报 (DeviceUUID=%s, 文件=%s)", deviceData.DeviceUUID, mergedFile) + defer func() { + if err := recover(); err != nil { + logger.Printf("上传火焰检测报警视频和上报事件发生 panic: %v", err) + // 确保临时文件被清理 + os.Remove(mergedFile) + } + }() + + // 检查事件周期是否超时 + logger.Printf("检查事件周期状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if IsCycleTimeout() { + // 超时后切换到fire_check周期 + logger.Printf("事件周期超时,切换到fire_check周期 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := SwitchEventCycle(EventCycleFireCheck); err != nil { + logger.Printf("事件周期超时切换失败: %v", err) + } + } + + // 生成Nginx上传参数 + logger.Printf("生成Nginx上传参数 (DeviceUUID=%s)", deviceData.DeviceUUID) + nginxParams := r.GenerateNginxParams(deviceData, alarmTime, ReportLevelFireCheck) + + // 上传视频到Nginx + logger.Printf("开始上传视频到Nginx (DeviceUUID=%s, 文件=%s, URL=%s)", deviceData.DeviceUUID, mergedFile, deviceData.UploadURL) + videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL) + videoURLStr := "" + if err != nil { + logger.Printf("Nginx上传失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + videoURLStr = videoURL + logger.Printf("Nginx上传成功: %s (DeviceUUID=%s)", videoURL, deviceData.DeviceUUID) + } + + // 检查WebSocket客户端 + logger.Printf("检查WebSocket客户端连接状态 (DeviceUUID=%s)", deviceData.DeviceUUID) + if r.wsClient == nil { + logger.Printf("WebSocket客户端未初始化,尝试重新获取 (DeviceUUID=%s)", deviceData.DeviceUUID) + // 尝试重新获取WebSocket客户端 + r.wsClient = connect.GetWSClient() + if r.wsClient == nil { + logger.Printf("WebSocket客户端仍然为nil,无法上报事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + } + + if !r.wsClient.IsConnected() { + logger.Printf("WS未就绪,延迟上报 (DeviceUUID=%s)", deviceData.DeviceUUID) + os.Remove(mergedFile) + return + } + logger.Printf("WebSocket客户端连接状态正常 (DeviceUUID=%s)", deviceData.DeviceUUID) + + // 上报事件(只上报fire_check类型) + logger.Printf("开始上报火焰检测报警事件 (DeviceUUID=%s, Level=%s, VideoURL=%s)", deviceData.DeviceUUID, ReportLevelFireCheck, videoURLStr) + if err := r.ReportEvent(deviceData, alarmTime, videoURLStr, string(ReportLevelFireCheck)); err != nil { + logger.Printf("火焰检测报警事件上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("火焰检测报警事件上报成功 (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, ReportLevelFireCheck) + } + + // 上报数据快照 + logger.Printf("开始上报数据快照 (DeviceUUID=%s)", deviceData.DeviceUUID) + if err := r.ReportDataSnapshot(deviceData, alarmTime); err != nil { + logger.Printf("数据快照上报失败: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID) + } else { + logger.Printf("数据快照上报成功 (DeviceUUID=%s)", deviceData.DeviceUUID) + } + + // 清理临时文件 + logger.Printf("清理临时文件: %s (DeviceUUID=%s)", mergedFile, deviceData.DeviceUUID) + os.Remove(mergedFile) + logger.Printf("火焰检测报警视频上传和事件上报完成 (DeviceUUID=%s)", deviceData.DeviceUUID) + }() + + return nil +} + +// reportEventWithLevel 按指定级别上报事件 +func (r *Reporter) reportEventWithLevel(deviceData connect.DeviceData, alarmTime int64, mergedFile string, reportLevel ReportLevel) error { + // 异步执行视频上传和上报,避免阻塞主线程 + go func() { + logger := r.getLogger() + defer func() { + if err := recover(); err != nil { + logger.Printf("按指定级别上报事件发生 panic: %v", err) + } + }() + + // 生成Nginx上传参数 + nginxParams := r.GenerateNginxParams(deviceData, alarmTime, reportLevel) + + // 上传视频到Nginx + videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL) + videoURLStr := "" + if err != nil { + logger.Printf("Nginx上传失败: %v", err) + } else { + videoURLStr = videoURL + logger.Printf("Nginx上传成功: %s", videoURL) + } + + // 检查WebSocket客户端 + if r.wsClient == nil || !r.wsClient.IsConnected() { + logger.Printf("WS未就绪,延迟上报 (DeviceUUID=%s)", deviceData.DeviceUUID) + return + } + + // 上报事件 + if err := r.ReportEvent(deviceData, alarmTime, videoURLStr, string(reportLevel)); err != nil { + logger.Printf("事件上报失败: %v", err) + } + + // 上报数据快照 + if err := r.ReportDataSnapshot(deviceData, alarmTime); err != nil { + logger.Printf("数据快照上报失败: %v", err) + } + + logger.Printf("按指定级别上报事件完成 (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, string(reportLevel)) + }() + + return nil +} + +// ReportEvent 上报事件 +func (r *Reporter) ReportEvent(deviceData connect.DeviceData, alarmTime int64, videoURL string, level string) error { + logger := r.getLogger() + + // 检查WebSocket客户端 + if r.wsClient == nil { + logger.Printf("WebSocket客户端未初始化,无法上报事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + return fmt.Errorf("websocket client not initialized") + } + + if !r.wsClient.IsConnected() { + logger.Printf("WebSocket客户端未连接,无法上报事件 (DeviceUUID=%s)", deviceData.DeviceUUID) + return fmt.Errorf("websocket client not connected") + } + + // 生成事件消息 + eventMsg := r.GenerateEventMessage(deviceData, alarmTime, videoURL, level) + + // 发送事件消息 + r.wsClient.SendAsync(eventMsg) + logger.Printf("报警事件已上报 (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, level) + + return nil +} + +// ReportDataSnapshot 上报数据快照 +func (r *Reporter) ReportDataSnapshot(deviceData connect.DeviceData, alarmTime int64) error { + logger := r.getLogger() + + // 检查WebSocket客户端 + if r.wsClient == nil { + logger.Printf("WebSocket客户端未初始化,无法上报数据快照 (DeviceUUID=%s)", deviceData.DeviceUUID) + return fmt.Errorf("websocket client not initialized") + } + + if !r.wsClient.IsConnected() { + logger.Printf("WebSocket客户端未连接,无法上报数据快照 (DeviceUUID=%s)", deviceData.DeviceUUID) + return fmt.Errorf("websocket client not connected") + } + + // 生成数据快照消息 + dataMsg := r.GenerateDataMessage(deviceData, alarmTime) + + // 发送数据快照消息 + r.wsClient.SendAsync(dataMsg) + logger.Printf("数据快照已上报 (DeviceUUID=%s)", deviceData.DeviceUUID) + + return nil +} + +// GenerateEventMessage 生成事件消息 +func (r *Reporter) GenerateEventMessage(deviceData connect.DeviceData, alarmTime int64, videoURL string, level string) map[string]interface{} { + // 获取HostUUID + hostUUID := r.getHostUUID(deviceData.DeviceUUID) + + // 生成描述 + description := "无人工作区且温度超阈值报警" + if level == string(ReportLevelFireCheck) { + description = "检测到异常火苗" + } + + // 构建事件负载 + eventPayload := EventPayload{ + Type: string(ReportTypeEvent), + Args: map[string]interface{}{ + "level": level, + "description": description, + "device_uuid": deviceData.DeviceUUID, + "host_uuid": hostUUID, + "video_url": videoURL, + "alarm_time": alarmTime, + "address": deviceData.Address, + }, + } + + // 构建上报消息 + eventMsg := map[string]interface{}{ + "method": "metric_data", + "params": map[string]interface{}{ + "route_key": fmt.Sprintf("/dhlr/device/%s/event", deviceData.DeviceUUID), + "metric": jsonString(eventPayload), + }, + } + + return eventMsg +} + +// GenerateDataMessage 生成数据快照消息 +func (r *Reporter) GenerateDataMessage(deviceData connect.DeviceData, alarmTime int64) map[string]interface{} { + // 构建数据快照负载 + dataPayload := DataPayload{ + Type: string(ReportTypeData), + Args: map[string]interface{}{ + "person_count": deviceData.PersonCount, + "temperature": deviceData.Temperature, + "camera_rtsp": deviceData.CameraRTSP, + "task_id": deviceData.TaskID, + "camera_ip": deviceData.CameraIP, + "confidence": deviceData.Confidence, + "alarm_time": alarmTime, + "address": deviceData.Address, + }, + } + + // 构建上报消息 + dataMsg := map[string]interface{}{ + "method": "metric_data", + "params": map[string]interface{}{ + "route_key": fmt.Sprintf("/dhlr/device/%s/data", deviceData.DeviceUUID), + "metric": jsonString(dataPayload), + }, + } + + return dataMsg +} + +// GenerateNginxParams 生成Nginx上传参数 +func (r *Reporter) GenerateNginxParams(deviceData connect.DeviceData, alarmTime int64, level ReportLevel) map[string]interface{} { + // 获取HostUUID + hostUUID := r.getHostUUID(deviceData.DeviceUUID) + + // 生成描述 + description := "无人工作区且温度超阈值报警" + if level == ReportLevelFireCheck { + description = "检测到异常火苗" + } + + // 构建参数 + params := map[string]interface{}{ + "alarm_time": alarmTime, + "description": description, + "device_UUID": deviceData.DeviceUUID, + "host_uuid": hostUUID, + "person_count": deviceData.PersonCount, + "level": string(level), + } + + // 火焰检测不需要温度数据 + if level != ReportLevelFireCheck { + params["temperature"] = deviceData.Temperature + } + + return params +} + +// TriggerAlarmWithDelay 延迟触发报警并上报(温度报警) +func (r *Reporter) TriggerAlarmWithDelay(deviceData connect.DeviceData, alarmTime int64, cooldown int) { + startTime := time.Now() + logger := r.getLogger() + logger.Printf("[%.3f] 开始处理温度报警事件 (DeviceUUID=%s, alarmTime=%d, cooldown=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime, cooldown) + + // 捕获所有异常,确保函数不会因为panic而崩溃 + defer func() { + if err := recover(); err != nil { + logger.Printf("[%.3f] 处理温度报警事件发生panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID) + // 确保锁被释放 + key := fmt.Sprintf("%s_%d", deviceData.DeviceUUID, alarmTime) + mergingAlarms.Delete(key) + } + }() + + // 防止重复处理 + key := fmt.Sprintf("%s_%d", deviceData.DeviceUUID, alarmTime) + logger.Printf("[%.3f] 检查温度报警事件是否已在处理中 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + if _, loaded := mergingAlarms.LoadOrStore(key, true); loaded { + logger.Printf("[%.3f] 温度报警事件已在处理中,跳过重复处理 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + return + } + defer mergingAlarms.Delete(key) + logger.Printf("[%.3f] 成功获取处理锁 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + + // 合并视频 + logger.Printf("[%.3f] 开始合并视频 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime) + mergeStartTime := time.Now() + mergedFile, err := video_server.MergeVideo(deviceData.DeviceUUID, alarmTime) + if err != nil { + logger.Printf("[%.3f] 视频合并失败 (DeviceUUID=%s, 耗时:%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds(), err) + return + } + logger.Printf("[%.3f] 视频合并成功,生成文件: %s (DeviceUUID=%s, 耗时:%.3fs)", time.Since(startTime).Seconds(), mergedFile, deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds()) + + // 上传视频并上报(只上报温度报警,即fire_leave类型) + logger.Printf("[%.3f] 开始上传视频并上报温度报警事件 (DeviceUUID=%s, 文件=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, mergedFile) + uploadStartTime := time.Now() + if err := r.UploadVideoAndReportFireLeave(deviceData, alarmTime, mergedFile); err != nil { + logger.Printf("[%.3f] 温度报警上报失败 (DeviceUUID=%s, 耗时:%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds(), err) + // 清理临时文件 + if err := os.Remove(mergedFile); err != nil { + logger.Printf("[%.3f] 清理临时文件失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID) + } + return + } + logger.Printf("[%.3f] 温度报警上报成功 (DeviceUUID=%s, 耗时:%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds()) + + logger.Printf("[%.3f] 温度报警事件处理完成 (DeviceUUID=%s, 总耗时:%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(startTime).Seconds()) +} + +// TriggerFireCheckAlarm 触发火焰检测报警并上报 +func (r *Reporter) TriggerFireCheckAlarm(deviceData connect.DeviceData, alarmTime int64) { + startTime := time.Now() + logger := r.getLogger() + logger.Printf("[%.3f] 开始处理火焰检测报警事件 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime) + + // 捕获所有异常,确保函数不会因为panic而崩溃 + defer func() { + if err := recover(); err != nil { + logger.Printf("[%.3f] 处理火焰检测报警事件发生panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID) + // 确保锁被释放 + key := fmt.Sprintf("%s_fire_%d", deviceData.DeviceUUID, alarmTime) + mergingAlarms.Delete(key) + } + }() + + // 防止重复处理 + key := fmt.Sprintf("%s_fire_%d", deviceData.DeviceUUID, alarmTime) + logger.Printf("[%.3f] 检查火焰检测报警事件是否已在处理中 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + if _, loaded := mergingAlarms.LoadOrStore(key, true); loaded { + logger.Printf("[%.3f] 火焰检测报警事件已在处理中,跳过重复处理 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + return + } + defer mergingAlarms.Delete(key) + logger.Printf("[%.3f] 成功获取处理锁 (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key) + + // 合并视频(使用fire_check目录的视频) + logger.Printf("[%.3f] 开始合并火焰检测视频 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime) + mergeStartTime := time.Now() + // 这里可以修改MergeVideo函数,使其支持指定目录,或者创建一个新的函数用于合并fire_check视频 + mergedFile, err := video_server.MergeVideo(deviceData.DeviceUUID+"_fire_check", alarmTime) + if err != nil { + logger.Printf("[%.3f] 火焰检测视频合并失败 (DeviceUUID=%s, 耗时:%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds(), err) + return + } + logger.Printf("[%.3f] 火焰检测视频合并成功,生成文件: %s (DeviceUUID=%s, 耗时:%.3fs)", time.Since(startTime).Seconds(), mergedFile, deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds()) + + // 上传视频并上报(只上报火焰检测,即fire_check类型) + logger.Printf("[%.3f] 开始上传视频并上报火焰检测报警事件 (DeviceUUID=%s, 文件=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, mergedFile) + uploadStartTime := time.Now() + if err := r.UploadVideoAndReportFireCheck(deviceData, alarmTime, mergedFile); err != nil { + logger.Printf("[%.3f] 火焰检测报警上报失败 (DeviceUUID=%s, 耗时:%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds(), err) + // 清理临时文件 + if err := os.Remove(mergedFile); err != nil { + logger.Printf("[%.3f] 清理临时文件失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID) + } + return + } + logger.Printf("[%.3f] 火焰检测报警上报成功 (DeviceUUID=%s, 耗时:%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds()) + + logger.Printf("[%.3f] 火焰检测报警事件处理完成 (DeviceUUID=%s, 总耗时:%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(startTime).Seconds()) +} + +// getHostUUID 获取设备的HostUUID +func (r *Reporter) getHostUUID(deviceUUID string) string { + configs, err := connect.LoadServiceConfig() + if err != nil { + r.logger.Printf("加载服务配置失败: %v", err) + return "" + } + + for _, cfg := range configs { + if cfg.DeviceUUID == deviceUUID { + return cfg.HostUUID + } + } + + return "" +} + +// SetWSClient 设置WebSocket客户端 +func (r *Reporter) SetWSClient(client *connect.WSClient) { + r.wsClient = client +} + +// SetUploadURL 设置上传URL +func (r *Reporter) SetUploadURL(url string) { + r.uploadURL = url +} + +// IsWSReady 检查WebSocket是否就绪 +func (r *Reporter) IsWSReady() bool { + return r.wsClient != nil && r.wsClient.IsConnected() +} diff --git a/reporter/types.go b/reporter/types.go new file mode 100644 index 0000000..68e0ded --- /dev/null +++ b/reporter/types.go @@ -0,0 +1,57 @@ +package reporter + +// ReportLevel 上报级别类型 +type ReportLevel string + +// 上报级别常量 +const ( + ReportLevelAlarm ReportLevel = "alarm" + ReportLevelFireLeave ReportLevel = "fire_leave" + ReportLevelFireCheck ReportLevel = "fire_check" +) + +// ReportType 上报类型 +type ReportType string + +// 上报类型常量 +const ( + ReportTypeEvent ReportType = "event" + ReportTypeData ReportType = "data" +) + +// EventPayload 事件上报负载 +type EventPayload struct { + Type string `json:"type"` + Args map[string]interface{} `json:"args"` +} + +// DataPayload 数据快照上报负载 +type DataPayload struct { + Type string `json:"type"` + Args map[string]interface{} `json:"args"` +} + +// ReportMessage 上报消息结构 +type ReportMessage struct { + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +// NginxUploadParams Nginx上传参数 +type NginxUploadParams struct { + AlarmTime int64 `json:"alarm_time"` + Description string `json:"description"` + DeviceUUID string `json:"device_UUID"` + HostUUID string `json:"host_uuid"` + PersonCount int `json:"person_count"` + Temperature float64 `json:"temperature,omitempty"` + Level string `json:"level,omitempty"` +} + +// ReportOptions 上报选项 +type ReportOptions struct { + Level ReportLevel + Description string + VideoURL string + AlarmTime int64 +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..28a5c8b --- /dev/null +++ b/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +echo "Starting efka_flow_new inference..." +cd /usr/local/models +export LD_LIBRARY_PATH=/usr/lib:$LD_LIBRARY_PATH + + +# 启动主程序,在前台运行 +./fireleave_tool \ No newline at end of file diff --git a/video_server/video_merge.go b/video_server/video_merge.go index d47ecdd..97cc158 100644 --- a/video_server/video_merge.go +++ b/video_server/video_merge.go @@ -1,6 +1,7 @@ package video_server import ( + "context" "fmt" "io" "os" @@ -16,14 +17,33 @@ import ( // MergeVideo 合并视频文件 - 完全适配 mp4_merge --out 模式 func MergeVideo(deviceUUID string, alarmTime int64) (string, error) { + startTime := time.Now() + logger.Logger.Printf("[%.3f] 开始合并视频 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) + + // 捕获所有异常,确保函数不会因为panic而崩溃 + defer func() { + if err := recover(); err != nil { + logger.Logger.Printf("[%.3f] 合并视频发生panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) + } + }() + + // 检查视频目录 videoDir := fmt.Sprintf("/usr/data/camera/%s", deviceUUID) + logger.Logger.Printf("[%.3f] 检查视频目录: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) if _, err := os.Stat(videoDir); os.IsNotExist(err) { + logger.Logger.Printf("[%.3f] 视频目录不存在: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) return "", fmt.Errorf("video directory not exist: %s", videoDir) + } else if err != nil { + logger.Logger.Printf("[%.3f] 检查视频目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) + return "", fmt.Errorf("check video directory failed: %v", err) } + logger.Logger.Printf("[%.3f] 视频目录检查成功: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) // === 1. 时间对齐到 10 秒 === + logger.Logger.Printf("[%.3f] 开始时间对齐处理 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) alarmTimeAligned := alignTo10Seconds(alarmTime) alarmTimeStr := time.Unix(alarmTimeAligned, 0).Format("20060102150405") + logger.Logger.Printf("[%.3f] 时间对齐结果: alarmTime=%d → aligned=%d (DeviceUUID=%s)", time.Since(startTime).Seconds(), alarmTime, alarmTimeAligned, deviceUUID) beforeTime := alarmTimeAligned - 10 afterTime := alarmTimeAligned + 10 @@ -37,67 +57,85 @@ func MergeVideo(deviceUUID string, alarmTime int64) (string, error) { afterStr: filepath.Join(videoDir, afterStr+".mp4"), } + // 查找视频文件 videoFiles := []string{} + logger.Logger.Printf("[%.3f] 开始查找视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) for name, path := range paths { + logger.Logger.Printf("[%.3f] 检查文件: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), path, deviceUUID) if _, err := os.Stat(path); err == nil { videoFiles = append(videoFiles, path) - logger.Logger.Printf("Found video: %s", name+".mp4") + logger.Logger.Printf("[%.3f] Found video: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), name+".mp4", deviceUUID) } else { - logger.Logger.Printf("Missing video: %s", name+".mp4") + logger.Logger.Printf("[%.3f] Missing video: %s (DeviceUUID=%s, error: %v)", time.Since(startTime).Seconds(), name+".mp4", deviceUUID, err) } } // === 2. 补充替代文件 === + logger.Logger.Printf("[%.3f] 当前找到 %d 个视频文件,开始补充替代文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 3 { - allFiles, _ := os.ReadDir(videoDir) - var candidates []struct { - path string - ts int64 - } - for _, f := range allFiles { - if !f.IsDir() && strings.HasSuffix(f.Name(), ".mp4") && !strings.Contains(f.Name(), "_joined") { - if ts, err := parseSimpleTimestamp(f.Name()); err == nil { - candidates = append(candidates, struct { - path string - ts int64 - }{filepath.Join(videoDir, f.Name()), ts}) + logger.Logger.Printf("[%.3f] 视频文件不足3个,开始查找替代文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) + allFiles, err := os.ReadDir(videoDir) + if err != nil { + logger.Logger.Printf("[%.3f] 读取目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) + } else { + logger.Logger.Printf("[%.3f] 读取目录所有文件,共 %d 个文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(allFiles), deviceUUID) + var candidates []struct { + path string + ts int64 + } + for _, f := range allFiles { + if !f.IsDir() && strings.HasSuffix(f.Name(), ".mp4") && !strings.Contains(f.Name(), "_joined") { + if ts, err := parseSimpleTimestamp(f.Name()); err == nil { + candidates = append(candidates, struct { + path string + ts int64 + }{filepath.Join(videoDir, f.Name()), ts}) + } } } - } - targets := []int64{beforeTime, alarmTimeAligned, afterTime} - for _, target := range targets { - if containsTimestamp(videoFiles, target) { - continue - } - closest := findClosest(candidates, target) - if closest != "" && !contains(videoFiles, closest) { - videoFiles = append(videoFiles, closest) - logger.Logger.Printf("Using fallback: %s", filepath.Base(closest)) + logger.Logger.Printf("[%.3f] 找到 %d 个候选视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(candidates), deviceUUID) + targets := []int64{beforeTime, alarmTimeAligned, afterTime} + for _, target := range targets { + if containsTimestamp(videoFiles, target) { + continue + } + closest := findClosest(candidates, target) + if closest != "" && !contains(videoFiles, closest) { + videoFiles = append(videoFiles, closest) + logger.Logger.Printf("[%.3f] Using fallback: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), filepath.Base(closest), deviceUUID) + } } } } + logger.Logger.Printf("[%.3f] 最终收集到 %d 个视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 2 { + logger.Logger.Printf("[%.3f] 视频文件不足,至少需要 2 个文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) return "", fmt.Errorf("insufficient video files: %d", len(videoFiles)) } // === 3. 排序 === + logger.Logger.Printf("[%.3f] 开始排序视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) sort.Slice(videoFiles, func(i, j int) bool { return getTimestampFromPath(videoFiles[i]) < getTimestampFromPath(videoFiles[j]) }) - logger.Logger.Printf("Merging %d videos:", len(videoFiles)) + logger.Logger.Printf("[%.3f] Merging %d videos (DeviceUUID=%s):", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) for i, f := range videoFiles { - logger.Logger.Printf(" [%d] %s", i, filepath.Base(f)) + logger.Logger.Printf("[%.3f] [%d] %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), i, filepath.Base(f), deviceUUID) } // === 4. 显式指定输出文件(100% 可控)=== outputFile := fmt.Sprintf("%s.mp4_joined.mp4", alarmTimeStr) joinedPath := filepath.Join(videoDir, outputFile) + logger.Logger.Printf("[%.3f] 输出文件路径: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) // 清理旧文件(防止冲突) + logger.Logger.Printf("[%.3f] 清理旧的输出文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) if err := os.Remove(joinedPath); err != nil && !os.IsNotExist(err) { - logger.Logger.Printf("Failed to remove old output file %s: %v", joinedPath, err) + logger.Logger.Printf("[%.3f] Failed to remove old output file %s: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) + } else if err == nil { + logger.Logger.Printf("[%.3f] 旧的输出文件清理成功: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) } // === 5. 调用 mp4_merge,显式 --out === @@ -105,42 +143,61 @@ func MergeVideo(deviceUUID string, alarmTime int64) (string, error) { cmd := exec.Command("mp4_merge", args...) cmd.Stdout = logger.Logger.Writer() cmd.Stderr = logger.Logger.Writer() + logger.Logger.Printf("[%.3f] 开始执行视频合并命令: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), cmd.Args, deviceUUID) + mergeStartTime := time.Now() if err := cmd.Run(); err != nil { + logger.Logger.Printf("[%.3f] 视频合并命令执行失败 (耗时:%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("mp4_merge failed: %v", err) } + logger.Logger.Printf("[%.3f] 视频合并命令执行成功 (耗时:%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), deviceUUID) // === 6. 验证输出文件存在 === + logger.Logger.Printf("[%.3f] 验证输出文件是否存在: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) if _, err := os.Stat(joinedPath); err != nil { + logger.Logger.Printf("[%.3f] 输出文件未创建: %s, error: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) return "", fmt.Errorf("output file not created: %s, error: %v", joinedPath, err) } info, err := os.Stat(joinedPath) if err != nil { + logger.Logger.Printf("[%.3f] 获取输出文件信息失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("stat output file failed: %v", err) } - logger.Logger.Printf("Merged file created: %s (size: %d bytes)", outputFile, info.Size()) + logger.Logger.Printf("[%.3f] Merged file created: %s (size: %d bytes) (DeviceUUID=%s)", time.Since(startTime).Seconds(), outputFile, info.Size(), deviceUUID) // === 7. 移动到 /data/upload/ === + logger.Logger.Printf("[%.3f] 开始移动文件到 /data/upload/ 目录 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) t := time.Unix(alarmTimeAligned, 0) year, month, day := t.Date() timestampPart := fmt.Sprintf("%d-%d-%d-%d", year, int(month), day, alarmTimeAligned) finalName := fmt.Sprintf("%s-%s.MP4", deviceUUID, timestampPart) finalPath := filepath.Join("/data/upload", finalName) + logger.Logger.Printf("[%.3f] 最终文件路径: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), finalPath, deviceUUID) + logger.Logger.Printf("[%.3f] 确保 /data/upload 目录存在 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) if err := os.MkdirAll("/data/upload", 0755); err != nil { + logger.Logger.Printf("[%.3f] 创建 /data/upload 目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("create /data/upload failed: %v", err) } // 跨分区复制 + 删除 + logger.Logger.Printf("[%.3f] 开始复制文件: %s → %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, finalPath, deviceUUID) + copyStartTime := time.Now() if err := copyFile(joinedPath, finalPath); err != nil { + logger.Logger.Printf("[%.3f] 文件复制失败 (耗时:%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("copy failed: %v", err) } + logger.Logger.Printf("[%.3f] 文件复制成功 (耗时:%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), deviceUUID) + + logger.Logger.Printf("[%.3f] 删除临时文件: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) if err := os.Remove(joinedPath); err != nil { - logger.Logger.Printf("Failed to delete temp file %s: %v", joinedPath, err) + logger.Logger.Printf("[%.3f] Failed to delete temp file %s: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) + } else { + logger.Logger.Printf("[%.3f] 临时文件删除成功 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) } - logger.Logger.Printf("Video merged and moved successfully: %s", finalPath) + logger.Logger.Printf("[%.3f] Video merged and moved successfully: %s (DeviceUUID=%s, 总耗时:%.3fs)", time.Since(startTime).Seconds(), finalPath, deviceUUID, time.Since(startTime).Seconds()) return finalPath, nil } @@ -181,24 +238,89 @@ func abs(x int64) int64 { return x } -// copyFile 支持跨分区复制 +// copyFile 支持跨分区复制,带超时机制 func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err + startTime := time.Now() + logger.Logger.Printf("[%.3f] 开始复制文件: %s → %s", time.Since(startTime).Seconds(), src, dst) + + // 创建带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 创建通道用于接收复制结果 + type copyResult struct { + n int64 + err error } - defer in.Close() + resultChan := make(chan copyResult, 1) + + // 在goroutine中执行复制操作 + go func() { + // 打开源文件 + logger.Logger.Printf("[%.3f] 打开源文件: %s", time.Since(startTime).Seconds(), src) + in, err := os.Open(src) + if err != nil { + resultChan <- copyResult{0, err} + return + } + defer in.Close() - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() + // 创建目标文件 + logger.Logger.Printf("[%.3f] 创建目标文件: %s", time.Since(startTime).Seconds(), dst) + out, err := os.Create(dst) + if err != nil { + resultChan <- copyResult{0, err} + return + } + defer out.Close() - if _, err = io.Copy(out, in); err != nil { - return err + // 获取源文件大小 + fileInfo, err := in.Stat() + if err != nil { + logger.Logger.Printf("[%.3f] 获取源文件信息失败: %v", time.Since(startTime).Seconds(), err) + } else { + logger.Logger.Printf("[%.3f] 源文件大小: %d 字节", time.Since(startTime).Seconds(), fileInfo.Size()) + } + + // 执行复制 + logger.Logger.Printf("[%.3f] 开始执行文件复制操作", time.Since(startTime).Seconds()) + copyStartTime := time.Now() + n, err := io.Copy(out, in) + elapsed := time.Since(copyStartTime) + if err != nil { + logger.Logger.Printf("[%.3f] 文件复制失败: %v, 已复制 %d 字节, 耗时 %v", time.Since(startTime).Seconds(), err, n, elapsed) + resultChan <- copyResult{n, err} + return + } + logger.Logger.Printf("[%.3f] 文件复制成功: 复制 %d 字节, 耗时 %v", time.Since(startTime).Seconds(), n, elapsed) + + // 同步文件到磁盘 + logger.Logger.Printf("[%.3f] 开始同步文件到磁盘", time.Since(startTime).Seconds()) + syncStartTime := time.Now() + if err = out.Sync(); err != nil { + logger.Logger.Printf("[%.3f] 文件同步失败: %v, 耗时 %v", time.Since(startTime).Seconds(), err, time.Since(syncStartTime)) + resultChan <- copyResult{n, err} + return + } + logger.Logger.Printf("[%.3f] 文件同步成功, 耗时 %v", time.Since(startTime).Seconds(), time.Since(syncStartTime)) + + resultChan <- copyResult{n, nil} + }() + + // 等待复制结果或超时 + select { + case result := <-resultChan: + if result.err != nil { + return result.err + } + case <-ctx.Done(): + err := ctx.Err() + logger.Logger.Printf("[%.3f] 文件复制操作超时: %v", time.Since(startTime).Seconds(), err) + return fmt.Errorf("copy file timeout: %v", err) } - return out.Sync() + + logger.Logger.Printf("[%.3f] 文件复制操作完成", time.Since(startTime).Seconds()) + return nil } // alignTo10Seconds 将时间对齐到最近的10秒间隔 @@ -294,7 +416,15 @@ func CleanupOldVideos(deviceUUID string) { // CleanupOldRawVideos 清理推理程序生成的原始10s视频,保留最近60条 func CleanupOldRawVideos(deviceUUID string) { - rawDir := filepath.Join("/usr/data/camera", deviceUUID) + // 清理主设备目录 + cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID)) + + // 清理火焰检测目录 + cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check")) +} + +// cleanupRawVideosDirectory 清理单个目录中的原始视频文件 +func cleanupRawVideosDirectory(rawDir string) { if _, err := os.Stat(rawDir); os.IsNotExist(err) { return } diff --git a/video_server/video_upload.go b/video_server/video_upload.go index f88fcd7..f4b1cc5 100644 --- a/video_server/video_upload.go +++ b/video_server/video_upload.go @@ -16,36 +16,62 @@ import ( // UploadToNginx 上传视频到 Nginx 服务器,返回 URL func UploadToNginx(filePath string, eventParams map[string]any, uploadURL string) (string, error) { + logger.Logger.Printf("开始上传视频到 Nginx: %s → %s", filePath, uploadURL) + // 打开文件 + logger.Logger.Printf("打开文件: %s", filePath) + startTime := time.Now() file, err := os.Open(filePath) if err != nil { + logger.Logger.Printf("打开文件失败: %v, 耗时: %v", err, time.Since(startTime)) return "", fmt.Errorf("open file failed: %w", err) } defer file.Close() + logger.Logger.Printf("文件打开成功, 耗时: %v", time.Since(startTime)) + // 创建多部分表单 + logger.Logger.Printf("创建多部分表单") body := new(bytes.Buffer) writer := multipart.NewWriter(body) + // 创建表单文件字段 + logger.Logger.Printf("创建表单文件字段: %s", filepath.Base(filePath)) part, err := writer.CreateFormFile("file", filepath.Base(filePath)) if err != nil { + logger.Logger.Printf("创建表单文件字段失败: %v", err) return "", fmt.Errorf("create form file failed: %w", err) } + + // 复制文件内容到表单 + logger.Logger.Printf("开始复制文件内容到表单") + copyStartTime := time.Now() if _, err = io.Copy(part, file); err != nil { + logger.Logger.Printf("复制文件内容失败: %v, 耗时: %v", err, time.Since(copyStartTime)) return "", fmt.Errorf("copy file failed: %w", err) } + logger.Logger.Printf("文件内容复制成功, 耗时: %v", time.Since(copyStartTime)) + // 序列化事件参数 + logger.Logger.Printf("序列化事件参数") eventJSON, err := json.Marshal(eventParams) if err != nil { + logger.Logger.Printf("序列化事件参数失败: %v", err) return "", fmt.Errorf("marshal event failed: %w", err) } logger.Logger.Printf("[Upload Event parameters]eventParams: %v", eventParams) logger.Logger.Printf("[Upload Event parameters]eventJSON: %s", string(eventJSON)) + // 写入事件字段 + logger.Logger.Printf("写入事件字段到表单") if err := writer.WriteField("event", string(eventJSON)); err != nil { + logger.Logger.Printf("写入事件字段失败: %v", err) return "", fmt.Errorf("write event field failed: %w", err) } + // 关闭表单写入器 + logger.Logger.Printf("关闭表单写入器") if err := writer.Close(); err != nil { + logger.Logger.Printf("关闭表单写入器失败: %v", err) return "", fmt.Errorf("close writer failed: %w", err) } @@ -53,41 +79,64 @@ func UploadToNginx(filePath string, eventParams map[string]any, uploadURL string logger.Logger.Printf("[HTTP Request]Content-Type: %s", writer.FormDataContentType()) logger.Logger.Printf("[HTTP Request]Body 长度: %d bytes", body.Len()) + // 创建HTTP请求 + logger.Logger.Printf("创建HTTP请求") req, err := http.NewRequest("POST", uploadURL, body) if err != nil { + logger.Logger.Printf("创建HTTP请求失败: %v", err) return "", fmt.Errorf("create request failed: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) + // 发送HTTP请求 + logger.Logger.Printf("发送HTTP请求到 Nginx") + httpStartTime := time.Now() client := &http.Client{Timeout: 180 * time.Second} resp, err := client.Do(req) if err != nil { + logger.Logger.Printf("发送HTTP请求失败: %v, 耗时: %v", err, time.Since(httpStartTime)) return "", fmt.Errorf("upload failed: %w", err) } defer resp.Body.Close() + logger.Logger.Printf("HTTP请求发送成功, 耗时: %v", time.Since(httpStartTime)) + // 读取响应 + logger.Logger.Printf("读取HTTP响应") + respStartTime := time.Now() respBody, _ := io.ReadAll(resp.Body) + logger.Logger.Printf("响应读取完成, 耗时: %v", time.Since(respStartTime)) logger.Logger.Printf("[nginx response]Status: %d", resp.StatusCode) logger.Logger.Printf("[nginx response]Body: %s", string(respBody)) if resp.StatusCode != http.StatusOK { + logger.Logger.Printf("Nginx返回错误状态码: %d", resp.StatusCode) return "", fmt.Errorf("nginx error %d: %s", resp.StatusCode, string(respBody)) } + // 解析响应 + logger.Logger.Printf("解析Nginx响应") var result struct { Code int `json:"code"` Result json.RawMessage `json:"result"` } if err := json.Unmarshal(respBody, &result); err != nil { + logger.Logger.Printf("解析响应失败: %v", err) return "", fmt.Errorf("parse response failed: %w", err) } if result.Code != 0 { + logger.Logger.Printf("Nginx返回错误代码: %d", result.Code) return "", fmt.Errorf("nginx error: %s", string(respBody)) } + // 提取视频URL + logger.Logger.Printf("提取视频URL") var videoURL string - json.Unmarshal(result.Result, &videoURL) + if err := json.Unmarshal(result.Result, &videoURL); err != nil { + logger.Logger.Printf("提取视频URL失败: %v", err) + return "", fmt.Errorf("extract video URL failed: %w", err) + } logger.Logger.Printf("[ Upload Successful ] Local file: %s → Remote URL: %s", filePath, videoURL) + logger.Logger.Printf("视频上传完成, 总耗时: %v", time.Since(startTime)) return videoURL, nil } diff --git a/动火离人业务逻辑.md b/动火离人业务逻辑.md new file mode 100644 index 0000000..5799581 --- /dev/null +++ b/动火离人业务逻辑.md @@ -0,0 +1,60 @@ +flowchart TD +A[前端配置设备/检测区/参数] --> B[后端下发或本地生成 service.conf] +B --> C[微服务启动] +C --> D[读取 /usr/local/etc/service.conf] +D --> E[按设备初始化] + + E --> F1[启动 fire_leave 推理程序
./yolov5 -m model] + E --> F2[启动 fire_check 推理程序
./yolov5 -m fire_check -R] + E --> F3[启动测温程序
run_thermometry.sh] + + F1 --> G1[HTTP 回传人员检测结果] + F2 --> G2[HTTP 回传火焰检测结果] + F3 --> G3[TCP 回传温度值] + + G1 --> H1[更新当前人数 PersonCount] + G3 --> H2[更新当前温度 Temperature] + + H1 --> I1{PersonCount == 0?} + H2 --> I2{温度 > 阈值?} + + I1 -->|是| J1[无人条件成立] + I2 -->|是| J2[超温条件成立] + + J1 --> K{无人 + 超温
是否同时成立?} + J2 --> K + + K -->|否| K0[重置持续计时] + K -->|是| L[开始累计持续时间] + + L --> M{持续时间 >= detection_time?} + M -->|否| L + M -->|是| N{是否在 alarm_time 冷却期?} + + N -->|是| N0[不上报,继续监测] + N -->|否| O[触发 fire_leave 动火离人报警] + + G2 --> P{检测到火焰
ClassIdx=0 且 Number>0?} + P -->|否| P0[重置火焰计时] + P -->|是| Q[累计火焰持续时间] + Q --> R{持续时间 >= fire_check detection_time?} + R -->|否| Q + R -->|是| S[触发 fire_check 火焰报警] + + O --> T[查找报警前后视频片段] + S --> T + + T --> U[拼接约30秒报警视频] + U --> V[复制到 /data/upload] + V --> W[上传视频到后端/Nginx] + W --> X[WebSocket 上报告警事件] + X --> Y[后端转发前端展示] + + Z1[GPIO按钮] --> Z2[目标业务:暂停 fire_leave 上报] + Z2 --> Z3[暂停期内人员离开报警不上报] + Z3 --> K + + Z2 -.不影响.-> P + + WS1[WebSocket /dhlr/alert] --> BEEP[控制蜂鸣器 GPIO109] + WS2[WebSocket /dhlr/forbid_time] --> Z2 \ No newline at end of file