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