Compare commits

..

10 Commits

Author SHA1 Message Date
gqc aa60ca1cad docs: add project readme 2026-07-08 19:21:07 +08:00
gqc d3d60ad042 docs: add simplified customer platform manual 2026-07-06 17:27:15 +08:00
gqc 0842f555fd feat: update inference reporting and ffmpeg video merge 2026-07-06 16:26:08 +08:00
Smos 79490bd27b docs: update customer management platform manual 2026-07-05 02:27:41 +08:00
Smos 0f7cf62abd docs: update customer platform manual 2026-07-05 01:17:15 +08:00
gqc 95826247c7 docs: move user manual into usr_doc 2026-07-04 16:46:31 +08:00
gqc 91b60a82d3 docs: add customer management platform guide 2026-07-03 22:15:53 +08:00
gqc 13c4d03055 update current project state 2026-07-03 20:55:15 +08:00
gqc 6056b1fdc5 chore: upload current project state 2026-05-12 15:45:50 +08:00
gqc cd9b199e2c json修改 2026-01-30 17:25:23 +08:00
62 changed files with 6635 additions and 673 deletions
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
Binary file not shown.
+2
View File
@@ -46,6 +46,7 @@
"metric": {
"type": "data",
"args": {
"level": "fire_leave",
"person_count": 0,
"temperature": 38.5,
"camera_rtsp": "摄像头RTSP地址",
@@ -140,6 +141,7 @@
"metric": {
"type": "data",
"args": {
"level": "fire_check",
"fire_detected": true,
"camera_rtsp": "摄像头RTSP地址",
"task_id": "任务ID",
+90
View File
@@ -0,0 +1,90 @@
# FireLeave_tool
## 当前代码情况
本仓库是动火离人/火焰检测边缘端工具,主程序使用 Go 编写,模块名为 `FireLeave_tool`,当前 `go.mod` 指定 Go 1.19。
主要能力:
- 启动并管理 `fire_leave``fire_check` 两路推理进程。
- 接收推理进程通过 HTTP 上报的检测结果和心跳。
- 维护设备运行状态、告警冷却、禁报时间窗口和本地缓存。
- 通过 WebSocket 与平台通信,支持告警、禁报时间等主题。
- 检测到告警后合成事件前后视频,并上传到 Nginx/平台。
- 支持 GPIO 按键触发本地告警暂停。
主要目录:
- `main.go`:主流程入口,负责设备初始化、推理进程启动、状态监控和告警判断。
- `connect/`:WebSocket 通信、设备配置存储、推理进程心跳与重启。
- `httpreader/`:本地 HTTP 服务,接收推理结果、视频片段和心跳。
- `reporter/`:告警视频上传、事件上报、禁报时间持久化。
- `video_server/`:视频合成与上传逻辑。
- `gpio_monitor/`GPIO 按键监控。
- `logger/`:日志封装。
- `docs/``usr_doc/`:变更记录和客户操作说明。
- `bin/fireleave_tool`:当前构建产物。
## 视频合成逻辑
视频合成入口在 `video_server/video_merge.go`
- 根据告警时间对齐到 10 秒边界。
- 优先查找告警前 10 秒、告警时刻、告警后 10 秒对应的视频片段。
- 支持 `.mp4``.h264` 输入。
- 如果精确时间片段不足,会在目录中查找最接近目标时间的候选片段。
- 至少找到 2 个片段才会执行合成。
- 合成完成后复制到 `/data/upload`,文件名格式为:
```text
{device_uuid}-{year}-{month}-{day}-{aligned_alarm_timestamp}.MP4
```
当前代码通过系统命令调用 FFmpeg:
```text
ffmpeg -hide_banner -loglevel error -y ...
```
`.h264` 片段会先按时间顺序拼接为临时 `.h264` 文件,再使用 FFmpeg 封装为 MP4;`.mp4` 或混合输入则使用 FFmpeg concat list 方式合成,并使用 `-c copy``-movflags +faststart`
## 本次视频合成使用的 FFmpeg 版本
当前仓库代码没有内置 FFmpeg 二进制,也没有在代码中锁定 FFmpeg 版本;运行时依赖部署环境 PATH 中可执行的 `ffmpeg` 命令。
本仓库可确认的相关信息:
- `Dockerfile` 中安装的视频合成相关包为 `mp4-merge_0.1.11-1_arm64.deb`
- `video_server/video_merge.go` 实际调用的是 `ffmpeg` 命令。
- 当前 Windows 工作区未检测到可执行的 `ffmpeg`,因此无法从本机直接读取 `ffmpeg -version` 输出。
部署设备或容器中可用下面命令确认本次实际使用的 FFmpeg 版本:
```sh
ffmpeg -version
```
建议将输出第一行记录到本节,例如:
```text
ffmpeg version <实际版本号>
```
## 构建与验证记录
最近一次文档记录中的构建信息:
```text
go version: go1.26.4
GOOS: linux
GOARCH: arm64
CGO_ENABLED: 0
binary: bin/fireleave_tool
```
常用验证命令:
```sh
go test ./...
go build -o bin/fireleave_tool.new .
```
Binary file not shown.
+382 -3
View File
@@ -6,14 +6,335 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"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
devicesBaseDir = "/data/devices"
deviceDataList []DeviceData
serviceConfigFile = "/usr/local/etc/service.conf"
)
const fixedUploadURL = "http://172.17.0.1/api/v1/upload/video"
// 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 the canonical ServiceConfig array. Fall back to the previous
// DeviceData array cache shape so older local service.conf files still boot.
var serviceConfigs []ServiceConfig
if err := json.Unmarshal(data, &serviceConfigs); err != nil {
var legacyDeviceData []DeviceData
if legacyErr := json.Unmarshal(data, &legacyDeviceData); legacyErr != nil {
logger.Logger.Printf("Failed to parse service config file: %v", err)
return nil, err
}
for i := range legacyDeviceData {
NormalizeDeviceRuntimeFields(&legacyDeviceData[i])
}
logger.Logger.Printf("Loaded %d legacy device configurations from service config file", len(legacyDeviceData))
return legacyDeviceData, nil
}
// Convert ServiceConfig to DeviceData
var deviceDataList []DeviceData
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)
}
}
NormalizeDeviceRuntimeFields(&deviceData)
// 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
}
func DecodeDeviceDataPayload(data []byte) (DeviceData, error) {
var deviceData DeviceData
if err := json.Unmarshal(data, &deviceData); err == nil && deviceData.DeviceUUID != "" {
NormalizeDeviceRuntimeFields(&deviceData)
if deviceData.UploadURL == "" {
deviceData.UploadURL = fixedUploadURL
}
return deviceData, nil
}
var serviceConfig ServiceConfig
if err := json.Unmarshal(data, &serviceConfig); err != nil {
return DeviceData{}, err
}
deviceData = ServiceConfigToDeviceData(serviceConfig, fixedUploadURL)
if deviceData.DeviceUUID == "" {
return DeviceData{}, fmt.Errorf("device_uuid is empty")
}
return deviceData, nil
}
func ServiceConfigToDeviceData(config ServiceConfig, uploadURL string) DeviceData {
deviceData := DeviceData{
DeviceUUID: config.DeviceUUID,
TaskID: config.TaskID,
CameraRTSP: config.CameraRTSP,
CameraIP: config.CameraIP,
Confidence: config.Confidence,
HostUUID: config.HostUUID,
Address: config.Address,
UploadURL: uploadURL,
FireLeaveDetectArea: config.DetectArea.FireLeave,
FireCheckDetectArea: config.DetectArea.FireCheck,
FireLeaveParam: config.Param.FireLeave,
FireCheckParam: config.Param.FireCheck,
}
NormalizeDeviceRuntimeFields(&deviceData)
return deviceData
}
func DeviceDataToServiceConfig(deviceData DeviceData) ServiceConfig {
config := ServiceConfig{
HostUUID: deviceData.HostUUID,
DeviceUUID: deviceData.DeviceUUID,
Address: deviceData.Address,
TaskID: deviceData.TaskID,
CameraRTSP: deviceData.CameraRTSP,
CameraIP: deviceData.CameraIP,
Confidence: deviceData.Confidence,
DetectArea: DetectAreaConfig{
FireLeave: deviceData.FireLeaveDetectArea,
FireCheck: deviceData.FireCheckDetectArea,
},
Param: ParamConfig{
FireLeave: deviceData.FireLeaveParam,
FireCheck: deviceData.FireCheckParam,
},
}
if len(config.DetectArea.FireLeave) == 0 {
config.DetectArea.FireLeave = deviceData.DetectArea
}
if config.Param.FireLeave.DetectionTime == "" && deviceData.DetectionTime != "" {
config.Param.FireLeave.DetectionTime = deviceData.DetectionTime.String()
}
if config.Param.FireLeave.AlarmTime == "" && deviceData.AlarmTime != "" {
config.Param.FireLeave.AlarmTime = deviceData.AlarmTime.String()
}
if config.Param.FireLeave.TemperatureThreshold == "" && deviceData.TemperatureThreshold > 0 {
config.Param.FireLeave.TemperatureThreshold = strconv.FormatFloat(deviceData.TemperatureThreshold, 'f', -1, 64)
}
if config.Param.FireLeave.Confidence == "" && deviceData.Confidence > 0 {
config.Param.FireLeave.Confidence = strconv.Itoa(deviceData.Confidence)
}
if config.Param.FireCheck.Confidence == "" && deviceData.Confidence > 0 {
config.Param.FireCheck.Confidence = strconv.Itoa(deviceData.Confidence)
}
return config
}
func NormalizeDeviceRuntimeFields(deviceData *DeviceData) {
if deviceData == nil {
return
}
if len(deviceData.FireLeaveDetectArea) == 0 && len(deviceData.DetectArea) > 0 {
deviceData.FireLeaveDetectArea = deviceData.DetectArea
}
if len(deviceData.DetectArea) == 0 && len(deviceData.FireLeaveDetectArea) > 0 {
deviceData.DetectArea = deviceData.FireLeaveDetectArea
}
if deviceData.FireLeaveParam.PersonCount != "" {
if count, err := strconv.Atoi(deviceData.FireLeaveParam.PersonCount); err == nil && count >= 0 {
deviceData.PersonCount = count
}
}
if deviceData.FireLeaveParam.DetectionTime != "" {
deviceData.DetectionTime = json.Number(deviceData.FireLeaveParam.DetectionTime)
}
if deviceData.FireLeaveParam.AlarmTime != "" {
deviceData.AlarmTime = json.Number(deviceData.FireLeaveParam.AlarmTime)
}
if deviceData.FireLeaveParam.TemperatureThreshold != "" {
if threshold, err := strconv.ParseFloat(deviceData.FireLeaveParam.TemperatureThreshold, 64); err == nil && threshold > 0 {
deviceData.TemperatureThreshold = threshold
logger.Logger.Printf("Set temperature threshold from config: %.1f掳C (DeviceUUID=%s)", threshold, deviceData.DeviceUUID)
}
}
if deviceData.FireLeaveParam.Confidence != "" {
if confidence, err := strconv.Atoi(deviceData.FireLeaveParam.Confidence); err == nil && confidence > 0 {
deviceData.Confidence = confidence
}
}
}
func GetFireCheckConfidence(deviceData DeviceData) int {
if confidenceText := strings.TrimSpace(deviceData.FireCheckParam.Confidence); confidenceText != "" {
if confidence, err := strconv.Atoi(confidenceText); err == nil && confidence > 0 {
return confidence
}
}
return deviceData.Confidence
}
// 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
NormalizeDeviceRuntimeFields(&processed)
// 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
NormalizeDeviceRuntimeFields(&processed)
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
NormalizeDeviceRuntimeFields(&processed)
if len(data.FireCheckDetectArea) > 0 {
processed.DetectAreaStr = ProcessDetectAreaForInference(data.FireCheckDetectArea)
}
return processed
}
func InitializeDevicesFromServiceConfig() ([]DeviceData, error) {
deviceDataMutex.Lock()
defer deviceDataMutex.Unlock()
@@ -94,8 +415,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 +518,59 @@ 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 {
serviceConfigs := make([]ServiceConfig, 0, len(deviceDataList))
for _, deviceData := range deviceDataList {
if deviceData.DeviceUUID == "" {
continue
}
serviceConfigs = append(serviceConfigs, DeviceDataToServiceConfig(deviceData))
}
configData, err := json.MarshalIndent(serviceConfigs, "", " ")
if err != nil {
logger.Logger.Printf("Failed to serialize service configuration: %v", err)
return err
}
if err := os.MkdirAll(filepath.Dir(serviceConfigFile), 0755); err != nil {
logger.Logger.Printf("Failed to create service config directory: %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)
}
+70 -47
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
@@ -11,78 +12,100 @@ import (
)
var (
InferenceProcesses sync.Map // key: deviceUUID string, value: *exec.Cmd
// 记录最后一次收到心跳的时间
inferenceLastHeartbeat sync.Map // key: deviceUUID string, value: time.Time
InferenceProcesses sync.Map // key: process UUID, value: *exec.Cmd
inferenceLastHeartbeat sync.Map // key: process UUID, value: time.Time
)
// UpdateInferenceHeartbeat 每收到一帧 /video/post 就调用这个
func UpdateInferenceHeartbeat(deviceUUID string) {
inferenceLastHeartbeat.Store(deviceUUID, time.Now())
// UpdateInferenceHeartbeat records the latest heartbeat time for one inference process.
func UpdateInferenceHeartbeat(processUUID string) {
inferenceLastHeartbeat.Store(processUUID, time.Now())
if strings.Contains(processUUID, "_fire_check") {
logger.Logger.Printf("推理心跳收到 | 时间: %s | UUID: %s | 类型: fire_check", time.Now().Format("2006/01/02 15:04:05.000"), processUUID)
} else {
logger.Logger.Printf("推理心跳收到 | 时间: %s | UUID: %s | 类型: fire_leave", time.Now().Format("2006/01/02 15:04:05.000"), processUUID)
}
}
// ShouldRestartInference 判断是否需要重启推理程序
func ShouldRestartInference(deviceUUID string) bool {
val, ok := inferenceLastHeartbeat.Load(deviceUUID)
// ShouldRestartInference reports whether one inference process should be restarted.
func ShouldRestartInference(processUUID string) bool {
val, ok := inferenceLastHeartbeat.Load(processUUID)
if !ok {
return true // 从未收到过心跳,肯定要启动
return true
}
last := val.(time.Time)
return time.Since(last) > 85*time.Second
}
// StopInferenceProcess 停止推理进程(供 main 和 DeviceManager 调用)
func StopInferenceProcess(deviceUUID string) {
if raw, ok := InferenceProcesses.Load(deviceUUID); ok {
// StopInferenceProcess stops one inference process.
func StopInferenceProcess(processUUID string) {
if raw, ok := InferenceProcesses.Load(processUUID); ok {
if cmd, ok := raw.(*exec.Cmd); ok && cmd.Process != nil {
logger.Logger.Printf("正在停止推理进程 (DeviceUUID=%s, PID=%d)", deviceUUID, cmd.Process.Pid)
cmd.Process.Kill()
cmd.Wait()
logger.Logger.Printf("正在停止推理进程 (ProcessUUID=%s, PID=%d)", processUUID, cmd.Process.Pid)
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
InferenceProcesses.Delete(deviceUUID)
InferenceProcesses.Delete(processUUID)
}
}
// StartInferenceProcess 重启推理程序(保持原端口!)
// StartInferenceProcess keeps the old "restart both" behavior for compatibility.
func StartInferenceProcess(deviceData *DeviceData) {
if err := RestartInferenceProcess(deviceData, deviceData.DeviceUUID); err != nil {
logger.Logger.Printf("fire_leave推理程序重启失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err)
}
if err := RestartInferenceProcess(deviceData, fmt.Sprintf("%s_fire_check", deviceData.DeviceUUID)); err != nil {
logger.Logger.Printf("fire_check推理程序重启失败 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err)
}
}
StopInferenceProcess(deviceData.DeviceUUID)
port, err := GlobalPortManager.GetPort(deviceData.DeviceUUID)
if err != nil {
logger.Logger.Printf("获取端口失败,无法重启推理程序 (DeviceUUID=%s): %v", deviceData.DeviceUUID, err)
return
// RestartInferenceProcess restarts only the requested inference process UUID.
func RestartInferenceProcess(deviceData *DeviceData, processUUID string) error {
if deviceData == nil {
return fmt.Errorf("device data is nil")
}
detectAreaStr := ProcessDetectAreaForInference(deviceData.DetectArea)
port, err := GlobalPortManager.GetPort(processUUID)
if err != nil {
return fmt.Errorf("get port failed: %w", err)
}
inferenceCmd := exec.Command(
"./yolov5",
modelName := "model"
detectAreaStr := ProcessDetectAreaForInference(deviceData.FireLeaveDetectArea)
args := []string{
"-s", deviceData.CameraRTSP,
"-m", "model",
"-c", deviceData.DeviceUUID,
"-m", modelName,
"-c", processUUID,
"-p", fmt.Sprintf("%d", port),
"-w", detectAreaStr,
"-r", fmt.Sprintf("%d", deviceData.Confidence),
)
inferenceCmd.Stdout = os.Stdout
inferenceCmd.Stderr = os.Stderr
logger.Logger.Printf("正在重启推理程序 (使用原端口 %d): %v", port, inferenceCmd.Args)
if err := inferenceCmd.Start(); err != nil {
logger.Logger.Printf("推理程序重启失败 (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)
if strings.HasSuffix(processUUID, "_fire_check") {
modelName = "fire_check"
detectAreaStr = ProcessDetectAreaForInference(deviceData.FireCheckDetectArea)
confidence := GetFireCheckConfidence(*deviceData)
args = []string{
"-s", deviceData.CameraRTSP,
"-m", modelName,
"-c", processUUID,
"-p", fmt.Sprintf("%d", port),
"-w", detectAreaStr,
"-r", fmt.Sprintf("%d", confidence),
}
}
// 重启后立即刷新一次心跳,防止 30s 检测又触发
UpdateInferenceHeartbeat(deviceData.DeviceUUID)
StopInferenceProcess(processUUID)
cmd := exec.Command("./yolov5", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logger.Logger.Printf("正在重启推理程序 (ProcessUUID=%s, Port=%d): %v", processUUID, port, cmd.Args)
if err := cmd.Start(); err != nil {
return err
}
InferenceProcesses.Store(processUUID, cmd)
UpdateInferenceHeartbeat(processUUID)
logger.Logger.Printf("推理程序已成功重启,PID=%d (ProcessUUID=%s, Port=%d)", cmd.Process.Pid, processUUID, port)
return nil
}
+433 -211
View File
@@ -4,8 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
@@ -30,6 +28,12 @@ type PublishParams struct {
Content json.RawMessage `json:"content,omitempty"`
}
const (
wsFrameRequest byte = 0x01
wsFrameReply byte = 0x02
wsFrameCast byte = 0x03
)
type InferenceHeartbeat struct {
DeviceUUID string `json:"device_uuid"`
PersonCount int `json:"person_count"`
@@ -49,10 +53,6 @@ func (c *WSClient) IsConnected() bool {
return c.connected
}
const (
serviceConfigFile = "/usr/local/etc/service.conf"
)
// DeviceData struct
type DeviceData struct {
DeviceUUID string `json:"device_uuid"`
@@ -74,6 +74,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 +90,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"`
@@ -109,6 +104,12 @@ type WSError struct {
Message string `json:"message"`
}
type ServiceReply struct {
PacketID uint32
Result []byte
Error *WSError
}
type RegisterParams struct {
ContainerName string `json:"container_name,omitempty"`
ServiceID string `json:"service_id,omitempty"`
@@ -126,11 +127,12 @@ type WSClient struct {
serverID string
connected bool
mutex sync.Mutex
messageID int64
callbacks map[int64]chan *WSMessage
messageID uint32
callbacks map[uint32]chan *ServiceReply
subscribers map[string]func([]byte)
closeChan chan struct{}
reconnectFlag bool
reconnecting bool
}
// NewWSClient creates WebSocket client
@@ -145,7 +147,7 @@ func NewWSClient(url string) (*WSClient, error) {
url: url,
connected: true,
messageID: 1,
callbacks: make(map[int64]chan *WSMessage),
callbacks: make(map[uint32]chan *ServiceReply),
subscribers: make(map[string]func([]byte)),
closeChan: make(chan struct{}),
reconnectFlag: true,
@@ -184,7 +186,6 @@ func InitWSChannel() error {
}
isConnecting = true
defer func() { isConnecting = false }()
logger.Logger.Printf("Starting WebSocket connection initialization: %s", wsURL)
@@ -193,15 +194,21 @@ func InitWSChannel() error {
client, err := NewWSClient(wsURL)
if err != nil {
logger.Logger.Printf("Failed to create WebSocket client: %v", err)
wsMutex.Lock()
isConnecting = false
wsMutex.Unlock()
return
}
wsMutex.Lock()
defer wsMutex.Unlock()
if ConfigUpdateChan == nil {
ConfigUpdateChan = make(chan DeviceData, 100)
}
// Note: No longer calling LoadDeviceData() here to avoid duplicate loading
wsClient = client
isConnecting = false
logger.Logger.Printf("WebSocket connection initialized successfully (asynchronous)")
}()
@@ -227,7 +234,8 @@ func GetServiceID() string {
// RegisterService service registration - modified: now requires providing own server_id
func RegisterService(containerName string) (string, error) {
if wsClient == nil {
client := GetWSClient()
if client == nil {
return "", fmt.Errorf("WebSocket client not initialized")
}
@@ -242,7 +250,7 @@ func RegisterService(containerName string) (string, error) {
},
}
logger.Logger.Printf("Starting service registration: ContainerName=%s, ServiceID=%s", containerName, serviceID)
response, err := wsClient.sendRequest("register", params, true)
response, err := client.sendRequest("register", params, true)
if err != nil {
logger.Logger.Printf("Service registration failed: %v", err)
return "", err
@@ -252,33 +260,29 @@ func RegisterService(containerName string) (string, error) {
return "", fmt.Errorf("Service registration error: %s", response.Error.Message)
}
// Parse response
var resultStr string
if err := json.Unmarshal(response.Result, &resultStr); err != nil {
logger.Logger.Printf("Failed to parse service registration response: %v, Raw response: %s", err, string(response.Result))
return "", fmt.Errorf("Failed to parse service registration response: %v", err)
}
if resultStr != "ok" {
logger.Logger.Printf("Service registration failed: Expected result 'ok', got '%s'", resultStr)
if string(response.Result) != "ok" {
logger.Logger.Printf("Service registration failed: Expected result 'ok', got '%s'", string(response.Result))
return "", fmt.Errorf("Service registration failed: Server returned abnormal response")
}
wsClient.serviceID = serviceID
client.mutex.Lock()
client.serviceID = serviceID
client.mutex.Unlock()
logger.Logger.Printf("Service registration successful: ServiceID=%s", serviceID)
return serviceID, nil
}
// SubscribeTopic topic subscription
func SubscribeTopic(topic string, callback func([]byte)) error {
if wsClient == nil {
client := GetWSClient()
if client == nil {
return fmt.Errorf("WebSocket client not initialized")
}
params := SubscribeParams{
Topic: topic,
}
logger.Logger.Printf("Starting topic subscription: Topic=%s", topic)
response, err := wsClient.sendRequest("subscribe", params, true)
response, err := client.sendRequest("subscribe", params, true)
if err != nil {
logger.Logger.Printf("Topic subscription failed: %v", err)
return err
@@ -287,55 +291,107 @@ func SubscribeTopic(topic string, callback func([]byte)) error {
logger.Logger.Printf("Topic subscription error: Code=%d, Message=%s", response.Error.Code, response.Error.Message)
return fmt.Errorf("Topic subscription error: %s", response.Error.Message)
}
wsClient.mutex.Lock()
wsClient.subscribers[topic] = callback
wsClient.mutex.Unlock()
client.mutex.Lock()
client.subscribers[topic] = callback
client.mutex.Unlock()
logger.Logger.Printf("Topic subscription successful: Topic=%s", topic)
return nil
}
func (c *WSClient) SendRawMessage(message interface{}) error {
messageData, err := json.Marshal(message)
if err != nil {
logger.Logger.Printf("[WebSocket Send Full JSON] marshal failed: %v", err)
} else {
logger.Logger.Printf("[WebSocket Send Full JSON] %s", string(messageData))
}
if metric, ok := extractMetricData(message); ok {
return c.sendMetricData(metric.routeKey, metric.metric)
}
return fmt.Errorf("unsupported websocket message type for protobuf protocol")
}
type metricDataMessage struct {
routeKey []byte
metric []byte
}
func extractMetricData(message interface{}) (metricDataMessage, bool) {
msg, ok := message.(map[string]interface{})
if !ok || msg["method"] != "metric_data" {
return metricDataMessage{}, false
}
params, ok := msg["params"].(map[string]interface{})
if !ok {
return metricDataMessage{}, false
}
routeKey, ok := params["route_key"].(string)
if !ok || routeKey == "" {
return metricDataMessage{}, false
}
metric, ok := params["metric"].(string)
if !ok {
return metricDataMessage{}, false
}
return metricDataMessage{
routeKey: []byte(routeKey),
metric: []byte(metric),
}, true
}
func (c *WSClient) sendBinaryFrame(frameType byte, payload []byte) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if !c.connected {
return fmt.Errorf("WebSocket connection disconnected")
}
messageData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("Message serialization failed: %v", err)
}
logger.Logger.Printf("Sending WebSocket message: %s", string(messageData))
err = c.Conn.WriteMessage(websocket.TextMessage, messageData)
frame := make([]byte, 1+len(payload))
frame[0] = frameType
copy(frame[1:], payload)
logger.Logger.Printf("Sending WebSocket binary frame: type=0x%02x, payload=%d bytes", frameType, len(payload))
err := c.Conn.WriteMessage(websocket.BinaryMessage, frame)
if err != nil {
logger.Logger.Printf("WebSocket message sending failed: %v", err)
c.connected = false
go c.reconnect()
c.startReconnectLocked()
return fmt.Errorf("Message sending failed: %v", err)
}
return nil
}
func (c *WSClient) sendMetricData(routeKey, metric []byte) error {
payload := encodeServiceCastMetricData(routeKey, metric)
return c.sendBinaryFrame(wsFrameCast, payload)
}
// sendRequest sends request
func (c *WSClient) sendRequest(method string, params interface{}, waitResponse bool) (*WSMessage, error) {
func (c *WSClient) sendRequest(method string, params interface{}, waitResponse bool) (*ServiceReply, error) {
c.mutex.Lock()
id := c.messageID
c.messageID++
c.mutex.Unlock()
message := WSMessage{
ID: id,
Method: method,
}
if params != nil {
paramsData, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("Parameter serialization failed: %v", err)
var payload []byte
switch method {
case "register":
registerParams, ok := params.(RegisterParams)
if !ok {
return nil, fmt.Errorf("invalid register params")
}
message.Params = paramsData
payload = encodeServiceRequestRegister(id, registerParams.ServiceID)
case "subscribe":
subscribeParams, ok := params.(SubscribeParams)
if !ok {
return nil, fmt.Errorf("invalid subscribe params")
}
payload = encodeServiceRequestSubscribe(id, subscribeParams.Topic)
default:
return nil, fmt.Errorf("unsupported request method: %s", method)
}
if waitResponse {
callback := make(chan *WSMessage, 1)
callback := make(chan *ServiceReply, 1)
c.mutex.Lock()
c.callbacks[id] = callback
c.mutex.Unlock()
@@ -347,7 +403,7 @@ func (c *WSClient) sendRequest(method string, params interface{}, waitResponse b
logger.Logger.Printf("Cleaning up callback channel: ID=%d", id)
}()
if err := c.SendRawMessage(message); err != nil {
if err := c.sendBinaryFrame(wsFrameRequest, payload); err != nil {
return nil, err
}
@@ -364,7 +420,7 @@ func (c *WSClient) sendRequest(method string, params interface{}, waitResponse b
return nil, fmt.Errorf("Connection closed")
}
}
return nil, c.SendRawMessage(message)
return nil, c.sendBinaryFrame(wsFrameRequest, payload)
}
// readLoop message reading loop
@@ -379,44 +435,61 @@ func (c *WSClient) readLoop() {
}()
for {
_, messageData, err := c.Conn.ReadMessage()
messageType, messageData, err := c.Conn.ReadMessage()
if err != nil {
logger.Logger.Printf("WebSocket message reading failed: %v", err)
c.mutex.Lock()
c.connected = false
c.mutex.Unlock()
go c.reconnect() // Trigger reconnection
c.startReconnect() // Trigger reconnection
return
}
if messageType != websocket.BinaryMessage {
logger.Logger.Printf("[WebSocket Received Raw] non-binary type=%d size=%d payload=%s", messageType, len(messageData), string(messageData))
continue
}
if len(messageData) == 0 {
logger.Logger.Printf("Ignoring empty WebSocket binary frame")
continue
}
if string(messageData) == "pong" {
logger.Logger.Printf("Received pong response")
continue
}
logger.Logger.Printf("Received WebSocket message: %s", string(messageData))
frameType := messageData[0]
payload := messageData[1:]
logger.Logger.Printf("Received WebSocket binary frame: type=0x%02x, payload=%d bytes", frameType, len(payload))
var message WSMessage
if err := json.Unmarshal(messageData, &message); err != nil {
logger.Logger.Printf("WebSocket message parsing failed: %v", err)
continue
}
if message.ID > 0 {
switch frameType {
case wsFrameReply:
reply, err := decodeServiceReply(payload)
if err != nil {
logger.Logger.Printf("WebSocket reply parsing failed: %v", err)
continue
}
logServiceReply(reply)
c.mutex.Lock()
if callback, exists := c.callbacks[message.ID]; exists {
if callback, exists := c.callbacks[reply.PacketID]; exists {
select {
case callback <- &message:
logger.Logger.Printf("Successfully sent message to callback channel: ID=%d", message.ID)
case callback <- reply:
logger.Logger.Printf("Successfully sent reply to callback channel: ID=%d", reply.PacketID)
default:
logger.Logger.Printf("Callback channel full, discarding message: ID=%d", message.ID)
logger.Logger.Printf("Callback channel full, discarding reply: ID=%d", reply.PacketID)
}
} else {
logger.Logger.Printf("[WebSocket Received Reply] no waiting callback for packet_id=%d", reply.PacketID)
}
c.mutex.Unlock()
}
if message.Method == "publish" {
c.handlePublishMessage(message)
case wsFrameCast:
if err := c.handleServiceCast(payload); err != nil {
logger.Logger.Printf("WebSocket cast handling failed: %v", err)
}
default:
logger.Logger.Printf("Ignoring unknown WebSocket frame type: 0x%02x", frameType)
continue
}
}
}
@@ -442,42 +515,49 @@ func (c *WSClient) handlePublishMessage(message WSMessage) {
}
if publishParams.Data != nil {
var deviceData DeviceData
if err := json.Unmarshal(publishParams.Data, &deviceData); err != nil {
logger.Logger.Printf("Failed to parse DeviceData: %v", err)
return
}
// Check if DeviceUUID is valid
if deviceData.DeviceUUID == "" {
logger.Logger.Printf("Invalid DeviceData: DeviceUUID is empty, ignoring")
return
}
logger.Logger.Printf("Received valid DeviceData: DeviceUUID=%s", deviceData.DeviceUUID)
// 1. Save original configuration to service config file (using SaveSingleServiceConfig)
if err := SaveSingleServiceConfig(deviceData); err != nil {
logger.Logger.Printf("Failed to save service configuration: %v", err)
}
// 2. Process data and save to device data file
processedData := ProcessDeviceDataForInference(deviceData)
if err := UpdateDeviceData(processedData); err != nil {
logger.Logger.Printf("Failed to update DeviceData: %v", err)
return
}
select {
case ConfigUpdateChan <- processedData:
logger.Logger.Printf("New DeviceData sent to config update channel: DeviceUUID=%s", processedData.DeviceUUID)
default:
logger.Logger.Printf("Config update channel full, discarding message")
}
logger.Logger.Printf("Ignoring publish.data device configuration payload; service.conf is managed by startup config only")
}
}
func (c *WSClient) handleServiceCast(payload []byte) error {
topic, content, ok, err := decodeServiceCastTopicEvent(payload)
if err != nil {
return err
}
if !ok {
logger.Logger.Printf("Ignoring unsupported ServiceCast body")
return nil
}
logger.Logger.Printf("WebSocket received topic_event: Topic=%s | Content=%s", topic, string(content))
c.mutex.Lock()
callback, exists := c.subscribers[topic]
c.mutex.Unlock()
if !exists || callback == nil {
logger.Logger.Printf("No subscriber for topic_event: Topic=%s", topic)
return nil
}
wrapped, err := wrapTopicEvent(topic, content)
if err != nil {
return err
}
callback(wrapped)
return nil
}
func wrapTopicEvent(topic string, content []byte) ([]byte, error) {
msg := map[string]interface{}{
"topic": topic,
}
if json.Valid(content) {
msg["content"] = json.RawMessage(content)
} else {
msg["content"] = string(content)
}
return json.Marshal(msg)
}
// heartbeatLoop heartbeat loop - fixed version
func (c *WSClient) heartbeatLoop() {
logger.Logger.Printf("Starting WebSocket heartbeat loop")
@@ -499,7 +579,7 @@ func (c *WSClient) heartbeatLoop() {
c.mutex.Lock()
c.connected = false
c.mutex.Unlock()
go c.reconnect()
c.startReconnect()
} else {
logger.Logger.Printf("Ping sent successfully")
}
@@ -526,7 +606,13 @@ func (c *WSClient) reRegisterAndResubscribe() {
logger.Logger.Printf("Re-registration successful: ServiceID=%s", serviceID)
// 3. 重新订阅
c.mutex.Lock()
subscribers := make(map[string]func([]byte), len(c.subscribers))
for topic, callback := range c.subscribers {
subscribers[topic] = callback
}
c.mutex.Unlock()
for topic, callback := range subscribers {
if err := SubscribeTopic(topic, callback); err != nil {
logger.Logger.Printf("Failed to re-subscribe topic %s: %v", topic, err)
} else {
@@ -535,9 +621,32 @@ func (c *WSClient) reRegisterAndResubscribe() {
}
}
func (c *WSClient) startReconnect() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.startReconnectLocked()
}
func (c *WSClient) startReconnectLocked() {
if !c.reconnectFlag || c.reconnecting {
return
}
c.reconnecting = true
go c.reconnect()
}
// connect/wschannel.go → reconnect complete fix
func (c *WSClient) reconnect() {
if !c.reconnectFlag {
defer func() {
c.mutex.Lock()
c.reconnecting = false
c.mutex.Unlock()
}()
c.mutex.Lock()
reconnectFlag := c.reconnectFlag
c.mutex.Unlock()
if !reconnectFlag {
return
}
@@ -566,12 +675,13 @@ func (c *WSClient) reconnect() {
// 重启读写循环
go c.readLoop()
go c.heartbeatLoop()
return
}
logger.Logger.Printf("Reconnection failed after 12 attempts")
c.mutex.Lock()
c.reconnectFlag = false
c.mutex.Unlock()
}
func (d *DeviceData) ToInferenceParams(port int) InferenceParams {
return InferenceParams{
@@ -584,125 +694,237 @@ 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
func appendVarint(dst []byte, v uint64) []byte {
for v >= 0x80 {
dst = append(dst, byte(v)|0x80)
v >>= 7
}
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
return append(dst, byte(v))
}
// SaveSingleServiceConfig saves single device configuration (compatible with existing calls)
func SaveSingleServiceConfig(deviceData DeviceData) error {
// First read existing configuration
existingConfigs, err := LoadServiceConfig()
func appendBytesField(dst []byte, fieldNumber int, value []byte) []byte {
dst = appendVarint(dst, uint64(fieldNumber<<3|2))
dst = appendVarint(dst, uint64(len(value)))
return append(dst, value...)
}
func appendVarintField(dst []byte, fieldNumber int, value uint64) []byte {
dst = appendVarint(dst, uint64(fieldNumber<<3))
return appendVarint(dst, value)
}
func encodeServiceRequestRegister(packetID uint32, serviceID string) []byte {
register := appendBytesField(nil, 1, []byte(serviceID))
payload := appendVarintField(nil, 1, uint64(packetID))
payload = appendBytesField(payload, 10, register)
return payload
}
func encodeServiceRequestSubscribe(packetID uint32, topic string) []byte {
subscribe := appendBytesField(nil, 1, []byte(topic))
payload := appendVarintField(nil, 1, uint64(packetID))
payload = appendBytesField(payload, 11, subscribe)
return payload
}
func encodeServiceCastMetricData(routeKey, metric []byte) []byte {
metricData := appendBytesField(nil, 1, routeKey)
metricData = appendBytesField(metricData, 2, metric)
return appendBytesField(nil, 11, metricData)
}
func consumeVarint(data []byte, pos *int) (uint64, error) {
var value uint64
var shift uint
for *pos < len(data) {
b := data[*pos]
*pos += 1
value |= uint64(b&0x7f) << shift
if b < 0x80 {
return value, nil
}
shift += 7
if shift >= 64 {
return 0, fmt.Errorf("protobuf varint overflow")
}
}
return 0, fmt.Errorf("unexpected end of protobuf varint")
}
func consumeBytes(data []byte, pos *int) ([]byte, error) {
length, err := consumeVarint(data, pos)
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
return nil, err
}
if length > uint64(len(data)-*pos) {
return nil, fmt.Errorf("protobuf bytes field length out of range")
}
value := data[*pos : *pos+int(length)]
*pos += int(length)
return value, nil
}
func skipField(data []byte, pos *int, wireType int) error {
switch wireType {
case 0:
_, err := consumeVarint(data, pos)
return err
case 2:
_, err := consumeBytes(data, pos)
return err
default:
return fmt.Errorf("unsupported protobuf wire type: %d", wireType)
}
}
func decodeServiceReply(payload []byte) (*ServiceReply, error) {
reply := &ServiceReply{}
for pos := 0; pos < len(payload); {
key, err := consumeVarint(payload, &pos)
if err != nil {
return nil, err
}
fieldNumber := int(key >> 3)
wireType := int(key & 0x7)
switch fieldNumber {
case 1:
if wireType != 0 {
return nil, fmt.Errorf("invalid packet_id wire type: %d", wireType)
}
packetID, err := consumeVarint(payload, &pos)
if err != nil {
return nil, err
}
reply.PacketID = uint32(packetID)
case 10:
if wireType != 2 {
return nil, fmt.Errorf("invalid result wire type: %d", wireType)
}
result, err := consumeBytes(payload, &pos)
if err != nil {
return nil, err
}
reply.Result = result
case 11:
if wireType != 2 {
return nil, fmt.Errorf("invalid error wire type: %d", wireType)
}
errorPayload, err := consumeBytes(payload, &pos)
if err != nil {
return nil, err
}
reply.Error = decodeServiceReplyError(errorPayload)
default:
if err := skipField(payload, &pos, wireType); err != nil {
return nil, err
}
}
// If not exists, add to list
if !found {
existingConfigs = append(existingConfigs, deviceData)
}
}
return SaveServiceConfig(existingConfigs)
return reply, nil
}
// 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")
func logServiceReply(reply *ServiceReply) {
if reply == nil {
logger.Logger.Printf("[WebSocket Received Reply] <nil>")
return
}
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)
errorJSON := "null"
if reply.Error != nil {
if data, err := json.Marshal(reply.Error); err == nil {
errorJSON = string(data)
} else {
errorJSON = fmt.Sprintf(`{"marshal_error":%q}`, err.Error())
}
}
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)
logger.Logger.Printf("[WebSocket Received Reply] packet_id=%d result=%s error=%s", reply.PacketID, string(reply.Result), errorJSON)
}
func decodeServiceReplyError(payload []byte) *WSError {
wsErr := &WSError{}
for pos := 0; pos < len(payload); {
key, err := consumeVarint(payload, &pos)
if err != nil {
return wsErr
}
fieldNumber := int(key >> 3)
wireType := int(key & 0x7)
switch fieldNumber {
case 1:
code, err := consumeVarint(payload, &pos)
if err != nil {
return wsErr
}
wsErr.Code = int(int32(code))
case 2:
msg, err := consumeBytes(payload, &pos)
if err != nil {
return wsErr
}
wsErr.Message = string(msg)
default:
_ = skipField(payload, &pos, wireType)
}
}
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
return wsErr
}
// ProcessDetectAreaForInference processes detection area data, converts to format required by inference program
func ProcessDetectAreaForInference(areas []Area) string {
if len(areas) == 0 {
return ""
func decodeServiceCastTopicEvent(payload []byte) (string, []byte, bool, error) {
for pos := 0; pos < len(payload); {
key, err := consumeVarint(payload, &pos)
if err != nil {
return "", nil, false, err
}
fieldNumber := int(key >> 3)
wireType := int(key & 0x7)
if fieldNumber != 10 {
if err := skipField(payload, &pos, wireType); err != nil {
return "", nil, false, err
}
continue
}
if wireType != 2 {
return "", nil, false, fmt.Errorf("invalid topic_event wire type: %d", wireType)
}
topicPayload, err := consumeBytes(payload, &pos)
if err != nil {
return "", nil, false, err
}
topic, content, err := decodeTopicEvent(topicPayload)
return topic, content, true, err
}
// 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, ",")
return "", nil, false, nil
}
func ProcessDeviceDataForInference(data DeviceData) DeviceData {
// Create a copy, only process fields that need conversion
processed := data
processed.DetectAreaStr = ProcessDetectAreaForInference(data.DetectArea)
return processed
func decodeTopicEvent(payload []byte) (string, []byte, error) {
var topic string
var content []byte
for pos := 0; pos < len(payload); {
key, err := consumeVarint(payload, &pos)
if err != nil {
return "", nil, err
}
fieldNumber := int(key >> 3)
wireType := int(key & 0x7)
switch fieldNumber {
case 1:
value, err := consumeBytes(payload, &pos)
if err != nil {
return "", nil, err
}
topic = string(value)
case 2:
value, err := consumeBytes(payload, &pos)
if err != nil {
return "", nil, err
}
content = value
default:
if err := skipField(payload, &pos, wireType); err != nil {
return "", nil, err
}
}
}
return topic, content, nil
}
func (c *WSClient) SendAsync(message interface{}) {
@@ -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-<timestamp>`
- 新链路是否要求固定 `service_id`
4. topic 命名是否需要改
- 当前项目使用:
- `/dhlr/alert`
- `/dhlr/forbid_time`
- `/dhlr/device/<uuid>/event`
- `/dhlr/device/<uuid>/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.可能是你不清楚现在的运行环境,我先描述一下现在的业务环境,
@@ -0,0 +1,66 @@
# 2026-07-05 22:40 Global Status Log Fix
## Problem
In fire_check-only mode, the global status monitor still printed values like:
```text
person:1 | temp:0.0°C | threshold value:70.0°C
```
Those fields belong to fire_leave/thermometry logic. When thermometry startup fails and fire_leave is disabled, these values are not active runtime measurements and should not be shown as real current data.
## Change
- Added latest fire_check result state in `main.go`:
- `FireCheckHasResult`
- `FireCheckLastNumber`
- Updated the fire_check result branch to record the latest detected fire target count.
- Updated `printGlobalDeviceStatus()`:
- prints `fire:<value>` for fire_check target count;
- prints `fire:null` before any fire_check result is received;
- prints `person:null`, `temp:null`, and `threshold value:null` when fire_leave is disabled;
- keeps `person/temp/threshold` only when fire_leave is active;
- adds `fire_candidate`, `fire_last_seen_age`, and `fire_leave_enabled`.
## Expected Logs
Before any fire_check inference result is received:
```text
[1] DeviceUUID=xxx | fire:null | fire_candidate:false | fire_last_seen_age:null | fire_leave_enabled:false | person:null | temp:null | threshold value:null | cooling time:0s
```
After fire_check reports one fire target:
```text
[1] DeviceUUID=xxx | fire:1 | fire_candidate:true | fire_last_seen_age:500ms | fire_leave_enabled:false | person:null | temp:null | threshold value:null | cooling time:0s
```
If fire_check has reported a result but no fire target is present:
```text
[1] DeviceUUID=xxx | fire:0 | fire_candidate:false | fire_last_seen_age:null | fire_leave_enabled:false | person:null | temp:null | threshold value:null | cooling time:0s
```
## Impact
- Does not change fire_check alarm upload/report logic.
- Does not change heartbeat JSON format.
- Does not change inference startup arguments.
- Only changes runtime state tracking and global monitor log output.
- fire_leave logs remain available when fire_leave and thermometry are active.
## Verification
```text
gofmt -w main.go
go test ./...
go build -o bin/fireleave_tool.new .
```
The new binary was installed as:
```text
bin/fireleave_tool
```
+241
View File
@@ -0,0 +1,241 @@
# 2026-07-05 修改日志
本文记录本次代码审查后实际落地的修复、影响范围、验证结果和构建产物。
## 1. 本次目标
修复代码审查中确认的 1、2、4、5、8 号问题,并重新构建新的程序到 `bin/fireleave_tool`
涉及的问题包括:
- `fire_check` 报警上传/上报失败时仍被当作成功处理
- 心跳超时重启会扩大影响,可能同时重启两个推理进程
- 视频时间戳解析和合并异常处理不可靠
- WebSocket 重连可能并发启动多个重连循环
- `copyFile` 超时返回后后台 goroutine 仍可能继续写文件
## 2. 修改文件
主要修改:
- `main.go`
- `connect/heartbeat.go`
- `connect/ws_channel.go`
- `reporter/reporter.go`
- `video_server/video_merge.go`
- `bin/fireleave_tool`
构建/环境相关:
- 本机安装了 Go 编译器
- 当前构建使用 `go1.26.4`
- 新二进制目标为 `linux/arm64`
## 3. 详细修改内容
### 3.1 `fire_check` 上报成功语义修复
原问题:
`fire_check` 报警触发后,上传视频和 WebSocket 上报在异步流程中执行。调用方无法知道最终是否真正上传成功、是否真正写入 WebSocket,因此主流程可能提前清理 `FireCheckCandidate` 状态。这样会造成“报警没有报上去,但本地状态认为已经处理完成”的风险。
修改后:
- `reporter.UploadVideoAndReportFireCheck()` 改为同步执行。
- 视频上传失败会返回 error。
- WebSocket 未初始化或未连接会返回 error。
- 事件上报失败会返回 error。
- 数据快照上报失败会返回 error。
- 只有视频上传、事件上报、快照上报都成功后才清理合并文件并返回成功。
- `reporter.TriggerFireCheckAlarm()` 改为返回 `bool`
- `true` 表示报警已完整处理成功
- `false` 表示合并、上传、上报或并发锁获取失败
- `main.go` 中只有当 `TriggerFireCheckAlarm()` 返回 `true` 时才清空 `FireCheckCandidate``FireCheckFirstSeen``FireCheckLastSeen`
影响:
- `fire_check` 报警处理会比原来更严格,调用路径会等待上传和 WebSocket 写入完成。
- 如果平台或网络异常,本地候选状态不会被误清理,后续仍有机会重新触发。
- 失败场景下日志可能增加,但这是为了避免报警丢失。
### 3.2 `fire_check` 连续检测状态修复
原问题:
旧逻辑在收到推理结果的分支中立即判断持续时间并触发上报。如果上报失败,状态仍可能被重置。同时检测结果和上报处理耦合较重。
修改后:
- `deviceAlarmState` 中使用:
- `FireCheckCandidate`
- `FireCheckFirstSeen`
- `FireCheckLastSeen`
- 收到火焰推理结果时只更新候选状态和最近一次看到火焰的时间。
-`monitorTicker` 分支中统一判断是否达到 `fire_check` 持续时间。
- 增加 `fireCheckLastSeenGrace`,避免检测帧之间的短暂间隔导致候选状态过早失效。
影响:
- 火焰检测持续时间判断更稳定。
- 上报是否成功会影响候选状态是否清理。
- 短暂丢帧不一定立即打断候选状态。
### 3.3 心跳重启范围修复
原问题:
`fire_leave``fire_check` 某一个推理进程心跳超时时,旧逻辑会调用共享重启流程,可能把两个推理进程都重启。这样会扩大影响面,也可能造成重复重启。
修改后:
- 新增 `connect.RestartInferenceProcess(deviceData, processUUID)`
- 该函数只重启指定的 `processUUID`
- 主模型:`deviceUUID`
- 火焰模型:`deviceUUID_fire_check`
- `connect.StartInferenceProcess()` 保留兼容行为,仍可重启两路推理。
- `main.go` 心跳监控改为:
- `fire_leave` 超时只重启 `deviceUUID`
- `fire_check` 超时只重启 `deviceUUID_fire_check`
- 重启后只刷新对应进程的心跳时间。
影响:
- 单路推理异常不会连带影响另一路推理。
- 保留旧的 `StartInferenceProcess()`,避免已有调用点失效。
- 重启逻辑仍使用 `connect.GlobalPortManager` 获取原端口。
### 3.4 视频合并异常处理修复
原问题:
`video_server.parseSimpleTimestamp()` 直接按固定下标切片,遇到异常文件名可能 panic。`MergeVideo()` 虽然有 recover,但旧逻辑存在 panic 后没有明确错误返回的风险。
修改后:
- `MergeVideo()` 改为命名返回值:
- `mergedPath string`
- `err error`
- `recover` 捕获 panic 后会设置:
- `mergedPath = ""`
- `err = fmt.Errorf("merge video panic: %v", recovered)`
- `parseSimpleTimestamp()` 增加文件名长度校验。
- 年、月、日、时、分、秒解析失败时都会返回明确 error。
- `getTimestampFromPath()` 改为返回 `(int64, error)`
- 排序和时间戳匹配逻辑会处理解析失败,必要时回退到文件名排序。
影响:
- 异常文件名不会导致程序崩溃。
- 视频合并失败会更明确地返回错误,调用方可感知失败。
- 正常命名的 mp4 文件处理逻辑不变。
### 3.5 WebSocket 重连并发修复
原问题:
读循环、心跳循环、发送失败都可能触发 `reconnect()`。如果多个错误同时发生,可能启动多个重连 goroutine,并在重连成功后重复启动心跳循环。
修改后:
- `WSClient` 增加 `reconnecting bool`
- 新增:
- `startReconnect()`
- `startReconnectLocked()`
- 所有触发重连的地方都通过 `startReconnect` 系列方法进入。
- 同一时间只允许一个重连循环运行。
- 重连完成后重置 `reconnecting`
- 重连成功后只恢复 `readLoop()`,不再重复启动新的 `heartbeatLoop()`
- 重新订阅时先复制 `subscribers` map,再遍历订阅,降低并发读写风险。
- `RegisterService()``SubscribeTopic()` 使用本地 `client := GetWSClient()`,减少直接访问全局变量的竞态窗口。
影响:
- 断线时重连行为更可控。
- 减少 goroutine 堆积和重复 heartbeat 的风险。
- WebSocket 正常收发路径不变。
### 3.6 `copyFile` 超时写入修复
原问题:
`copyFile` 使用 goroutine 执行复制,外层通过 context 超时返回。超时后后台 goroutine 仍可能继续写目标文件,导致函数已经失败但文件仍在被写入,甚至留下半成品。
修改后:
- `copyFile` 改为同步分块复制。
- 每次循环检查 30 秒 deadline。
- 读取和写入使用 1MB buffer。
- 超时、读失败、写失败、短写或 sync 失败时都会删除目标文件。
- 函数返回后不再有后台 goroutine 继续写文件。
影响:
- 超时后不会继续写目标文件。
- 失败时不保留不完整目标文件。
- 大文件复制仍可能耗时,但行为更确定。
## 4. 构建与验证
已执行:
```text
gofmt -w main.go connect/heartbeat.go connect/ws_channel.go reporter/reporter.go video_server/video_merge.go
go test ./...
go build -o bin/fireleave_tool.new .
```
验证结果:
```text
go test ./... 通过
所有包均为 [no test files]
```
新构建产物:
```text
bin/fireleave_tool
```
构建信息:
```text
go version: go1.26.4
GOOS: linux
GOARCH: arm64
CGO_ENABLED: 0
binary size: 10011779 bytes
```
构建完成后已清理 `bin` 目录旧产物,当前 `bin` 目录只保留:
```text
bin/fireleave_tool
```
## 5. 兼容性与风险说明
### 保持兼容的部分
- `connect.StartInferenceProcess()` 保留,已有调用仍可使用。
- `fire_leave` 报警入口仍保持异步上报行为。
- WebSocket 消息结构和 topic 订阅逻辑没有改协议。
- 视频文件命名规则未改变。
### 行为变化
- `fire_check` 上报变为同步确认成功。
- `fire_check` 上报失败时不会清空候选报警状态。
- 心跳超时只重启异常的单个推理进程。
- WebSocket 同一时间只允许一个重连流程。
- 文件复制超时会删除不完整目标文件。
### 仍需关注
- 当前部分历史中文注释已经存在编码损坏,后续维护时建议逐步替换成清晰 UTF-8 注释。
- `reporter` 中仍有旧的 event cycle 相关逻辑,当前未完全重构,只做了本次问题相关的收敛。
- 如果现场网络长期异常,`fire_check` 候选状态可能持续重试,需要结合日志确认是否需要增加队列、退避或最大重试策略。
## 6. 本次结论
本次修复重点是避免误判成功、缩小重启影响、减少并发重连和后台写文件风险。新的 `bin/fireleave_tool` 已基于修复后的代码构建完成,可用于后续部署验证。
+180
View File
@@ -0,0 +1,180 @@
# Current Change Summary
Date: 2026-05-13
## 1. Configuration Ownership
The startup device configuration is owned by:
```text
/usr/local/etc/service.conf
```
This file is the Docker startup configuration source. The service reads it at startup and uses it to initialize devices, inference processes, detection areas, and runtime parameters.
WebSocket messages must not update this file.
## 2. service.conf Schema
`service.conf` is written as an array of `ServiceConfig` objects:
```json
[
{
"host_uuid": "",
"device_uuid": "",
"address": "",
"task_id": "",
"camera_rtsp": "",
"camera_ip": "",
"confidence": 70,
"detect_area": {
"fire_leave": [],
"fire_check": []
},
"param": {
"fire_check": {
"confidence": "70",
"detection_time": "2"
},
"fire_leave": {
"person_count": "0",
"confidence": "70",
"detection_time": "20",
"temperature_threshold": "45",
"alarm_time": "300"
}
}
}
]
```
The service still tolerates the previous `DeviceData[]` cache shape when reading `service.conf`, but writes back only the canonical `ServiceConfig[]` shape.
## 3. service.conf Write Boundary
The remaining write functions are:
- `connect.SaveServiceConfig()`
- `connect.SaveSingleServiceConfig()`
They write to:
```text
/usr/local/etc/service.conf
```
The old WebSocket path that wrote `publish.params.data` into `service.conf` is now disabled. If a WebSocket publish message contains `data`, the service logs and ignores it.
## 4. Runtime Device State
Runtime state is stored under:
```text
/data/devices/device_{device_uuid}.json
```
This includes operational values such as current temperature, current person count, and alarm cooldown state.
This is separate from `service.conf`.
## 5. WebSocket Topic Responsibilities
The service currently subscribes to:
```text
/dhlr/alert
/dhlr/forbid_time
```
### /dhlr/alert
This topic controls the local sound/light alarm.
Handled by:
```text
connect.HandleBuzzerAlert()
```
It reads:
- `type`
- `buzzer_duration`
It triggers GPIO109. It does not write `service.conf`.
### /dhlr/forbid_time
This topic controls the `fire_leave` reporting pause window.
It reads:
- `minute`
It does not write `service.conf`.
It writes the pause configuration to:
```text
/data/cache/forbid_time_config.json
```
## 6. forbid_time Persistence
`forbid_time_config.json` now stores:
```json
{
"minute": "15",
"start_time": 1770000000,
"end_time": 1770000900
}
```
Meaning:
- `minute`: configured pause duration
- `start_time`: Unix timestamp when the current pause window started
- `end_time`: Unix timestamp when the current pause window ends
If Docker restarts while a pause window is still active, startup restores the pause window from `end_time`.
If `end_time` is expired, no pause is restored.
If `minute` is `0`, the pause window is cleared and `end_time` is written as `0`.
## 7. fire_leave Pause Behavior
During an active pause window:
- person detection continues
- temperature reading continues
- `fire_leave` reporting is skipped
- `fire_check` reporting is not affected
Default pause duration is:
```text
15 minutes
```
## 8. Port Manager Fix
Inference startup and heartbeat restart now use the same port manager:
```text
connect.GlobalPortManager
```
This avoids restart failures caused by using a separate `portmanager.GlobalPortManager` state.
## 9. Known Remaining Risks
1. The protobuf WebSocket protocol migration is not implemented because this repository does not contain `service.proto` or generated Go protobuf types.
2. The `reporter` package still contains old event-cycle functions. GPIO no longer calls them, but the old reporter upload paths still reference event-cycle behavior. This should be cleaned after confirming no external dependency remains.
3. `fire_check` heartbeat restart currently calls a shared restart function that restarts both `fire_leave` and `fire_check`. This is functional but broader than necessary.
4. The local environment currently does not expose `go` or `gofmt`, so compilation and formatting have not been executed in this workspace.
+70
View File
@@ -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. 增加最小验证
- 增加协议层编解码基础测试
- 增加配置映射测试
- 增加报警暂停窗口逻辑测试
- 条件允许时补最小集成测试
+528
View File
@@ -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 当前代码实现差异”文档,专门帮你找现在代码和你脑中业务之间的偏差。
+332
View File
@@ -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`,也没有看到覆盖关键链路的自动化测试。
### 风险
- 协议层改造、重启链路改造、报警暂停逻辑改造容易引入回归
### 准备怎么改
至少补最小化验证路径。
+149
View File
@@ -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"}}
BIN
View File
Binary file not shown.
+224
View File
@@ -0,0 +1,224 @@
package gpio_monitor
import (
"context"
"fmt"
"os"
"strconv"
"time"
"FireLeave_tool/logger"
)
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
onPress func()
}
// 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, // 冷却期状态
}
}
func (m *GPIOButtonMonitor) SetOnPress(handler func()) {
m.onPress = handler
}
// 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, triggering fire_leave pause...", m.gpioPin)
} else if log := logger.GlobalLoggerManager.GetLogger("reporter"); log != nil {
log.Printf("GPIO %d detected stable high level after debounce, triggering fire_leave pause...", m.gpioPin)
}
// 切换事件周期
if m.onPress != nil {
m.onPress()
}
// 更新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
}
+113 -45
View File
@@ -2,11 +2,9 @@ package httpreader
import (
"FireLeave_tool/connect"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -17,14 +15,31 @@ import (
// InferenceResult 推理结果
type InferenceResult struct {
Serial string `json:"serial"`
At int64 `json:"at"`
Type int `json:"type"`
Params []struct {
ClassIdx int `json:"class_idx"`
Name string `json:"name"`
Number int `json:"number"`
} `json:"params"`
MessageType string `json:"message_type"`
Serial string `json:"serial"`
At int64 `json:"at"`
Type int `json:"type"`
Params []InferenceParam `json:"params"`
}
type InferenceParam struct {
ClassIdx int `json:"class_idx"`
Name string `json:"name"`
Number int `json:"number"`
}
type HeartbeatMessage struct {
MessageType string `json:"message_type"`
SchemaVersion int `json:"schema_version"`
Serial string `json:"serial"`
ProcessUUID string `json:"process_uuid"`
Timestamp int64 `json:"timestamp"`
Status string `json:"status"`
}
type inferenceStats struct {
windowStart time.Time
count int
}
// InferenceServer 推理数据接收服务器
@@ -35,6 +50,8 @@ type InferenceServer struct {
dataChan chan InferenceResult
mu sync.RWMutex
running bool
statsMu sync.Mutex
stats inferenceStats
}
// NewInferenceServer 创建推理数据接收服务器
@@ -58,6 +75,7 @@ func (is *InferenceServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/video/post", is.handleVideoPost)
mux.HandleFunc("/heartbeat", is.handleHeartbeat)
mux.HandleFunc("/health", is.handleHealthCheck)
is.server = &http.Server{
@@ -91,41 +109,23 @@ func (is *InferenceServer) handleVideoPost(w http.ResponseWriter, r *http.Reques
return
}
// 读取原始 body
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
logger.Logger.Printf("Read request body failed: %v", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// 重新设置 body 用于解析
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
var result InferenceResult
if err := json.NewDecoder(r.Body).Decode(&result); err != nil {
logger.Logger.Printf("Parse JSON failed: %v", err)
logger.Logger.Printf("Parse inference_result JSON failed: %v (DeviceUUID=%s)", err, is.deviceUUID)
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
return
}
// 验证必要字段
if result.Serial == "" {
logger.Logger.Printf("Data validation failed: Serial is empty")
http.Error(w, `{"error":"Missing serial field"}`, http.StatusBadRequest)
if result.MessageType != "inference_result" {
logger.Logger.Printf("Inference result validation failed: message_type=%q (DeviceUUID=%s)", result.MessageType, is.deviceUUID)
http.Error(w, `{"error":"Invalid message_type"}`, http.StatusBadRequest)
return
}
now := time.Now().Format("2006/01/02 15:04:05.000")
logger.Logger.Printf("♥ 推理心跳收到 | 时间: %s | UUID: %s | Serial: %s | 人数量: %d",
now, is.deviceUUID, result.Serial, getPersonCount(result))
// 这一行就是你缺失的救命稻草!
connect.UpdateInferenceHeartbeat(is.deviceUUID)
// 额外写一份永不丢失的独立心跳日志
if f, err := os.OpenFile("/data/heartbeat.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
defer f.Close()
fmt.Fprintf(f, "[%s] HEARTBEAT OK | %s | person=%d\n", now, is.deviceUUID, getPersonCount(result))
if result.Serial == "" {
logger.Logger.Printf("Inference result validation failed: serial is empty (DeviceUUID=%s)", is.deviceUUID)
http.Error(w, `{"error":"Missing serial field"}`, http.StatusBadRequest)
return
}
// 发送到处理通道
@@ -135,13 +135,89 @@ func (is *InferenceServer) handleVideoPost(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok", "message":"data received"}`))
// 使用 startTime 记录处理时间
logger.Logger.Printf("Inference data processed in %v", time.Since(startTime))
is.recordInferenceProcessed(time.Since(startTime))
default:
logger.Logger.Printf("Data channel full, discard data")
http.Error(w, `{"error":"Channel full"}`, http.StatusServiceUnavailable)
}
}
func (is *InferenceServer) recordInferenceProcessed(processCost time.Duration) {
is.statsMu.Lock()
defer is.statsMu.Unlock()
now := time.Now()
if is.stats.windowStart.IsZero() {
is.stats.windowStart = now
}
is.stats.count++
if now.Sub(is.stats.windowStart) >= 10*time.Second {
logger.Logger.Printf("Inference data processed summary | UUID=%s | count=%d | window=%v | last_cost=%v",
is.deviceUUID, is.stats.count, now.Sub(is.stats.windowStart).Round(time.Millisecond), processCost)
is.stats.windowStart = now
is.stats.count = 0
}
}
func (is *InferenceServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var heartbeat HeartbeatMessage
if err := json.NewDecoder(r.Body).Decode(&heartbeat); err != nil {
logger.Logger.Printf("Parse heartbeat JSON failed: %v (DeviceUUID=%s)", err, is.deviceUUID)
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
return
}
if heartbeat.MessageType != "heartbeat" {
logger.Logger.Printf("Heartbeat validation failed: message_type=%q (DeviceUUID=%s)", heartbeat.MessageType, is.deviceUUID)
http.Error(w, `{"error":"Invalid message_type"}`, http.StatusBadRequest)
return
}
if heartbeat.SchemaVersion != 1 {
logger.Logger.Printf("Heartbeat validation failed: schema_version=%d (DeviceUUID=%s)", heartbeat.SchemaVersion, is.deviceUUID)
http.Error(w, `{"error":"Unsupported schema_version"}`, http.StatusBadRequest)
return
}
if heartbeat.Serial == "" {
logger.Logger.Printf("Heartbeat validation failed: serial is empty (DeviceUUID=%s)", is.deviceUUID)
http.Error(w, `{"error":"Missing serial field"}`, http.StatusBadRequest)
return
}
if heartbeat.ProcessUUID == "" {
logger.Logger.Printf("Heartbeat validation failed: process_uuid is empty (DeviceUUID=%s)", is.deviceUUID)
http.Error(w, `{"error":"Missing process_uuid field"}`, http.StatusBadRequest)
return
}
if heartbeat.ProcessUUID != is.deviceUUID {
logger.Logger.Printf("Heartbeat process_uuid mismatch: got=%s expected=%s", heartbeat.ProcessUUID, is.deviceUUID)
http.Error(w, `{"error":"process_uuid mismatch"}`, http.StatusBadRequest)
return
}
connect.UpdateInferenceHeartbeat(is.deviceUUID)
now := time.Now().Format("2006/01/02 15:04:05.000")
logger.Logger.Printf("Inference heartbeat received | Time: %s | UUID: %s | Serial: %s | Status: %s",
now, is.deviceUUID, heartbeat.Serial, heartbeat.Status)
if f, err := os.OpenFile("/data/heartbeat.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
defer f.Close()
fmt.Fprintf(f, "[%s] HEARTBEAT OK | %s | serial=%s | status=%s | ts=%d\n", now, is.deviceUUID, heartbeat.Serial, heartbeat.Status, heartbeat.Timestamp)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok","message":"heartbeat received"}`))
}
func (is *InferenceServer) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -178,11 +254,3 @@ func (s *InferenceServer) StopWithContext(ctx context.Context) {
logger.Logger.Printf("The HTTP server has been shut down (DeviceUUID=%s)", s.deviceUUID)
}
}
func getPersonCount(result InferenceResult) int {
for _, p := range result.Params {
if p.ClassIdx == 1 {
return p.Number
}
}
return 0
}
+178 -8
View File
@@ -9,24 +9,119 @@ import (
)
// Logger 全局日志实例
var Logger *log.Logger
var Logger = log.New(os.Stdout, "", log.LstdFlags)
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 {
Binary file not shown.
+561 -271
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

View File
+278
View File
@@ -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()
+47
View File
@@ -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
)
+777
View File
@@ -0,0 +1,777 @@
package reporter
import (
"encoding/json"
"fmt"
"log"
"os"
"sync"
"time"
"FireLeave_tool/connect"
"FireLeave_tool/logger"
"FireLeave_tool/video_server"
)
var (
mergingAlarms sync.Map
eventCycleMutex sync.RWMutex
currentEventCycle EventCycle
cycleTimeout time.Time
)
type EventCycle string
const (
EventCycleFireLeave EventCycle = "fire_leave"
EventCycleFireCheck EventCycle = "fire_check"
CycleTimeoutFile = "/data/cache/event_cycle_timeout.json"
)
type Reporter struct {
wsClient *connect.WSClient
uploadURL string
logger *Logger
}
type Logger struct {
*log.Logger
}
func NewLogger() *Logger {
return &Logger{
Logger: logger.GlobalLoggerManager.GetLogger("reporter"),
}
}
func NewReporter() *Reporter {
eventCycleMutex.Lock()
currentEventCycle = EventCycleFireLeave
eventCycleMutex.Unlock()
return &Reporter{
wsClient: connect.GetWSClient(),
uploadURL: "",
logger: NewLogger(),
}
}
func (r *Reporter) getLogger() *log.Logger {
if r.logger != nil && r.logger.Logger != nil {
return r.logger.Logger
}
return logger.Logger
}
func GetCurrentEventCycle() EventCycle {
eventCycleMutex.RLock()
defer eventCycleMutex.RUnlock()
return currentEventCycle
}
func IsCycleTimeout() bool {
eventCycleMutex.RLock()
defer eventCycleMutex.RUnlock()
return time.Now().After(cycleTimeout)
}
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("Event cycle switched: %s -> %s, timeout=%v", oldCycle, cycle, timeout)
}
return nil
}
type CycleTimeoutConfig struct {
FireLeaveTimeout time.Duration `json:"fire_leave_timeout"`
FireCheckTimeout time.Duration `json:"fire_check_timeout"`
}
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
}
}
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)
}
var (
// ForbidTimeFile 缁備焦顒涢弮鍫曟?缂傛挸鐡ㄩ弬鍥︽
ForbidTimeFile = "/data/cache/forbid_time_config.json"
)
type ForbidTimeConfig struct {
Minute string `json:"minute"`
Minutr string `json:"minutr,omitempty"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
// SaveForbidTimeConfig 娣囨繂鐡ㄧ粋浣诡剾閺冨爼妫块柊宥囩枂
func SaveForbidTimeConfig(minute string) error {
return SaveForbidTimeWindow(minute, 0, 0)
}
func SaveForbidTimeWindow(minute string, startTime, endTime int64) error {
config := ForbidTimeConfig{
Minute: minute,
StartTime: startTime,
EndTime: endTime,
}
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)
}
func LoadForbidTimeConfig() (ForbidTimeConfig, error) {
data, err := os.ReadFile(ForbidTimeFile)
if err != nil {
return ForbidTimeConfig{}, err
}
var config ForbidTimeConfig
if err := json.Unmarshal(data, &config); err != nil {
return ForbidTimeConfig{}, err
}
if config.Minute == "" {
config.Minute = config.Minutr
}
return config, nil
}
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 {
config, err := LoadForbidTimeConfig()
if err != nil {
// 缂傛挸鐡ㄦ稉顓熺梾閺堝鈧吋妞傞敍宀勭帛鐠併倛绻戦崶?
return "0"
}
if config.Minute != "" {
return config.Minute
}
return config.Minutr
}
func GetForbidDuration(defaultDuration time.Duration) time.Duration {
return ParseForbidDuration(GetForbidTimeConfig(), defaultDuration)
}
func ParseForbidDuration(value string, defaultDuration time.Duration) time.Duration {
if value == "" || value == "0" {
return defaultDuration
}
if duration, err := time.ParseDuration(value); err == nil && duration > 0 {
return duration
}
var minutes int
if _, err := fmt.Sscanf(value, "%d", &minutes); err == nil && minutes > 0 {
return time.Duration(minutes) * time.Minute
}
return defaultDuration
}
func ShouldReportBothEvents() bool {
return false
}
// jsonString
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)
}
func logJSON(logger *log.Logger, label string, v interface{}) {
if logger == nil {
return
}
b, err := json.Marshal(v)
if err != nil {
logger.Printf("%s JSON缂傛牜鐖滄径杈Е: %v", label, err)
return
}
logger.Printf("%s %s", label, string(b))
}
func cleanupMergedFileAfterUpload(logger *log.Logger, mergedFile, deviceUUID string, uploadFailed bool) {
if uploadFailed {
logger.Printf("Upload failed, keep temp merged file for troubleshooting: %s (DeviceUUID=%s)", mergedFile, deviceUUID)
return
}
logger.Printf("Remove temp merged file: %s (DeviceUUID=%s)", mergedFile, deviceUUID)
if err := os.Remove(mergedFile); err != nil {
logger.Printf("Remove temp merged file failed: %v (DeviceUUID=%s, file=%s)", err, deviceUUID, mergedFile)
}
}
func (r *Reporter) UploadVideoAndReport(deviceData connect.DeviceData, alarmTime int64, mergedFile string) error {
go func() {
logger := r.getLogger()
defer func() {
if recovered := recover(); recovered != nil {
logger.Printf("upload/report panic: %v (DeviceUUID=%s)", recovered, deviceData.DeviceUUID)
_ = os.Remove(mergedFile)
}
}()
if IsCycleTimeout() {
if err := SwitchEventCycle(EventCycleFireLeave); err != nil {
logger.Printf("switch event cycle failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
}
}
currentCycle := GetCurrentEventCycle()
var reportLevel ReportLevel
switch currentCycle {
case EventCycleFireLeave:
reportLevel = ReportLevelFireLeave
case EventCycleFireCheck:
reportLevel = ReportLevelFireCheck
default:
reportLevel = ReportLevelFireLeave
}
nginxParams := r.GenerateNginxParams(deviceData, alarmTime, reportLevel)
videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL)
if err != nil {
logger.Printf("upload video failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, true)
return
}
if r.wsClient == nil {
r.wsClient = connect.GetWSClient()
}
if r.wsClient == nil || !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
if err := r.ReportEventSync(deviceData, alarmTime, videoURL, string(reportLevel)); err != nil {
logger.Printf("report event failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
if err := r.ReportDataSnapshotSync(deviceData, alarmTime); err != nil {
logger.Printf("report snapshot failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
}()
return nil
}
// UploadVideoAndReportFireLeave uploads and reports a fire_leave event asynchronously.
func (r *Reporter) UploadVideoAndReportFireLeave(deviceData connect.DeviceData, alarmTime int64, mergedFile string) error {
go func() {
logger := r.getLogger()
defer func() {
if recovered := recover(); recovered != nil {
logger.Printf("fire_leave upload/report panic: %v (DeviceUUID=%s)", recovered, deviceData.DeviceUUID)
_ = os.Remove(mergedFile)
}
}()
if IsCycleTimeout() {
if err := SwitchEventCycle(EventCycleFireLeave); err != nil {
logger.Printf("switch event cycle failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
}
}
nginxParams := r.GenerateNginxParams(deviceData, alarmTime, ReportLevelFireLeave)
videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL)
if err != nil {
logger.Printf("upload fire_leave video failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, true)
return
}
if r.wsClient == nil {
r.wsClient = connect.GetWSClient()
}
if r.wsClient == nil || !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
if err := r.ReportEventSync(deviceData, alarmTime, videoURL, string(ReportLevelFireLeave)); err != nil {
logger.Printf("report fire_leave event failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
if err := r.ReportDataSnapshotSync(deviceData, alarmTime); err != nil {
logger.Printf("report fire_leave snapshot failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
return
}
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
}()
return nil
}
// UploadVideoAndReportFireCheck uploads and reports a fire_check event synchronously.
// UploadVideoAndReportFireCheck uploads and reports a fire_check event synchronously.
func (r *Reporter) UploadVideoAndReportFireCheck(deviceData connect.DeviceData, alarmTime int64, mergedFile string) (err error) {
logger := r.getLogger()
logger.Printf("Start fire_check upload/report (DeviceUUID=%s, file=%s)", deviceData.DeviceUUID, mergedFile)
defer func() {
if recovered := recover(); recovered != nil {
logger.Printf("fire_check upload/report panic: %v (DeviceUUID=%s)", recovered, deviceData.DeviceUUID)
err = fmt.Errorf("fire_check upload/report panic: %v", recovered)
}
}()
if IsCycleTimeout() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
if err := SwitchEventCycle(EventCycleFireCheck); err != nil {
logger.Printf("Switch event cycle to fire_check failed: %v", err)
}
}
nginxParams := r.GenerateNginxParams(deviceData, alarmTime, ReportLevelFireCheck)
ensureNginxReportLevel(nginxParams, ReportLevelFireCheck)
logger.Printf("Upload fire_check video to Nginx (DeviceUUID=%s, file=%s, URL=%s)", deviceData.DeviceUUID, mergedFile, deviceData.UploadURL)
videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL)
if err != nil {
logger.Printf("Upload fire_check video to Nginx failed: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
return fmt.Errorf("upload fire_check video failed: %w", err)
}
logger.Printf("Upload fire_check video to Nginx succeeded: %s (DeviceUUID=%s)", videoURL, deviceData.DeviceUUID)
if r.wsClient == nil {
r.wsClient = connect.GetWSClient()
}
if r.wsClient == nil {
return fmt.Errorf("websocket client not initialized")
}
if !r.wsClient.IsConnected() {
return fmt.Errorf("websocket client not connected")
}
if err := r.ReportEventSync(deviceData, alarmTime, videoURL, string(ReportLevelFireCheck)); err != nil {
return err
}
if err := r.ReportDataSnapshotSync(deviceData, alarmTime); err != nil {
return err
}
cleanupMergedFileAfterUpload(logger, mergedFile, deviceData.DeviceUUID, false)
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return nil
}
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)
}
}()
nginxParams := r.GenerateNginxParams(deviceData, alarmTime, reportLevel)
videoURL, err := video_server.UploadToNginx(mergedFile, nginxParams, deviceData.UploadURL)
videoURLStr := ""
if err != nil {
logger.Printf("Nginx涓婁紶澶辫触: %v", err)
} else {
videoURLStr = videoURL
logger.Printf("Nginx upload succeeded: %s", videoURL)
}
if r.wsClient == nil || !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (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("Report event completed (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, string(reportLevel))
}()
return nil
}
func (r *Reporter) ReportEvent(deviceData connect.DeviceData, alarmTime int64, videoURL string, level string) error {
logger := r.getLogger()
if r.wsClient == nil {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not initialized")
}
if !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not connected")
}
eventMsg := r.GenerateEventMessage(deviceData, alarmTime, videoURL, level)
r.wsClient.SendAsync(eventMsg)
logger.Printf("Report event sync succeeded (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, level)
return nil
}
func (r *Reporter) ReportEventSync(deviceData connect.DeviceData, alarmTime int64, videoURL string, level string) error {
logger := r.getLogger()
if r.wsClient == nil {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not initialized")
}
if !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not connected")
}
if err := r.wsClient.SendRawMessage(r.GenerateEventMessage(deviceData, alarmTime, videoURL, level)); err != nil {
logger.Printf("浜嬩欢鍚屾涓婃姤澶辫触: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
return err
}
logger.Printf("Report event sync succeeded (DeviceUUID=%s, Level=%s)", deviceData.DeviceUUID, level)
return nil
}
func (r *Reporter) ReportDataSnapshot(deviceData connect.DeviceData, alarmTime int64) error {
logger := r.getLogger()
if r.wsClient == nil {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not initialized")
}
if !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not connected")
}
dataMsg := r.GenerateDataMessage(deviceData, alarmTime)
r.wsClient.SendAsync(dataMsg)
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return nil
}
func (r *Reporter) ReportDataSnapshotSync(deviceData connect.DeviceData, alarmTime int64) error {
logger := r.getLogger()
if r.wsClient == nil {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not initialized")
}
if !r.wsClient.IsConnected() {
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return fmt.Errorf("websocket client not connected")
}
if err := r.wsClient.SendRawMessage(r.GenerateDataMessage(deviceData, alarmTime)); err != nil {
logger.Printf("鏁版嵁蹇収鍚屾涓婃姤澶辫触: %v (DeviceUUID=%s)", err, deviceData.DeviceUUID)
return err
}
logger.Printf("Report data snapshot sync succeeded (DeviceUUID=%s)", deviceData.DeviceUUID)
return nil
}
func (r *Reporter) GenerateEventMessage(deviceData connect.DeviceData, alarmTime int64, videoURL string, level string) map[string]interface{} {
hostUUID := r.getHostUUID(deviceData.DeviceUUID)
description := "No person in work area and temperature over threshold"
if level == string(ReportLevelFireCheck) {
description = "Abnormal fire detected"
}
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,
},
}
return map[string]interface{}{
"method": "metric_data",
"params": map[string]interface{}{
"route_key": fmt.Sprintf("/dhlr/device/%s/event", deviceData.DeviceUUID),
"metric": jsonString(eventPayload),
},
}
}
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,
},
}
return map[string]interface{}{
"method": "metric_data",
"params": map[string]interface{}{
"route_key": fmt.Sprintf("/dhlr/device/%s/data", deviceData.DeviceUUID),
"metric": jsonString(dataPayload),
},
}
}
func (r *Reporter) GenerateNginxParams(deviceData connect.DeviceData, alarmTime int64, level ReportLevel) map[string]interface{} {
hostUUID := r.getHostUUID(deviceData.DeviceUUID)
description := "No person in work area and temperature over threshold"
if level == ReportLevelFireCheck {
description = "Abnormal fire detected"
}
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
}
logJSON(r.getLogger(), fmt.Sprintf("[Nginx Upload Params JSON] DeviceUUID=%s Level=%s", deviceData.DeviceUUID, level), params)
return params
}
func ensureNginxReportLevel(params map[string]interface{}, level ReportLevel) {
params["level"] = string(level)
if _, ok := params["description"]; !ok {
if level == ReportLevelFireCheck {
params["description"] = "Abnormal fire detected"
} else {
params["description"] = "No person in work area and temperature over threshold"
}
}
}
// TriggerAlarmWithDelay 瀵ゆ儼绻滅憴锕€褰傞幎銉劅楠炴湹绗傞幎銉礄濞撯晛瀹抽幎銉劅閿?// TriggerAlarmWithDelay handles a fire_leave temperature alarm.
func (r *Reporter) TriggerAlarmWithDelay(deviceData connect.DeviceData, alarmTime int64, cooldown int) {
startTime := time.Now()
logger := r.getLogger()
logger.Printf("[%.3f] Start fire_leave alarm handling (DeviceUUID=%s, alarmTime=%d, cooldown=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime, cooldown)
defer func() {
if err := recover(); err != nil {
logger.Printf("[%.3f] fire_leave alarm handling 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] Check fire_leave alarm lock (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
if _, loaded := mergingAlarms.LoadOrStore(key, true); loaded {
logger.Printf("[%.3f] fire_leave alarm already handling, skip duplicate (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
return
}
defer mergingAlarms.Delete(key)
logger.Printf("[%.3f] fire_leave alarm lock acquired (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
logger.Printf("[%.3f] Start merging fire_leave video (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] Merge fire_leave video failed (DeviceUUID=%s, elapsed=%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds(), err)
return
}
logger.Printf("[%.3f] Merge fire_leave video succeeded: %s (DeviceUUID=%s, elapsed=%.3fs)", time.Since(startTime).Seconds(), mergedFile, deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds())
logger.Printf("[%.3f] Start upload/report for fire_leave alarm (DeviceUUID=%s, file=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, mergedFile)
uploadStartTime := time.Now()
if err := r.UploadVideoAndReportFireLeave(deviceData, alarmTime, mergedFile); err != nil {
logger.Printf("[%.3f] fire_leave alarm upload/report failed (DeviceUUID=%s, elapsed=%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds(), err)
if err := os.Remove(mergedFile); err != nil {
logger.Printf("[%.3f] Remove merged file after fire_leave failure failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID)
}
return
}
logger.Printf("[%.3f] fire_leave alarm upload/report succeeded (DeviceUUID=%s, elapsed=%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds())
logger.Printf("[%.3f] fire_leave alarm handling completed (DeviceUUID=%s, total=%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(startTime).Seconds())
}
func (r *Reporter) TriggerFireCheckAlarm(deviceData connect.DeviceData, alarmTime int64) bool {
startTime := time.Now()
logger := r.getLogger()
logger.Printf("[%.3f] Start fire_check alarm handling (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime)
defer func() {
if err := recover(); err != nil {
logger.Printf("[%.3f] fire_check alarm handling panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID)
key := fmt.Sprintf("%s_fire", deviceData.DeviceUUID)
mergingAlarms.Delete(key)
}
}()
key := fmt.Sprintf("%s_fire", deviceData.DeviceUUID)
logger.Printf("[%.3f] Check fire_check alarm lock (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
if _, loaded := mergingAlarms.LoadOrStore(key, true); loaded {
logger.Printf("[%.3f] fire_check alarm already handling, skip duplicate (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
return false
}
defer mergingAlarms.Delete(key)
logger.Printf("[%.3f] fire_check alarm lock acquired (DeviceUUID=%s, key=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, key)
const fireCheckVideoFlushDelay = 21 * time.Second
logger.Printf("[%.3f] Wait %v for fire_check video cache flush before merge (DeviceUUID=%s)",
time.Since(startTime).Seconds(), fireCheckVideoFlushDelay, deviceData.DeviceUUID)
time.Sleep(fireCheckVideoFlushDelay)
logger.Printf("[%.3f] Start merging fire_check video (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, alarmTime)
mergeStartTime := time.Now()
mergedFile, err := video_server.MergeVideoFromDirectory(
deviceData.DeviceUUID+"_fire_check",
deviceData.DeviceUUID,
alarmTime,
)
if err != nil {
logger.Printf("[%.3f] Merge fire_check video failed (DeviceUUID=%s, elapsed=%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds(), err)
dumpFireCheckVideoDirectory(logger, deviceData.DeviceUUID)
return false
}
logger.Printf("[%.3f] Merge fire_check video succeeded: %s (DeviceUUID=%s, elapsed=%.3fs)", time.Since(startTime).Seconds(), mergedFile, deviceData.DeviceUUID, time.Since(mergeStartTime).Seconds())
logger.Printf("[%.3f] Start upload/report for fire_check alarm (DeviceUUID=%s, file=%s)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, mergedFile)
uploadStartTime := time.Now()
if err := r.UploadVideoAndReportFireCheck(deviceData, alarmTime, mergedFile); err != nil {
logger.Printf("[%.3f] fire_check alarm upload/report failed (DeviceUUID=%s, elapsed=%.3fs): %v", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds(), err)
if err := os.Remove(mergedFile); err != nil {
logger.Printf("[%.3f] Remove merged file after fire_check failure failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceData.DeviceUUID)
}
return false
}
logger.Printf("[%.3f] fire_check alarm upload/report succeeded (DeviceUUID=%s, elapsed=%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(uploadStartTime).Seconds())
logger.Printf("[%.3f] fire_check alarm handling completed (DeviceUUID=%s, total=%.3fs)", time.Since(startTime).Seconds(), deviceData.DeviceUUID, time.Since(startTime).Seconds())
return true
}
func dumpFireCheckVideoDirectory(logger *log.Logger, deviceUUID string) {
videoDir := fmt.Sprintf("/usr/data/camera/%s_fire_check", deviceUUID)
files, err := os.ReadDir(videoDir)
if err != nil {
logger.Printf("fire_check video directory snapshot failed: dir=%s, err=%v", videoDir, err)
return
}
logger.Printf("fire_check video directory snapshot: dir=%s, files=%d", videoDir, len(files))
for _, file := range files {
info, err := file.Info()
if err != nil {
logger.Printf(" file=%s, stat_error=%v", file.Name(), err)
continue
}
if file.IsDir() {
logger.Printf(" dir=%s, mod=%s", file.Name(), info.ModTime().Format(time.RFC3339))
continue
}
logger.Printf(" file=%s, size=%d, mod=%s", file.Name(), info.Size(), info.ModTime().Format(time.RFC3339))
}
}
func (r *Reporter) getHostUUID(deviceUUID string) string {
configs, err := connect.LoadServiceConfig()
if err != nil {
r.logger.Printf("Load service config failed: %v", err)
return ""
}
for _, cfg := range configs {
if cfg.DeviceUUID == deviceUUID {
return cfg.HostUUID
}
}
return ""
}
// SetWSClient
func (r *Reporter) SetWSClient(client *connect.WSClient) {
r.wsClient = client
}
// SetUploadURL 鐠佸墽鐤嗘稉濠佺炊URL
func (r *Reporter) SetUploadURL(url string) {
r.uploadURL = url
}
// IsWSReady 濡偓閺岊櫇ebSocket閺勵垰鎯佺亸杈╁崕
func (r *Reporter) IsWSReady() bool {
return r.wsClient != nil && r.wsClient.IsConnected()
}
+57
View File
@@ -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
}
+133
View File
@@ -0,0 +1,133 @@
#!/bin/sh
set -eu
SYSTEM_DIR="${1:-/data/logs/system}"
REPORTER_DIR="${2:-/data/logs/reporter}"
LINES="${LINES:-40}"
DEVICE_UUID="${DEVICE_UUID:-}"
print_usage() {
cat <<'USAGE'
用法:
sh scripts/check_logs.sh [系统日志目录] [上报日志目录]
示例:
sh scripts/check_logs.sh
sh scripts/check_logs.sh /data/logs/system /data/logs/reporter
LINES=80 sh scripts/check_logs.sh
DEVICE_UUID=your-device-uuid sh scripts/check_logs.sh
环境变量:
LINES 每个分组显示最近多少行,默认 40
DEVICE_UUID 可选,按设备 UUID 过滤
USAGE
}
case "${1:-}" in
-h|--help)
print_usage
exit 0
;;
esac
has_logs() {
dir="$1"
[ -d "$dir" ] || return 1
find "$dir" -maxdepth 1 -type f -name '*.log' 2>/dev/null | grep -q .
}
grep_logs() {
pattern="$1"
shift
for dir in "$@"; do
has_logs "$dir" || continue
if [ -n "$DEVICE_UUID" ]; then
grep -hE "$pattern" "$dir"/*.log 2>/dev/null | grep -F "$DEVICE_UUID" || true
else
grep -hE "$pattern" "$dir"/*.log 2>/dev/null || true
fi
done
}
count_logs() {
pattern="$1"
shift
grep_logs "$pattern" "$@" | wc -l | tr -d ' '
}
section() {
title="$1"
pattern="$2"
shift 2
count="$(count_logs "$pattern" "$@")"
printf '\n=== %s%s 条)===\n' "$title" "$count"
if [ "$count" = "0" ]; then
echo "没有匹配日志。"
return
fi
grep_logs "$pattern" "$@" | tail -n "$LINES"
}
summary_item() {
label="$1"
pattern="$2"
shift 2
printf '%-34s %s\n' "$label:" "$(count_logs "$pattern" "$@")"
}
echo "FireLeave 日志快速检查"
echo "系统日志目录 : $SYSTEM_DIR"
echo "上报日志目录 : $REPORTER_DIR"
echo "每组显示行数 : $LINES"
if [ -n "$DEVICE_UUID" ]; then
echo "设备过滤 : $DEVICE_UUID"
fi
if ! has_logs "$SYSTEM_DIR" && ! has_logs "$REPORTER_DIR"; then
echo "指定目录下没有找到 *.log 日志文件。"
exit 1
fi
printf '\n=== 汇总 ===\n'
summary_item "程序启动次数" "Starting FireLeave Tool" "$SYSTEM_DIR"
summary_item "WS 注册成功" "Service registration successful" "$SYSTEM_DIR"
summary_item "Topic 订阅成功" "Topic subscription successful" "$SYSTEM_DIR"
summary_item "收到 topic_event" "WebSocket received topic_event" "$SYSTEM_DIR"
summary_item "收到声光报警命令" "Received platform alert push|Platform alert command" "$SYSTEM_DIR"
summary_item "收到 forbid_time" "Received forbid time message|Forbid time config received" "$SYSTEM_DIR"
summary_item "fire_leave 暂停命中" "fire_leave reporting paused|fire_leave report paused" "$SYSTEM_DIR"
summary_item "火焰报警触发" "Fire detected|FIRE DETECTED ALARM TRIGGERED|Fire detected continuously" "$SYSTEM_DIR"
summary_item "离人报警触发" "ALARM TRIGGERED|Alarm condition met" "$SYSTEM_DIR"
summary_item "WS/上报失败" "WebSocket.*failed|Response timeout|Asynchronous reporting failed|unsupported websocket message type|reply parsing failed|cast handling failed|WebSocket消息发送失败|响应超时|上报失败" "$SYSTEM_DIR" "$REPORTER_DIR"
summary_item "异常/错误" "panic|fatal|killed|segfault|failed|error|Error|timeout|abnormally|not connected|失败|错误|异常|超时|未连接|无法上报" "$SYSTEM_DIR" "$REPORTER_DIR"
section "启动和设备初始化" "Starting FireLeave Tool|Initialize device data|Loaded .*device|Starting device|Device processing goroutine started|Main program startup completed|System status|初始化设备|设备数据初始化|启动设备" "$SYSTEM_DIR"
section "推理服务启动" "Port allocation successful|Fire_leave inference server|Fire_check inference server|inference program started|Both inference programs started successfully|Start fire_leave inference program command|Start fire_check inference program command|端口分配成功|推理程序启动|推理服务" "$SYSTEM_DIR"
section "推理心跳和重启" "推理心跳收到|Inference data processed|fire_leave.*心跳|fire_check.*心跳|heartbeat.*timeout|准备重启|StartInferenceProcess|推理程序心跳超时" "$SYSTEM_DIR"
section "WebSocket 注册和订阅" "Starting WebSocket connection initialization|WebSocket connection initialized successfully|Starting service registration|Service registration successful|Starting topic subscription|Topic subscription successful|/dhlr/alert|/dhlr/forbid_time|WebSocket连接|服务注册|订阅" "$SYSTEM_DIR"
section "WebSocket 二进制协议" "Sending WebSocket binary frame|Received WebSocket binary frame|Received response|Response timeout|WebSocket reply parsing failed|WebSocket cast handling failed|Ignoring non-binary|Ignoring unknown|发送WebSocket二进制帧|收到WebSocket二进制帧|响应超时" "$SYSTEM_DIR"
section "WebSocket 重连" "WebSocket message reading failed|Failed to send ping|Reconnection attempt|WebSocket reconnection successful|Re-registration successful|Re-subscribed|Reconnection failed|read loop exited|重连|重新注册|重新订阅|读取失败" "$SYSTEM_DIR"
section "下行 topic_event 分发" "WebSocket received topic_event|No subscriber for topic_event|Received forbid time message|Received platform alert push|Forbid time config received|Platform alert command|收到.*topic_event|没有订阅者" "$SYSTEM_DIR"
section "声光报警 alert" "Received platform alert push|Outer parsing failed|Second parsing failed|Not a sound/light alert command|Platform alert command|Buzzer starting|GPIO109|sound/light alarm|声光报警|蜂鸣器|平台告警" "$SYSTEM_DIR"
section "forbid_time 和 fire_leave 暂停" "Received forbid time message|Forbid time config received|fire_leave reporting paused|fire_leave report paused|fire_leave reporting pause cleared|Forbid time config saved successfully|Restored fire_leave forbid window|Invalid fire_leave_timeout|Event cycle timeout config|禁止时间|暂停|forbid_time" "$SYSTEM_DIR"
section "fire_leave 离人报警链路" "Personnel quantity update|Temperature update|Alarm condition met|ALARM TRIGGERED|fire_leave report paused|报警事件已上报|数据快照已上报|level.*fire_leave|Level=fire_leave|人员数量更新|温度更新|离人|温度报警" "$SYSTEM_DIR" "$REPORTER_DIR"
section "fire_check 火焰报警链路" "Received fire_check inference data|Fire detected|Fire detection started|Fire detected continuously|FIRE DETECTED ALARM TRIGGERED|火焰检测|报警事件已上报|数据快照已上报|level.*fire_check|Level=fire_check" "$SYSTEM_DIR" "$REPORTER_DIR"
section "视频合并上传和事件上报" "开始合并视频|Merging .* videos|Video merged and moved successfully|开始上传视频|UploadToNginx|Nginx|upload failed|报警事件已上报|数据快照已上报|Asynchronous reporting successful|Asynchronous reporting failed|Sending WebSocket binary frame: type=0x03|视频上传|事件上报|数据快照" "$SYSTEM_DIR" "$REPORTER_DIR"
section "需要重点排查的问题" "panic|fatal|killed|segfault|WebSocket.*failed|Response timeout|Asynchronous reporting failed|unsupported websocket message type|reply parsing failed|cast handling failed|not connected|无法上报|失败|错误|异常|timeout|abnormally|超时|未连接" "$SYSTEM_DIR" "$REPORTER_DIR"
printf '\n检查完成。\n'
+10
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

@@ -0,0 +1,258 @@
# 管理平台客户操作手册(简版)
> 本文档为简版操作手册。如有疑问或设备异常,请联系技术支持人员,联系方式见“附录 A”。
>
> 当前火焰检测版本说明:检测到异常后,系统会启动告警处理流程。等待约 21 秒,使推理程序完成缓存视频保存后,系统将合成并上传包含事件前后画面的完整视频。
---
## 1. 快速开始
如现场有技术人员,可按照本手册自行完成配置;如无技术人员,请先将设备的 LAN0 接口连接至公司内部网络,再通过电话、邮件或微信联系技术支持人员。
### 1.1 连接设备
1. 连接设备电源线,确认电源指示灯 `PWR` 正常亮起。
![设备电源接口](images/IMG_0122.png)
2. 使用网线连接设备:
| 接口 | 连接对象 |
| --- | --- |
| LAN1 | Windows 管理主机 |
| LAN0 | 公司内部网络 |
![设备网络接口](images/IMG_0120.png)
### 1.2 配置 Windows 管理主机网络
1.`Win + S`,搜索并打开“控制面板”。
![打开控制面板](images/1.png)
2. 打开“网络和 Internet”。
![打开网络和 Internet](images/2.png)
3. 打开“网络和共享中心”。
![打开网络和共享中心](images/3.png)
4. 找到 Windows 主机连接设备 LAN1 接口所使用的网卡,右键单击该网卡并选择“属性”。
![打开网卡属性](images/4.png)
5. 在属性列表中选中“Internet 协议版本 4(TCP/IPv4)”,然后单击“属性”。
![选择 IPv4](images/5.png)
6. 按照下图设置 IP 地址和子网掩码,完成后单击“确定”。
![设置 IP 地址和子网掩码](images/6.png)
7. 返回网卡属性窗口,再次单击“确定”保存设置。
![保存网卡设置](images/7.png)
8.`Win + S`,搜索并打开“命令提示符(CMD)”。
![打开命令提示符](images/8.png)
9. 执行以下命令,测试管理主机与设备之间的网络是否连通:
```text
ping 10.20.1.1
```
![测试设备网络连通性](images/9.png)
10. 打开 Microsoft Edge 或 Google Chrome,在地址栏输入 `10.20.1.1`。
![访问管理平台](images/10.png)
### 1.3 登录平台
1. 打开浏览器。
2. 在地址栏输入平台地址:`http://10.20.1.1`。
3. 进入平台登录页面。
![平台登录页面](images/image.png)
4. 输入用户名和密码。默认用户名为 `admin`,默认密码为 `admin`。
5. 单击“登录”,进入平台首页。
---
## 2. 平台网络设置
### 2.1 网络设置说明
| 项目 | 说明 |
| --- | --- |
| 默认管理地址 | `10.20.1.1` |
| LAN0 | 用于连接公司内部局域网 |
| Wi-Fi | 可替代 LAN0 连接公司内部网络 |
首次配置时,请通过 `10.20.1.1` 进入管理平台,再设置 LAN0 或 Wi-Fi。LAN0 和 Wi-Fi 可任选一种方式连接内部网络;如果同时配置,两者的 IP 地址不得相同。
配置完成后,可通过分配给设备的内部网络地址访问管理平台。
### 2.2 修改 LAN0 网络
1. 在 Windows 管理主机上按 `Win + S`,搜索并打开“命令提示符(CMD)”,然后执行:
```text
ipconfig
```
查看公司内部网络当前使用的网段。
![查看当前网络配置](image-3.png)
2. 进入管理平台,在左侧导航栏中选择“网络设置”。
![进入网络设置](image.png)
3. 找到 LAN0,单击“编辑”。
4. 根据公司内部网络的网段填写 LAN0 的 IP 地址、子网掩码、网关和 DNS。
例如,管理主机的地址为 `192.168.1.123` 时,通常表示当前网段的第三段为 `1`。设备 LAN0 地址也应配置在 `192.168.1.x` 网段内,但不能与网络中的其他设备重复。
![编辑 LAN0 网络](image-4.png)
5. 确认配置无误后,单击“保存”。
> 保存后,设备管理地址可能发生变化。请使用新配置的 IP 地址重新访问平台。
### 2.3 配置 Wi-Fi
1. 在“网络设置”页面找到 Wi-Fi,单击“编辑”。
2. 选择公司内部无线网络。
3. 输入 Wi-Fi 密码,确认无误后单击“保存”。
![配置 Wi-Fi](image-5.png)
---
## 3. 摄像机管理
平台通常已预先配置默认摄像机。如无特殊需求,请勿自行添加或删除摄像机。
### 3.1 编辑摄像机
1. 在摄像机列表中选择目标摄像机,单击“编辑”。
![编辑摄像机](image-6.png)
2. 填写摄像机 RTSP 地址,格式如下:
```text
rtsp://用户名:密码@摄像机IP地址:554
```
其中,`554` 为常用 RTSP 端口;如果摄像机端口未修改,可保持该值。
3. 确认配置无误后,单击“保存”。
### 3.2 配置动火离人检测
> 如果只需要启用火焰检测,可跳过本节。
#### 3.2.1 设置检测区域
1. 使用蓝色框选工具标记需要检测的工作区域。
2. 根据现场摄像机画面调整区域,确认无误后单击“保存”。
![动火离人检测区域](image-7.png)
#### 3.2.2 设置检测参数
| 参数 | 说明 |
| --- | --- |
| 检测人数 | 工作区域内允许的最低人员数量。当检测人数低于设定值时,系统判定人员状态异常。 |
| 检测时间 | 人员数量持续低于设定值达到该时长后,人员条件成立。 |
| 温度阈值 | 测温系统检测到环境温度超过该值时,温度条件成立。人员条件与温度条件同时成立时触发报警。 |
| 置信度 | 模型判断目标可信程度的阈值。例如设置为 `70`,表示置信度达到或超过 70% 的目标才计入识别结果。该项不可留空,通常保持默认值 `70`。 |
| 报警间隔时间 | 一次报警触发后的冷却时间。冷却期间即使再次满足报警条件,也不会重复发送告警。设置为 `0` 表示不启用冷却时间。 |
![动火离人检测参数](image-8.png)
### 3.3 配置火焰检测
#### 3.3.1 设置检测区域
1. 使用蓝色框选工具标记需要检测的工作区域。
2. 根据现场摄像机画面调整区域,确认无误后单击“保存”。
![火焰检测区域](image-10.png)
#### 3.3.2 设置检测参数
默认参数已经过预设,如无必要,请勿修改。
| 参数 | 说明 |
| --- | --- |
| 检测时间 | 火焰目标持续被识别达到该时长后,系统确认报警。 |
| 置信度 | 模型判断火焰目标可信程度的阈值。只有达到或超过该阈值的目标才会进入检测计时。 |
![火焰检测参数](image-9.png)
---
## 4. 联动管理
设备联动是指通过 I/O 接口连接蜂鸣器、音柱等外部设备。如需配置联动设备,请联系技术支持人员。
---
## 5. 数据转发
数据转发用于对接第三方管理平台并传输视频数据。详细配置方法请参阅完整版操作手册。
![数据转发页面](image-11.png)
---
## 6. 告警信息
“告警信息”页面用于查看系统检测到的异常事件及相关视频。
### 6.1 查看告警列表
进入“告警信息”页面后,可按设备筛选和查询告警记录。
### 6.2 告警字段说明
| 字段 | 说明 |
| --- | --- |
| 相机名称 | 对应“摄像机管理”中配置的摄像机 |
| 信息 | 当前告警事件的描述 |
| 创建时间 | 告警事件触发时间 |
| 操作-查看视频 | 查看与告警事件关联的视频 |
| 操作-删除 | 删除无效或无需保留的告警记录 |
---
## 7. 报警转发
报警转发用于对接第三方管理平台,并在事件触发后发送报警信息。详细配置方法请参阅完整版操作手册。
---
## 附录 A:技术支持联系方式
| 项目 | 内容 |
| --- | --- |
| 技术支持电话 | `14752696604` |
| 技术支持邮箱 | `1935881371@qq.com` |
| 服务时间 | `10:00—22:00` |
---
## 附录 B:版本修订记录
| 文档版本 | 修订日期 | 修订内容 | 修订人 |
| --- | --- | --- | --- |
| V1.0 | `2026/05/05` | 首次编制 | `Smos_Guo` |
@@ -0,0 +1,170 @@
# 管理平台客户操作手册-simple
- 此版本为simple版本,如有疑问或设备问题请联系技术人员(查看附件A)
- 当前火焰检测版本说明:当检测到异常状态时会优先执行告警信息上传,等待15s后会合成完整的视频上传至告警信息内,保证记录完整事件前后视频
---
# 1. 快速开始
- 如有技术人员可自行操作,如无技术人员将Lan0连接之后发送邮件或微信联系工作人员
## 1.1 连接配置
- 连接电源线确保电源PWR正常亮起
![alt pwr](images/IMG_0122.png)
使用平台前,请确认网线已经正确连接:
- lan1连接windows主机|lan0连接公司内部网口
![alt text](images/IMG_0120.png)
## 1.2 网络配置
- 设置管理机网络(windows主机)
1. win+s 搜索栏 打开控制面板
![alt text](images/1.png)
2. 打开网络和Internet
![alt text](images/2.png)
3. 打开网络共享中心
![alt text](images/3.png)
4. 找到win主机连接设备lan1网口对应网卡,右键属性设置
![alt text](images/4.png)
5. 打开属性后勾选IPv4选项并且左键点击
![alt text](images/5.png)
6. 打开属性面板后设置如图所示ip|子网掩码,更改成功之后点击确定
![alt text](images/6.png)
7. 更改好IPv4设置之后点击确定保存
![alt text](images/7.png)
8. 打开cmd控制窗口
![alt text](images/8.png)
9. 测试设备是否连通
代码:ping 10.20.1.1
![alt text](images/9.png)
10. 打开浏览器(edge/Chrome)访问 10.20.1.1
![alt text](images/10.png)
## 1.2 访问平台
1. 打开浏览器。
2. 在地址栏输入平台地址:`10.20.1.1`
3. 进入平台登录页面。
![alt text](images/image.png)
4. 输入用户名和密码。(默认:admin\admin
5. 单击“登录”进入平台首页。
# 2. 平台内部网络设置
## 2.1 网络修改
- 默认IP 地址 | 平台或设备在局域网中的地址 | `10.20.1.1` |
- 首次通过 | `10.20.1.1` | 进入平台内部设置lan0(内部局域网)
- 修改之后通过内网网址访问即可
- 修改lan02.2)与修改WIFI(2.3)可选其一设置,但是ip地址不可以一样
## 2.2 修改lan0
1. 开始修改之前还要做一项准备工作,win+s搜索cmd打开命令行,输入 `ipconfig`
![alt text](image-3.png)
查询到当前网络处于什么网段
2. 进入平台前端页面,根据左侧选项栏选择网络设置
![alt text](image.png)
3. 点击编辑开始编辑lan1网络(如图所示),ip的第三位也就是`192.168.1.123`中的x.x.1.x应该填写操作步骤中的1,获取到的第三位网段
![alt text](image-4.png)
确认无误之后选择保存
## 2.3 修改WIFI
- 点击编辑之后选择当前内部网络的WiFi,然后输入密码保存即可
![alt text](image-5.png)
---
# 3. 摄像机管理
平台内部已经设置成功默认摄像头,无特殊需要请勿自己添加摄像头
## 3.1 摄像机编辑
1. 选择编辑
![alt text](image-6.png)
2. 仅需要添加rtsp地址`rtsp://usr(摄像头账户):摄像头(密码)@ 摄像头IP地址:554(摄像头默认rtsp端口,未修改无需改动)`
3. 确认修改无误之后点击保存
## 3.2 摄像机配置
- 如只需启用火焰检测,可省略1/2分类
1. 动火离人区域检测配置:
- 1.1 蓝色方框为工作目标区域框选
- 1.2 根据现场摄像头设置工作区域之后点击保存
![alt text](image-7.png)
1. 动火离人参数配置:
- 2.1 检测人数:检测当前工作区域内人员数量,当人员数量低于预设值则判定为异常
- 2.2 检测时间:当工作区域地狱检测人数时,持续达到预设时间后报警
- 2.3 温度阈值:测温系统检测到当前环境温度超过预设值则为异常,与2.1\2.2同步触发时报警
- 2.4 置信度:该值为模型识别到目标的准确值,70是值70%,一般预设为70不可空填。当识别到的目标置信度大于等于70时则判定当前工作区域内识别到目标(识别到工作人员)
- 2.5 报警间隔时间:当触发一次报警后,会进入到报警冷却时间,这期间如果再次触发上述报警逻辑后不会发送告警信息。该值根据当前管理标准设置,可设置为0,那么就不会有冷却时间,只要符合报警逻辑会立即上报。
![alt text](image-8.png)
1. 火焰检测检测区域配置:
- 1.1 蓝色方框为工作目标区域框选
- 1.2 根据现场摄像头设置工作区域之后点击保存
![alt text](image-10.png)
2. 火焰检测参数设置:
- 默认值已设置非必要请勿修改
- 4.1 检测时间:当识别到目标持续时间达到预设值则报警
- 4.2 置信度:与3.2.2.4含义一致
![alt text](image-9.png)
---
# 4. 联动管理
- 设备联动指io口连接对应的蜂鸣器与音柱,有需要请联系工程师
---
# 5. 数据转发
- 对接第三方管理平台,传输视频数据,详细配置请看完整版文档
![alt text](image-11.png)
# 6. 告警信息
- 输出检测到的告警信息
## 6.1 查看告警列表
进入“告警信息”页面,可按设备进行查询。
## 6.2 告警字段说明
| 字段 | 说明 |
| --- | --- |
| 相机名称 | 对应相机管理中创建的ID |
| 信息 | 当前事件触发的信息记录 |
| 创建时间 | 事件触发的具体日期 |
| 操作-查看视频 | 查看识别到目标的视频 |
| 操作-删除 | 清理无效数据 |
---
# 7. 报警转发
- 对接第三方管理平台,发送第一时间触发时间的报警信息,详细配置请看完整版文档
---
## 附录 A 技术支持联系方式
| 项目 | 内容 |
| --- | --- |
| 技术支持电话 | `14752696604` |
| 技术支持邮箱 | `1935881371@qq.com` |
| 服务时间 | `10:00-22:00` |
---
## 附录 B:版本修订记录
| 文档版本 | 修订日期 | 修订内容 | 修订人 |
| --- | --- | --- | --- |
| V1.0 | `2026/5/5` | 首次编制 | `Smos_Guo` |
+425
View File
@@ -0,0 +1,425 @@
# 管理平台客户操作手册
- 操作前请确认当前账号具有对应功能权限。。
- 页面布局、菜单名称若与本文档不同,请以实际部署版本为准。
---
# 1. 快速开始
## 1.1 连接配置
- 连接电源线确保电源PWR正常亮起
![alt pwr](images/IMG_0122.png)
使用平台前,请确认网线已经正确连接:
- lan1连接windows主机|lan0连接公司内部网口
![alt text](images/IMG_0120.png)
## 1.2 网络配置
- 设置管理机网络(windows主机)
1. win+s 搜索栏 打开控制面板
![alt text](images/1.png)
2. 打开网络和Internet
![alt text](images/2.png)
3. 打开网络共享中心
![alt text](images/3.png)
4. 找到win主机连接设备lan1网口对应网卡,右键属性设置
![alt text](images/4.png)
5. 打开属性后勾选IPv4选项并且左键点击
![alt text](images/5.png)
6. 打开属性面板后设置如图所示ip|子网掩码,更改成功之后点击确定
![alt text](images/6.png)
7. 更改好IPv4设置之后点击确定保存
![alt text](images/7.png)
8. 打开cmd控制窗口
![alt text](images/8.png)
9. 测试设备是否连通
代码:ping 10.20.1.1
![alt text](images/9.png)
10. 打开浏览器(edge/Chrome)访问 10.20.1.1
![alt text](images/10.png)
## 1.2 访问平台
1. 打开浏览器。
2. 在地址栏输入平台地址:`10.20.1.1`
3. 进入平台登录页面。
![alt text](images/image.png)
4. 输入用户名和密码。(默认:admin\admin
5. 单击“登录”进入平台首页。
# 2. 平台内部网络设置
## 2.1 网络修改
- 默认IP 地址 | 平台或设备在局域网中的地址 | `10.20.1.1` |
- 首次通过 | `10.20.1.1` | 进入平台内部设置lan0(内部局域网)
- 修改之后通过内网网址访问即可
- 修改lan02.2)与修改WIFI(2.3)可选其一设置,但是ip地址不可以一样
## 2.2 修改lan0
1. 开始修改之前还要做一项准备工作,win+s搜索cmd打开命令行,输入 `ipconfig`
![alt text](image-3.png)
查询到当前网络处于什么网段
2. 进入平台前端页面,根据左侧选项栏选择网络设置
![alt text](image.png)
3. 点击编辑开始编辑lan1网络(如图所示),ip的第三位也就是`192.168.1.123`中的x.x.1.x应该填写操作步骤中的1,获取到的第三位网段
![alt text](image-4.png)
确认无误之后选择保存
## 2.3 修改WIFI
- 点击编辑之后选择当前内部网络的WiFi,然后输入密码保存即可
![alt text](image-5.png)
---
# 3. 摄像机管理
平台内部已经设置成功默认摄像头,无特殊需要请勿自己添加摄像头
## 3.1 摄像机编辑
1. 选择编辑
![alt text](image-6.png)
2. 仅需要添加rtsp地址`rtsp://usr(摄像头账户):摄像头(密码)@ 摄像头IP地址:554(摄像头默认rtsp端口,未修改无需改动)`
3. 确认修改无误之后点击保存
## 3.2 摄像机配置
1. 动火离人区域检测配置:
- 1.1 蓝色方框为工作目标区域框选
- 1.2 根据现场摄像头设置工作区域之后点击保存
- 1.3 新增区域可
![alt text](image-7.png)
2. 动火离人参数配置:
- 2.1 检测人数:检测当前工作区域内人员数量,当人员数量低于预设值则判定为异常
- 2.2 检测时间:当工作区域地狱检测人数时,持续达到预设时间后报警
- 2.3 温度阈值:测温系统检测到当前环境温度超过预设值则为异常,与2.1\2.2同步触发时报警
- 2.4 置信度:该值为模型识别到目标的准确值,70是值70%,一般预设为70不可空填。当识别到的目标置信度大于等于70时则判定当前工作区域内识别到目标(识别到工作人员)
- 2.5 报警间隔时间:当触发一次报警后,会进入到报警冷却时间,这期间如果再次触发上述报警逻辑后不会发送告警信息。该值根据当前管理标准设置,可设置为0,那么就不会有冷却时间,只要符合报警逻辑会立即上报。
![alt text](image-8.png)
3. 火焰检测检测区域配置:
- 与3.2.1配置方式一致
4. 火焰检测参数设置:
- 4.1 检测时间:当识别到目标持续时间达到预设值则报警
- 4.2 置信度:与3.2.2.4含义一致
![alt text](image-9.png)
---
# 4. 联动管理
## 4.1 功能说明
---
# 6. 数据转发
## 6.1 功能说明
数据转发用于将平台产生的设备状态、业务事件或统计数据发送至第三方系统。
## 6.2 新增转发配置
1. 进入“数据转发”。
2. 单击“新增”。
3. 填写任务名称和接收地址。
4. 选择转发协议。
5. 选择需要转发的数据类型。
6. 配置认证信息、请求头或其他必要参数。
7. 设置超时时间和重试次数。
8. 单击“测试”。
9. 测试成功后保存并启用。
## 6.3 常用参数
| 参数 | 说明 |
| --- | --- |
| 接收地址 | 第三方系统提供的服务地址 |
| 请求方式 | 如 HTTP POST,按双方接口约定填写 |
| 数据格式 | 通常为 JSON,按接口文档确定 |
| 认证方式 | 无认证、Token、用户名密码或签名认证 |
| 超时时间 | 单次请求最大等待时间 |
| 重试次数 | 发送失败后的自动重试次数 |
## 6.4 查看转发状态
在任务列表或转发日志中查看最近发送时间、发送结果、响应状态及失败原因。
## 6.5 转发失败排查
- 检查接收地址、端口和路径是否正确。
- 检查平台与第三方服务器是否网络连通。
- 检查认证信息是否有效。
- 检查请求数据是否符合接口约定。
- 检查第三方服务是否正常运行。
- 检查防火墙、访问白名单和 HTTPS 证书。
- 查看转发日志中的响应码和错误信息。
---
# 7. 告警信息
## 7.1 查看告警列表
进入“告警信息”页面,可按时间、设备、告警类型、处理状态和告警级别进行查询。
## 7.2 告警字段说明
| 字段 | 说明 |
| --- | --- |
| 告警时间 | 告警发生时间 |
| 告警设备 | 产生告警的摄像机或设备 |
| 告警类型 | 具体事件类型 |
| 告警级别 | 告警严重程度 |
| 处理状态 | 未处理、处理中或已处理 |
| 告警图片/视频 | 事件相关的图片或录像 |
| 处理人员 | 当前或最终处理人 |
| 处理说明 | 告警处置过程和结果 |
## 7.3 查看告警详情
1. 在列表中找到目标告警。
2. 单击告警记录或“详情”。
3. 查看告警时间、位置、设备及相关图片或视频。
4. 根据现场情况进行确认和处置。
## 7.4 处理告警
1. 打开告警详情。
2. 确认告警真实性。
3. 按现场流程完成处置。
4. 填写处理说明。
5. 上传相关凭证(如平台支持)。
6. 将状态更新为“已处理”。
> **注意:** 不要在未核实现场情况时随意关闭或删除告警。
## 7.5 查询和导出
1. 设置查询时间范围。
2. 选择设备、类型或处理状态。
3. 单击“查询”。
4. 确认结果后单击“导出”。
5. 检查导出文件的内容和时间范围。
---
# 8. 报警转发
## 8.1 功能说明
报警转发用于将告警事件、告警图片或告警视频通知第三方平台。它与普通数据转发的主要区别是转发对象以告警事件为主。
## 8.2 新增报警转发任务
1. 进入“报警转发”。
2. 单击“新增”。
3. 填写任务名称。
4. 配置第三方接收地址和认证信息。
5. 选择需要转发的告警类型和告警级别。
6. 选择是否包含图片、视频或设备信息。
7. 设置生效时间、超时时间及重试策略。
8. 单击“测试”。
9. 测试成功后保存并启用。
## 8.3 重发告警
如平台支持手动重发:
1. 进入报警转发记录。
2. 筛选状态为“失败”的记录。
3. 查看失败原因并修正配置。
4. 选择目标记录。
5. 单击“重新发送”。
6. 确认发送结果。
## 8.4 重复报警处理
如果第三方平台收到重复告警,请检查:
- 平台是否因超时自动重试。
- 第三方是否已接收但未及时返回成功响应。
- 是否存在多个相同的转发任务。
- 第三方是否按照告警唯一标识进行去重。
---
# 9. 系统设置
## 9.1 基本信息
进入“系统设置”,可按实际权限查看或修改系统名称、站点名称、语言、时区和时间格式等信息。
## 9.2 日期、时间和时区
1. 进入系统时间设置。
2. 选择正确时区。
3. 选择自动同步或手动设置时间。
4. 如使用自动同步,填写 NTP 服务器地址。
5. 保存后检查平台时间和摄像机时间是否一致。
时间不一致可能导致告警、录像及转发记录无法准确对应。
## 9.3 存储设置
查看存储容量、已用空间、剩余空间及数据保留周期。修改保留周期前,应评估存储空间和客户的数据留存要求。
## 9.4 日志管理
系统日志通常包括:
- 登录日志;
- 操作日志;
- 设备日志;
- 告警日志;
- 转发日志;
- 系统异常日志。
出现问题时,应记录故障时间,并导出该时间前后的相关日志。
## 9.5 配置备份与恢复
如平台支持配置备份:
1. 进入“备份与恢复”。
2. 单击“导出配置”或“创建备份”。
3. 将备份文件保存到安全位置。
4. 记录备份对应的平台版本和日期。
恢复配置前,请确认备份文件来源可靠、版本兼容,并安排业务维护时间。
## 9.6 重启系统
重启前请确认:
- 当前无重要业务操作。
- 无正在进行的升级、导入、导出或录像处理任务。
- 已通知相关使用人员。
- 已保存未提交的配置。
重启后检查平台访问、摄像机状态、联动规则和转发任务是否正常。
---
# 10. 其他问题解答
## 10.1 无法打开平台页面
1. 检查访问地址是否正确。
2. 检查管理电脑网络是否正常。
3. 检查平台服务器是否启动。
4. 检查平台端口是否被防火墙拦截。
5. 尝试清除浏览器缓存或更换浏览器。
## 10.2 登录后看不到某个菜单
通常是账号权限不足。请联系管理员检查账号角色、菜单权限和数据权限。
## 10.3 页面显示异常或按钮无响应
- 刷新页面后重试。
- 清除浏览器缓存。
- 使用推荐浏览器的最新版本。
- 检查浏览器是否禁用了脚本或弹窗。
- 记录发生时间并保存页面截图和浏览器控制台信息。
## 10.4 实时视频无法播放
- 检查摄像机是否在线。
- 检查摄像机账号和密码。
- 检查视频流地址和端口。
- 检查浏览器是否支持当前播放方式。
- 检查网络带宽及防火墙配置。
## 10.5 告警没有产生
- 检查摄像机和检测服务是否在线。
- 检查相应检测或告警功能是否启用。
- 检查规则生效时间。
- 检查告警阈值和区域配置。
- 检查平台时间是否正确。
## 10.6 告警产生但未联动
- 检查联动规则是否启用。
- 检查告警类型是否与触发条件一致。
- 检查规则当前是否处于生效时间。
- 检查目标设备是否在线。
- 查看联动执行日志。
## 10.7 告警没有转发到第三方平台
- 检查报警转发任务是否启用。
- 检查告警类型是否在转发范围内。
- 检查接收地址、认证信息和网络。
- 查看转发日志和第三方服务日志。
- 检查第三方是否返回正确的成功响应。
## 10.8 如何提交技术支持请求
提交问题时,请尽量提供:
- 客户名称和项目名称;
- 平台版本;
- 问题发生时间;
- 问题现象和复现步骤;
- 相关设备名称及 IP 地址;
- 页面截图或录屏;
- 对应时间段的系统日志;
- 已尝试的处理方法。
## 10.9 技术支持联系方式
| 项目 | 内容 |
| --- | --- |
| 技术支持电话 | `待填写` |
| 技术支持邮箱 | `待填写` |
| 服务时间 | `待填写` |
| 紧急联系人 | `待填写` |
---
## 附录 A:上线前检查清单
- [ ] 平台地址可正常访问。
- [ ] 管理员和普通用户账号可正常登录。
- [ ] 系统时间及时区正确。
- [ ] 网络参数正确且无 IP 冲突。
- [ ] 所有摄像机在线且实时画面正常。
- [ ] 联动规则已启用并通过测试。
- [ ] 数据转发已通过测试。
- [ ] 报警转发已通过测试。
- [ ] 告警查询、处理和导出正常。
- [ ] 配置文件已完成备份。
- [ ] 技术支持联系方式已填写。
## 附录 B:截图命名建议
建议采用“章节编号-功能-序号”的命名方式,例如:
```text
images/
├─ 01-登录页面-01.png
├─ 03-网络设置-01.png
├─ 04-添加摄像机-01.png
├─ 05-新增联动规则-01.png
└─ 07-告警详情-01.png
```
## 附录 C:版本修订记录
| 文档版本 | 修订日期 | 修订内容 | 修订人 |
| --- | --- | --- | --- |
| V1.0 | `待填写` | 首次编制 | `待填写` |
+383 -86
View File
@@ -1,4 +1,4 @@
package video_server
package video_server
import (
"fmt"
@@ -14,16 +14,45 @@ import (
"FireLeave_tool/logger"
)
// MergeVideo 合并视频文件 - 完全适配 mp4_merge --out 模式
func MergeVideo(deviceUUID string, alarmTime int64) (string, error) {
videoDir := fmt.Sprintf("/usr/data/camera/%s", deviceUUID)
if _, err := os.Stat(videoDir); os.IsNotExist(err) {
return "", fmt.Errorf("video directory not exist: %s", videoDir)
}
// MergeVideo merges nearby raw video clips with ffmpeg.
func MergeVideo(deviceUUID string, alarmTime int64) (mergedPath string, err error) {
return mergeVideo(deviceUUID, deviceUUID, alarmTime)
}
// === 1. 时间对齐到 10 秒 ===
// MergeVideoFromDirectory reads clips from one device directory while naming
// the uploaded file with the real device UUID.
func MergeVideoFromDirectory(videoDirectoryUUID, uploadDeviceUUID string, alarmTime int64) (mergedPath string, err error) {
return mergeVideo(videoDirectoryUUID, uploadDeviceUUID, alarmTime)
}
func mergeVideo(deviceUUID, uploadDeviceUUID string, alarmTime int64) (mergedPath string, err error) {
startTime := time.Now()
logger.Logger.Printf("[%.3f] Start video merge (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime)
// Guard the merge path so one bad video cannot crash the process.
defer func() {
if recovered := recover(); recovered != nil {
logger.Logger.Printf("[%.3f] Merge video panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), recovered, deviceUUID)
mergedPath = ""
err = fmt.Errorf("merge video panic: %v", recovered)
}
}()
videoDir := fmt.Sprintf("/usr/data/camera/%s", deviceUUID)
logger.Logger.Printf("[%.3f] Check video directory: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID)
if _, err := os.Stat(videoDir); os.IsNotExist(err) {
logger.Logger.Printf("[%.3f] Video directory does not exist: %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] Check video directory failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID)
return "", fmt.Errorf("check video directory failed: %v", err)
}
logger.Logger.Printf("[%.3f] Video directory is ready: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID)
logger.Logger.Printf("[%.3f] Align alarm time to 10-second boundary (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime)
alarmTimeAligned := alignTo10Seconds(alarmTime)
alarmTimeStr := time.Unix(alarmTimeAligned, 0).Format("20060102150405")
logger.Logger.Printf("[%.3f] Time aligned: alarmTime=%d aligned=%d (DeviceUUID=%s)", time.Since(startTime).Seconds(), alarmTime, alarmTimeAligned, deviceUUID)
beforeTime := alarmTimeAligned - 10
afterTime := alarmTimeAligned + 10
@@ -31,130 +60,278 @@ func MergeVideo(deviceUUID string, alarmTime int64) (string, error) {
beforeStr := time.Unix(beforeTime, 0).Format("20060102150405")
afterStr := time.Unix(afterTime, 0).Format("20060102150405")
paths := map[string]string{
beforeStr: filepath.Join(videoDir, beforeStr+".mp4"),
alarmTimeStr: filepath.Join(videoDir, alarmTimeStr+".mp4"),
afterStr: filepath.Join(videoDir, afterStr+".mp4"),
}
targetNames := []string{beforeStr, alarmTimeStr, afterStr}
videoFiles := []string{}
for name, path := range paths {
if _, err := os.Stat(path); err == nil {
logger.Logger.Printf("[%.3f] Search expected video files (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID)
missingVideos := []string{}
for _, name := range targetNames {
if path, ok := findVideoFileForTimestamp(videoDir, name); ok {
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(), filepath.Base(path), deviceUUID)
} else {
logger.Logger.Printf("Missing video: %s", name+".mp4")
missingVideos = append(missingVideos, name+".mp4/.h264")
}
}
if len(missingVideos) > 0 {
logger.Logger.Printf("[%.3f] Missing expected videos: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), strings.Join(missingVideos, ","), deviceUUID)
}
// === 2. 补充替代文件 ===
logger.Logger.Printf("[%.3f] Found %d expected video files, searching fallbacks if needed (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] Less than 3 expected videos found, scan directory for fallback files (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID)
allFiles, err := os.ReadDir(videoDir)
if err != nil {
logger.Logger.Printf("[%.3f] Read video directory failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID)
} else {
logger.Logger.Printf("[%.3f] Directory contains %d files (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(allFiles), deviceUUID)
var candidates []struct {
path string
ts int64
}
for _, f := range allFiles {
if !f.IsDir() && isSupportedRawVideoFile(f.Name()) {
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] Found %d fallback candidate files (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)
}
}
}
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] Collected %d video files for merge (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID)
if len(videoFiles) < 2 {
logger.Logger.Printf("[%.3f] Not enough video files, need at least 2 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID)
return "", fmt.Errorf("insufficient video files: %d", len(videoFiles))
}
// === 3. 排序 ===
logger.Logger.Printf("[%.3f] Sort video files by timestamp (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID)
sort.Slice(videoFiles, func(i, j int) bool {
return getTimestampFromPath(videoFiles[i]) < getTimestampFromPath(videoFiles[j])
left, leftErr := getTimestampFromPath(videoFiles[i])
right, rightErr := getTimestampFromPath(videoFiles[j])
if leftErr != nil || rightErr != nil {
return videoFiles[i] < videoFiles[j]
}
return left < right
})
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] Output temp file: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID)
// 清理旧文件(防止冲突)
logger.Logger.Printf("[%.3f] Remove old output file if present (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] Old output file removed: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID)
}
// === 5. 调用 mp4_merge,显式 --out ===
args := append(videoFiles, "--out", joinedPath)
cmd := exec.Command("mp4_merge", args...)
args := []string{"-hide_banner", "-loglevel", "error", "-y"}
if allH264Files(videoFiles) {
rawJoinedPath := filepath.Join(videoDir, fmt.Sprintf("%s_joined.h264", alarmTimeStr))
logger.Logger.Printf("[%.3f] Concatenate H264 files in timestamp order: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), rawJoinedPath, deviceUUID)
if err := concatenateFiles(rawJoinedPath, videoFiles); err != nil {
logger.Logger.Printf("[%.3f] Concatenate H264 files failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID)
return "", fmt.Errorf("concatenate H264 files failed: %v", err)
}
defer os.Remove(rawJoinedPath)
args = append(args,
"-fflags", "+genpts",
"-f", "h264",
"-i", rawJoinedPath,
"-c:v", "copy",
"-movflags", "+faststart",
joinedPath,
)
} else {
concatListPath := filepath.Join(videoDir, fmt.Sprintf("%s_ffmpeg_concat.txt", alarmTimeStr))
if err := writeFFmpegConcatList(concatListPath, videoFiles); err != nil {
logger.Logger.Printf("[%.3f] Create ffmpeg concat list failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID)
return "", fmt.Errorf("create ffmpeg concat list failed: %v", err)
}
defer os.Remove(concatListPath)
args = append(args,
"-f", "concat",
"-safe", "0",
"-i", concatListPath,
"-c", "copy",
"-movflags", "+faststart",
joinedPath,
)
}
cmd := exec.Command("ffmpeg", args...)
cmd.Env = ffmpegCleanEnv()
cmd.Stdout = logger.Logger.Writer()
cmd.Stderr = logger.Logger.Writer()
logger.Logger.Printf("[%.3f] Run merge command: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), cmd.Args, deviceUUID)
mergeStartTime := time.Now()
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("mp4_merge failed: %v", err)
logger.Logger.Printf("[%.3f] Merge command failed (elapsed=%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), err, deviceUUID)
return "", fmt.Errorf("ffmpeg merge failed: %v", err)
}
logger.Logger.Printf("[%.3f] Merge command succeeded (elapsed=%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), deviceUUID)
// === 6. 验证输出文件存在 ===
logger.Logger.Printf("[%.3f] Verify merged file: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID)
if _, err := os.Stat(joinedPath); err != nil {
logger.Logger.Printf("[%.3f] Output file not created: %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] Stat merged file failed: %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] Move merged file to /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)
finalName := fmt.Sprintf("%s-%s.MP4", uploadDeviceUUID, timestampPart)
finalPath := filepath.Join("/data/upload", finalName)
logger.Logger.Printf("[%.3f] Final upload path: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), finalPath, deviceUUID)
logger.Logger.Printf("[%.3f] Ensure /data/upload exists (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID)
if err := os.MkdirAll("/data/upload", 0755); err != nil {
logger.Logger.Printf("[%.3f] Create /data/upload failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID)
return "", fmt.Errorf("create /data/upload failed: %v", err)
}
// 跨分区复制 + 删除
logger.Logger.Printf("[%.3f] Copy merged file: %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] Copy merged file failed (elapsed=%.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] Copy merged file succeeded (elapsed=%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), deviceUUID)
logger.Logger.Printf("[%.3f] Delete temp merged file: %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] Temp merged file deleted (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, total=%.3fs)", time.Since(startTime).Seconds(), finalPath, deviceUUID, time.Since(startTime).Seconds())
return finalPath, nil
}
// containsTimestamp 检查是否已有该时间戳
// containsTimestamp reports whether files include the given timestamp.mp(files []string, ts int64) bool {
func containsTimestamp(files []string, ts int64) bool {
for _, f := range files {
if getTimestampFromPath(f) == ts {
timestamp, err := getTimestampFromPath(f)
if err == nil && timestamp == ts {
return true
}
}
return false
}
// findClosest 查找最接近的时间戳文件
func findVideoFileForTimestamp(videoDir, timestamp string) (string, bool) {
for _, ext := range []string{".mp4", ".h264"} {
path := filepath.Join(videoDir, timestamp+ext)
if _, err := os.Stat(path); err == nil {
return path, true
}
}
return "", false
}
func isSupportedRawVideoFile(name string) bool {
if strings.Contains(name, "_joined") {
return false
}
ext := strings.ToLower(filepath.Ext(name))
return ext == ".mp4" || ext == ".h264"
}
func writeFFmpegConcatList(path string, videoFiles []string) error {
var builder strings.Builder
for _, videoFile := range videoFiles {
escaped := strings.ReplaceAll(videoFile, "'", "'\\''")
builder.WriteString("file '")
builder.WriteString(escaped)
builder.WriteString("'\n")
}
return os.WriteFile(path, []byte(builder.String()), 0644)
}
func allH264Files(videoFiles []string) bool {
for _, videoFile := range videoFiles {
if !strings.EqualFold(filepath.Ext(videoFile), ".h264") {
return false
}
}
return len(videoFiles) > 0
}
func concatenateFiles(outputPath string, inputPaths []string) (err error) {
output, err := os.Create(outputPath)
if err != nil {
return err
}
defer func() {
if closeErr := output.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(outputPath)
}
}()
for _, inputPath := range inputPaths {
input, openErr := os.Open(inputPath)
if openErr != nil {
return fmt.Errorf("open %s: %w", inputPath, openErr)
}
_, copyErr := io.Copy(output, input)
closeErr := input.Close()
if copyErr != nil {
return fmt.Errorf("append %s: %w", inputPath, copyErr)
}
if closeErr != nil {
return fmt.Errorf("close %s: %w", inputPath, closeErr)
}
}
return output.Sync()
}
func ffmpegCleanEnv() []string {
env := os.Environ()
cleaned := make([]string, 0, len(env))
for _, item := range env {
if strings.HasPrefix(item, "LD_LIBRARY_PATH=") {
continue
}
cleaned = append(cleaned, item)
}
return cleaned
}
func findClosest(candidates []struct {
path string
ts int64
@@ -181,8 +358,10 @@ func abs(x int64) int64 {
return x
}
// copyFile 支持跨分区复制
func copyFile(src, dst string) error {
startTime := time.Now()
logger.Logger.Printf("[%.3f] Start file copy: %s -> %s", time.Since(startTime).Seconds(), src, dst)
in, err := os.Open(src)
if err != nil {
return err
@@ -195,55 +374,166 @@ func copyFile(src, dst string) error {
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
deadline := time.Now().Add(30 * time.Second)
buf := make([]byte, 1024*1024)
var written int64
for {
if time.Now().After(deadline) {
_ = out.Close()
_ = os.Remove(dst)
logger.Logger.Printf("[%.3f] Copy timed out, removed incomplete file: %s", time.Since(startTime).Seconds(), dst)
return fmt.Errorf("copy file timeout after %d bytes", written)
}
nr, readErr := in.Read(buf)
if nr > 0 {
nw, writeErr := out.Write(buf[:nr])
written += int64(nw)
if writeErr != nil {
_ = os.Remove(dst)
return writeErr
}
if nw != nr {
_ = os.Remove(dst)
return io.ErrShortWrite
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
_ = os.Remove(dst)
return readErr
}
}
if err := out.Sync(); err != nil {
_ = os.Remove(dst)
return err
}
return out.Sync()
logger.Logger.Printf("[%.3f] file copy completed: %d bytes", time.Since(startTime).Seconds(), written)
return nil
}
// alignTo10Seconds 将时间对齐到最近的10秒间隔
/*
func copyFile(src, dst string) error {
startTime := time.Now()
logger.Logger.Printf("[%.3f] Start file copy: %s -> %s", time.Since(startTime).Seconds(), src, dst)
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
deadline := time.Now().Add(30 * time.Second)
buf := make([]byte, 1024*1024)
var written int64
for {
if time.Now().After(deadline) {
_ = out.Close()
_ = os.Remove(dst)
logger.Logger.Printf("[%.3f] Copy timed out, removed incomplete file: %s", time.Since(startTime).Seconds(), dst)
return fmt.Errorf("copy file timeout after %d bytes", written)
}
nr, readErr := in.Read(buf)
if nr > 0 {
nw, writeErr := out.Write(buf[:nr])
written += int64(nw)
if writeErr != nil {
_ = os.Remove(dst)
return writeErr
}
if nw != nr {
_ = os.Remove(dst)
return io.ErrShortWrite
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
_ = os.Remove(dst)
return readErr
}
}
if err := out.Sync(); err != nil {
_ = os.Remove(dst)
return err
}
logger.Logger.Printf("[%.3f] file copy completed: %d bytes", time.Since(startTime).Seconds(), written)
return nil
}
// alignTo10Seconds 鐏忓棙妞傞梻鏉戭嚠姒绘劕鍩岄張鈧潻鎴犳畱10缁夋帡妫块梾?*/
func alignTo10Seconds(timestamp int64) int64 {
t := time.Unix(timestamp, 0)
// 获取秒数并对齐到10秒
seconds := t.Second()
alignedSeconds := (seconds / 10) * 10
// 创建新的时间
alignedTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), alignedSeconds, 0, t.Location())
return alignedTime.Unix()
}
// parseSimpleTimestamp 解析简单的时间戳文件名
func parseSimpleTimestamp(filename string) (int64, error) {
nameWithoutExt := strings.TrimSuffix(filename, filepath.Ext(filename))
if len(nameWithoutExt) != 14 {
return 0, fmt.Errorf("invalid timestamp filename length: %s", filename)
}
year, err := strconv.Atoi(nameWithoutExt[0:4])
// 解析时间戳
year, _ := strconv.Atoi(nameWithoutExt[0:4])
month, _ := strconv.Atoi(nameWithoutExt[4:6])
day, _ := strconv.Atoi(nameWithoutExt[6:8])
hour, _ := strconv.Atoi(nameWithoutExt[8:10])
minute, _ := strconv.Atoi(nameWithoutExt[10:12])
second, _ := strconv.Atoi(nameWithoutExt[12:14])
// 鐟欙絾鐎介弮鍫曟?閹? year, err := strconv.Atoi(nameWithoutExt[0:4])
if err != nil {
return 0, fmt.Errorf("invalid year in filename %s: %w", filename, err)
}
month, err := strconv.Atoi(nameWithoutExt[4:6])
if err != nil {
return 0, fmt.Errorf("invalid month in filename %s: %w", filename, err)
}
day, err := strconv.Atoi(nameWithoutExt[6:8])
if err != nil {
return 0, fmt.Errorf("invalid day in filename %s: %w", filename, err)
}
hour, err := strconv.Atoi(nameWithoutExt[8:10])
if err != nil {
return 0, fmt.Errorf("invalid hour in filename %s: %w", filename, err)
}
minute, err := strconv.Atoi(nameWithoutExt[10:12])
if err != nil {
return 0, fmt.Errorf("invalid minute in filename %s: %w", filename, err)
}
second, err := strconv.Atoi(nameWithoutExt[12:14])
if err != nil {
return 0, fmt.Errorf("invalid second in filename %s: %w", filename, err)
}
t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.Local)
return t.Unix(), nil
}
// getTimestampFromPath 从文件路径中提取时间戳
func getTimestampFromPath(filePath string) int64 {
// getTimestampFromPath
func getTimestampFromPath(filePath string) (int64, error) {
filename := filepath.Base(filePath)
timestamp, err := parseSimpleTimestamp(filename)
if err != nil {
return 0
return 0, err
}
return timestamp
return timestamp, nil
}
// contains 检查切片是否包含某个元素
// contains
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
@@ -253,7 +543,7 @@ func contains(slice []string, item string) bool {
return false
}
// CleanupOldVideos 清理旧的合并视频文件,保留最近的 10 个
// CleanupOldVideos
func CleanupOldVideos(deviceUUID string) {
const maxFiles = 10
videoDir := "/data/upload"
@@ -292,9 +582,16 @@ func CleanupOldVideos(deviceUUID string) {
}
}
// CleanupOldRawVideos 清理推理程序生成的原始10s视频,保留最近60条
// CleanupOldRawVideos
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))
// 濞撳懐鎮婇悘顐ゅ姍濡偓濞村娲拌ぐ? cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check"))
cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check"))
}
func cleanupRawVideosDirectory(rawDir string) {
if _, err := os.Stat(rawDir); os.IsNotExist(err) {
return
}
@@ -313,7 +610,7 @@ func CleanupOldRawVideos(deviceUUID string) {
}
if len(videoFiles) <= 60 {
return // 不超过60条,不清理
return // 娑撳秷绉存潻?0閺夆槄绱濇稉宥嗙閻? }
}
sort.Slice(videoFiles, func(i, j int) bool {
+53 -2
View File
@@ -16,36 +16,64 @@ import (
// UploadToNginx 上传视频到 Nginx 服务器,返回 URL
func UploadToNginx(filePath string, eventParams map[string]any, uploadURL string) (string, error) {
logger.Logger.Printf("开始上传视频到 Nginx: %s → %s", filePath, uploadURL)
fileName := filepath.Base(filePath)
// 打开文件
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)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
// 创建表单文件字段
logger.Logger.Printf("创建表单文件字段: %s", fileName)
part, err := writer.CreateFormFile("file", fileName)
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("[Nginx Multipart Detail] URL=%s file_field=file event_field=event filename=%s event_json=%s", uploadURL, fileName, 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 +81,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
}
+60
View File
@@ -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 推理程序<br/>./yolov5 -m model]
E --> F2[启动 fire_check 推理程序<br/>./yolov5 -m fire_check -R]
E --> F3[启动测温程序<br/>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{无人 + 超温<br/>是否同时成立?}
J2 --> K
K -->|否| K0[重置持续计时]
K -->|是| L[开始累计持续时间]
L --> M{持续时间 >= detection_time?}
M -->|否| L
M -->|是| N{是否在 alarm_time 冷却期?}
N -->|是| N0[不上报,继续监测]
N -->|否| O[触发 fire_leave 动火离人报警]
G2 --> P{检测到火焰<br/>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