Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa60ca1cad | |||
| d3d60ad042 | |||
| 0842f555fd | |||
| 79490bd27b | |||
| 0f7cf62abd | |||
| 95826247c7 | |||
| 91b60a82d3 | |||
| 13c4d03055 | |||
| 6056b1fdc5 | |||
| cd9b199e2c |
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 .
|
||||
```
|
||||
@@ -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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
var payload []byte
|
||||
switch method {
|
||||
case "register":
|
||||
registerParams, ok := params.(RegisterParams)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid register params")
|
||||
}
|
||||
if params != nil {
|
||||
paramsData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Parameter serialization failed: %v", err)
|
||||
payload = encodeServiceRequestRegister(id, registerParams.ServiceID)
|
||||
case "subscribe":
|
||||
subscribeParams, ok := params.(SubscribeParams)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid subscribe params")
|
||||
}
|
||||
message.Params = paramsData
|
||||
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)
|
||||
switch frameType {
|
||||
case wsFrameReply:
|
||||
reply, err := decodeServiceReply(payload)
|
||||
if err != nil {
|
||||
logger.Logger.Printf("WebSocket reply parsing failed: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if message.ID > 0 {
|
||||
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()
|
||||
case wsFrameCast:
|
||||
if err := c.handleServiceCast(payload); err != nil {
|
||||
logger.Logger.Printf("WebSocket cast handling failed: %v", err)
|
||||
}
|
||||
|
||||
if message.Method == "publish" {
|
||||
c.handlePublishMessage(message)
|
||||
default:
|
||||
logger.Logger.Printf("Ignoring unknown WebSocket frame type: 0x%02x", frameType)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,40 +515,47 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
// Check if DeviceUUID is valid
|
||||
if deviceData.DeviceUUID == "" {
|
||||
logger.Logger.Printf("Invalid DeviceData: DeviceUUID is empty, ignoring")
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
wrapped, err := wrapTopicEvent(topic, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callback(wrapped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
func wrapTopicEvent(topic string, content []byte) ([]byte, error) {
|
||||
msg := map[string]interface{}{
|
||||
"topic": topic,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if json.Valid(content) {
|
||||
msg["content"] = json.RawMessage(content)
|
||||
} else {
|
||||
msg["content"] = string(content)
|
||||
}
|
||||
return json.Marshal(msg)
|
||||
}
|
||||
|
||||
// heartbeatLoop heartbeat loop - fixed version
|
||||
@@ -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}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func logServiceReply(reply *ServiceReply) {
|
||||
if reply == nil {
|
||||
logger.Logger.Printf("[WebSocket Received Reply] <nil>")
|
||||
return
|
||||
}
|
||||
errorJSON := "null"
|
||||
if reply.Error != nil {
|
||||
if data, err := json.Marshal(reply.Error); err == nil {
|
||||
errorJSON = string(data)
|
||||
} 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
|
||||
errorJSON = fmt.Sprintf(`{"marshal_error":%q}`, err.Error())
|
||||
}
|
||||
}
|
||||
// If not exists, add to list
|
||||
if !found {
|
||||
existingConfigs = append(existingConfigs, deviceData)
|
||||
}
|
||||
}
|
||||
|
||||
return SaveServiceConfig(existingConfigs)
|
||||
logger.Logger.Printf("[WebSocket Received Reply] packet_id=%d result=%s error=%s", reply.PacketID, string(reply.Result), errorJSON)
|
||||
}
|
||||
|
||||
// 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)
|
||||
func decodeServiceReplyError(payload []byte) *WSError {
|
||||
wsErr := &WSError{}
|
||||
for pos := 0; pos < len(payload); {
|
||||
key, err := consumeVarint(payload, &pos)
|
||||
if err != nil {
|
||||
logger.Logger.Printf("Failed to read service config file: %v", err)
|
||||
return nil, err
|
||||
return wsErr
|
||||
}
|
||||
|
||||
// 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")
|
||||
fieldNumber := int(key >> 3)
|
||||
wireType := int(key & 0x7)
|
||||
switch fieldNumber {
|
||||
case 1:
|
||||
code, err := consumeVarint(payload, &pos)
|
||||
if err != nil {
|
||||
return wsErr
|
||||
}
|
||||
|
||||
// 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
|
||||
wsErr.Code = int(int32(code))
|
||||
case 2:
|
||||
msg, err := consumeBytes(payload, &pos)
|
||||
if err != nil {
|
||||
return wsErr
|
||||
}
|
||||
|
||||
// Filter out configurations with empty DeviceUUID
|
||||
var validDeviceDataList []DeviceData
|
||||
for _, deviceData := range deviceDataList {
|
||||
if deviceData.DeviceUUID != "" {
|
||||
validDeviceDataList = append(validDeviceDataList, deviceData)
|
||||
wsErr.Message = string(msg)
|
||||
default:
|
||||
_ = skipField(payload, &pos, wireType)
|
||||
}
|
||||
}
|
||||
const FixedUploadURL = "http://192.168.80.168/api/v1/upload/video"
|
||||
for i := range validDeviceDataList {
|
||||
if validDeviceDataList[i].UploadURL == "" {
|
||||
validDeviceDataList[i].UploadURL = FixedUploadURL
|
||||
logger.Logger.Printf("Auto-completed UploadURL: %s (DeviceUUID=%s)",
|
||||
FixedUploadURL, validDeviceDataList[i].DeviceUUID)
|
||||
}
|
||||
}
|
||||
if len(validDeviceDataList) == 0 {
|
||||
logger.Logger.Printf("No valid device configurations found in service config file")
|
||||
return nil, fmt.Errorf("no valid device configurations")
|
||||
}
|
||||
|
||||
logger.Logger.Printf("Loaded %d device configurations from service config file", len(validDeviceDataList))
|
||||
return validDeviceDataList, nil
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
fieldNumber := int(key >> 3)
|
||||
wireType := int(key & 0x7)
|
||||
if fieldNumber != 10 {
|
||||
if err := skipField(payload, &pos, wireType); err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
|
||||
return strings.Join(points, ",")
|
||||
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
|
||||
}
|
||||
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
|
||||
```
|
||||
@@ -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` 已基于修复后的代码构建完成,可用于后续部署验证。
|
||||
@@ -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.
|
||||
@@ -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. 增加最小验证
|
||||
- 增加协议层编解码基础测试
|
||||
- 增加配置映射测试
|
||||
- 增加报警暂停窗口逻辑测试
|
||||
- 条件允许时补最小集成测试
|
||||
@@ -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 当前代码实现差异”文档,专门帮你找现在代码和你脑中业务之间的偏差。
|
||||
@@ -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`,也没有看到覆盖关键链路的自动化测试。
|
||||
|
||||
### 风险
|
||||
|
||||
- 协议层改造、重启链路改造、报警暂停逻辑改造容易引入回归
|
||||
|
||||
### 准备怎么改
|
||||
|
||||
至少补最小化验证路径。
|
||||
@@ -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"}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
MessageType string `json:"message_type"`
|
||||
Serial string `json:"serial"`
|
||||
At int64 `json:"at"`
|
||||
Type int `json:"type"`
|
||||
Params []struct {
|
||||
Params []InferenceParam `json:"params"`
|
||||
}
|
||||
|
||||
type InferenceParam struct {
|
||||
ClassIdx int `json:"class_idx"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 883 KiB |
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 744 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 307 KiB |
@@ -0,0 +1,258 @@
|
||||
# 管理平台客户操作手册(简版)
|
||||
|
||||
> 本文档为简版操作手册。如有疑问或设备异常,请联系技术支持人员,联系方式见“附录 A”。
|
||||
>
|
||||
> 当前火焰检测版本说明:检测到异常后,系统会启动告警处理流程。等待约 21 秒,使推理程序完成缓存视频保存后,系统将合成并上传包含事件前后画面的完整视频。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速开始
|
||||
|
||||
如现场有技术人员,可按照本手册自行完成配置;如无技术人员,请先将设备的 LAN0 接口连接至公司内部网络,再通过电话、邮件或微信联系技术支持人员。
|
||||
|
||||
### 1.1 连接设备
|
||||
|
||||
1. 连接设备电源线,确认电源指示灯 `PWR` 正常亮起。
|
||||
|
||||

|
||||
|
||||
2. 使用网线连接设备:
|
||||
|
||||
| 接口 | 连接对象 |
|
||||
| --- | --- |
|
||||
| LAN1 | Windows 管理主机 |
|
||||
| LAN0 | 公司内部网络 |
|
||||
|
||||

|
||||
|
||||
### 1.2 配置 Windows 管理主机网络
|
||||
|
||||
1. 按 `Win + S`,搜索并打开“控制面板”。
|
||||
|
||||

|
||||
|
||||
2. 打开“网络和 Internet”。
|
||||
|
||||

|
||||
|
||||
3. 打开“网络和共享中心”。
|
||||
|
||||

|
||||
|
||||
4. 找到 Windows 主机连接设备 LAN1 接口所使用的网卡,右键单击该网卡并选择“属性”。
|
||||
|
||||

|
||||
|
||||
5. 在属性列表中选中“Internet 协议版本 4(TCP/IPv4)”,然后单击“属性”。
|
||||
|
||||

|
||||
|
||||
6. 按照下图设置 IP 地址和子网掩码,完成后单击“确定”。
|
||||
|
||||

|
||||
|
||||
7. 返回网卡属性窗口,再次单击“确定”保存设置。
|
||||
|
||||

|
||||
|
||||
8. 按 `Win + S`,搜索并打开“命令提示符(CMD)”。
|
||||
|
||||

|
||||
|
||||
9. 执行以下命令,测试管理主机与设备之间的网络是否连通:
|
||||
|
||||
```text
|
||||
ping 10.20.1.1
|
||||
```
|
||||
|
||||

|
||||
|
||||
10. 打开 Microsoft Edge 或 Google Chrome,在地址栏输入 `10.20.1.1`。
|
||||
|
||||

|
||||
|
||||
### 1.3 登录平台
|
||||
|
||||
1. 打开浏览器。
|
||||
2. 在地址栏输入平台地址:`http://10.20.1.1`。
|
||||
3. 进入平台登录页面。
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||
查看公司内部网络当前使用的网段。
|
||||
|
||||

|
||||
|
||||
2. 进入管理平台,在左侧导航栏中选择“网络设置”。
|
||||
|
||||

|
||||
|
||||
3. 找到 LAN0,单击“编辑”。
|
||||
4. 根据公司内部网络的网段填写 LAN0 的 IP 地址、子网掩码、网关和 DNS。
|
||||
|
||||
例如,管理主机的地址为 `192.168.1.123` 时,通常表示当前网段的第三段为 `1`。设备 LAN0 地址也应配置在 `192.168.1.x` 网段内,但不能与网络中的其他设备重复。
|
||||
|
||||

|
||||
|
||||
5. 确认配置无误后,单击“保存”。
|
||||
|
||||
> 保存后,设备管理地址可能发生变化。请使用新配置的 IP 地址重新访问平台。
|
||||
|
||||
### 2.3 配置 Wi-Fi
|
||||
|
||||
1. 在“网络设置”页面找到 Wi-Fi,单击“编辑”。
|
||||
2. 选择公司内部无线网络。
|
||||
3. 输入 Wi-Fi 密码,确认无误后单击“保存”。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3. 摄像机管理
|
||||
|
||||
平台通常已预先配置默认摄像机。如无特殊需求,请勿自行添加或删除摄像机。
|
||||
|
||||
### 3.1 编辑摄像机
|
||||
|
||||
1. 在摄像机列表中选择目标摄像机,单击“编辑”。
|
||||
|
||||

|
||||
|
||||
2. 填写摄像机 RTSP 地址,格式如下:
|
||||
|
||||
```text
|
||||
rtsp://用户名:密码@摄像机IP地址:554
|
||||
```
|
||||
|
||||
其中,`554` 为常用 RTSP 端口;如果摄像机端口未修改,可保持该值。
|
||||
|
||||
3. 确认配置无误后,单击“保存”。
|
||||
|
||||
### 3.2 配置动火离人检测
|
||||
|
||||
> 如果只需要启用火焰检测,可跳过本节。
|
||||
|
||||
#### 3.2.1 设置检测区域
|
||||
|
||||
1. 使用蓝色框选工具标记需要检测的工作区域。
|
||||
2. 根据现场摄像机画面调整区域,确认无误后单击“保存”。
|
||||
|
||||

|
||||
|
||||
#### 3.2.2 设置检测参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| 检测人数 | 工作区域内允许的最低人员数量。当检测人数低于设定值时,系统判定人员状态异常。 |
|
||||
| 检测时间 | 人员数量持续低于设定值达到该时长后,人员条件成立。 |
|
||||
| 温度阈值 | 测温系统检测到环境温度超过该值时,温度条件成立。人员条件与温度条件同时成立时触发报警。 |
|
||||
| 置信度 | 模型判断目标可信程度的阈值。例如设置为 `70`,表示置信度达到或超过 70% 的目标才计入识别结果。该项不可留空,通常保持默认值 `70`。 |
|
||||
| 报警间隔时间 | 一次报警触发后的冷却时间。冷却期间即使再次满足报警条件,也不会重复发送告警。设置为 `0` 表示不启用冷却时间。 |
|
||||
|
||||

|
||||
|
||||
### 3.3 配置火焰检测
|
||||
|
||||
#### 3.3.1 设置检测区域
|
||||
|
||||
1. 使用蓝色框选工具标记需要检测的工作区域。
|
||||
2. 根据现场摄像机画面调整区域,确认无误后单击“保存”。
|
||||
|
||||

|
||||
|
||||
#### 3.3.2 设置检测参数
|
||||
|
||||
默认参数已经过预设,如无必要,请勿修改。
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| 检测时间 | 火焰目标持续被识别达到该时长后,系统确认报警。 |
|
||||
| 置信度 | 模型判断火焰目标可信程度的阈值。只有达到或超过该阈值的目标才会进入检测计时。 |
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 4. 联动管理
|
||||
|
||||
设备联动是指通过 I/O 接口连接蜂鸣器、音柱等外部设备。如需配置联动设备,请联系技术支持人员。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据转发
|
||||
|
||||
数据转发用于对接第三方管理平台并传输视频数据。详细配置方法请参阅完整版操作手册。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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正常亮起
|
||||

|
||||
使用平台前,请确认网线已经正确连接:
|
||||
- lan1连接windows主机|lan0连接公司内部网口
|
||||

|
||||
|
||||
## 1.2 网络配置
|
||||
- 设置管理机网络(windows主机)
|
||||
1. win+s 搜索栏 打开控制面板
|
||||

|
||||
2. 打开网络和Internet
|
||||

|
||||
3. 打开网络共享中心
|
||||

|
||||
4. 找到win主机连接设备lan1网口对应网卡,右键属性设置
|
||||

|
||||
5. 打开属性后勾选IPv4选项并且左键点击
|
||||

|
||||
6. 打开属性面板后设置如图所示ip|子网掩码,更改成功之后点击确定
|
||||

|
||||
7. 更改好IPv4设置之后点击确定保存
|
||||

|
||||
8. 打开cmd控制窗口
|
||||

|
||||
9. 测试设备是否连通
|
||||
代码:ping 10.20.1.1
|
||||

|
||||
10. 打开浏览器(edge/Chrome)访问 10.20.1.1
|
||||

|
||||
|
||||
## 1.2 访问平台
|
||||
|
||||
1. 打开浏览器。
|
||||
2. 在地址栏输入平台地址:`10.20.1.1`。
|
||||
3. 进入平台登录页面。
|
||||

|
||||
4. 输入用户名和密码。(默认:admin\admin)
|
||||
5. 单击“登录”进入平台首页。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 2. 平台内部网络设置
|
||||
|
||||
## 2.1 网络修改
|
||||
- 默认IP 地址 | 平台或设备在局域网中的地址 | `10.20.1.1` |
|
||||
- 首次通过 | `10.20.1.1` | 进入平台内部设置lan0(内部局域网)
|
||||
- 修改之后通过内网网址访问即可
|
||||
- 修改lan0(2.2)与修改WIFI(2.3)可选其一设置,但是ip地址不可以一样
|
||||
## 2.2 修改lan0
|
||||
1. 开始修改之前还要做一项准备工作,win+s搜索cmd打开命令行,输入 `ipconfig`
|
||||

|
||||
|
||||
查询到当前网络处于什么网段
|
||||
2. 进入平台前端页面,根据左侧选项栏选择网络设置
|
||||
|
||||

|
||||
|
||||
3. 点击编辑开始编辑lan1网络(如图所示),ip的第三位也就是`192.168.1.123`中的x.x.1.x应该填写操作步骤中的1,获取到的第三位网段
|
||||

|
||||
确认无误之后选择保存
|
||||
## 2.3 修改WIFI
|
||||
- 点击编辑之后选择当前内部网络的WiFi,然后输入密码保存即可
|
||||

|
||||
|
||||
---
|
||||
|
||||
# 3. 摄像机管理
|
||||
平台内部已经设置成功默认摄像头,无特殊需要请勿自己添加摄像头
|
||||
|
||||
## 3.1 摄像机编辑
|
||||
|
||||
|
||||
1. 选择编辑
|
||||
|
||||

|
||||
|
||||
2. 仅需要添加rtsp地址`rtsp://usr(摄像头账户):摄像头(密码)@ 摄像头IP地址:554(摄像头默认rtsp端口,未修改无需改动)`
|
||||
3. 确认修改无误之后点击保存
|
||||
|
||||
## 3.2 摄像机配置
|
||||
- 如只需启用火焰检测,可省略1/2分类
|
||||
1. 动火离人区域检测配置:
|
||||
- 1.1 蓝色方框为工作目标区域框选
|
||||
- 1.2 根据现场摄像头设置工作区域之后点击保存
|
||||
|
||||

|
||||
1. 动火离人参数配置:
|
||||
- 2.1 检测人数:检测当前工作区域内人员数量,当人员数量低于预设值则判定为异常
|
||||
- 2.2 检测时间:当工作区域地狱检测人数时,持续达到预设时间后报警
|
||||
- 2.3 温度阈值:测温系统检测到当前环境温度超过预设值则为异常,与2.1\2.2同步触发时报警
|
||||
- 2.4 置信度:该值为模型识别到目标的准确值,70是值70%,一般预设为70不可空填。当识别到的目标置信度大于等于70时则判定当前工作区域内识别到目标(识别到工作人员)
|
||||
- 2.5 报警间隔时间:当触发一次报警后,会进入到报警冷却时间,这期间如果再次触发上述报警逻辑后不会发送告警信息。该值根据当前管理标准设置,可设置为0,那么就不会有冷却时间,只要符合报警逻辑会立即上报。
|
||||

|
||||
|
||||
1. 火焰检测检测区域配置:
|
||||
- 1.1 蓝色方框为工作目标区域框选
|
||||
- 1.2 根据现场摄像头设置工作区域之后点击保存
|
||||

|
||||
2. 火焰检测参数设置:
|
||||
- 默认值已设置非必要请勿修改
|
||||
- 4.1 检测时间:当识别到目标持续时间达到预设值则报警
|
||||
- 4.2 置信度:与3.2.2.4含义一致
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 4. 联动管理
|
||||
- 设备联动指io口连接对应的蜂鸣器与音柱,有需要请联系工程师
|
||||
---
|
||||
# 5. 数据转发
|
||||
- 对接第三方管理平台,传输视频数据,详细配置请看完整版文档
|
||||

|
||||
|
||||
# 6. 告警信息
|
||||
- 输出检测到的告警信息
|
||||
## 6.1 查看告警列表
|
||||
|
||||
进入“告警信息”页面,可按设备进行查询。
|
||||
|
||||
## 6.2 告警字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 相机名称 | 对应相机管理中创建的ID |
|
||||
| 信息 | 当前事件触发的信息记录 |
|
||||
| 创建时间 | 事件触发的具体日期 |
|
||||
| 操作-查看视频 | 查看识别到目标的视频 |
|
||||
| 操作-删除 | 清理无效数据 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 7. 报警转发
|
||||
- 对接第三方管理平台,发送第一时间触发时间的报警信息,详细配置请看完整版文档
|
||||
|
||||
---
|
||||
## 附录 A 技术支持联系方式
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 技术支持电话 | `14752696604` |
|
||||
| 技术支持邮箱 | `1935881371@qq.com` |
|
||||
| 服务时间 | `10:00-22:00` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 附录 B:版本修订记录
|
||||
|
||||
| 文档版本 | 修订日期 | 修订内容 | 修订人 |
|
||||
| --- | --- | --- | --- |
|
||||
| V1.0 | `2026/5/5` | 首次编制 | `Smos_Guo` |
|
||||
@@ -0,0 +1,425 @@
|
||||
# 管理平台客户操作手册
|
||||
|
||||
- 操作前请确认当前账号具有对应功能权限。。
|
||||
- 页面布局、菜单名称若与本文档不同,请以实际部署版本为准。
|
||||
|
||||
---
|
||||
|
||||
# 1. 快速开始
|
||||
|
||||
## 1.1 连接配置
|
||||
- 连接电源线确保电源PWR正常亮起
|
||||

|
||||
使用平台前,请确认网线已经正确连接:
|
||||
- lan1连接windows主机|lan0连接公司内部网口
|
||||

|
||||
|
||||
## 1.2 网络配置
|
||||
- 设置管理机网络(windows主机)
|
||||
1. win+s 搜索栏 打开控制面板
|
||||

|
||||
2. 打开网络和Internet
|
||||

|
||||
3. 打开网络共享中心
|
||||

|
||||
4. 找到win主机连接设备lan1网口对应网卡,右键属性设置
|
||||

|
||||
5. 打开属性后勾选IPv4选项并且左键点击
|
||||

|
||||
6. 打开属性面板后设置如图所示ip|子网掩码,更改成功之后点击确定
|
||||

|
||||
7. 更改好IPv4设置之后点击确定保存
|
||||

|
||||
8. 打开cmd控制窗口
|
||||

|
||||
9. 测试设备是否连通
|
||||
代码:ping 10.20.1.1
|
||||

|
||||
10. 打开浏览器(edge/Chrome)访问 10.20.1.1
|
||||

|
||||
|
||||
## 1.2 访问平台
|
||||
|
||||
1. 打开浏览器。
|
||||
2. 在地址栏输入平台地址:`10.20.1.1`。
|
||||
3. 进入平台登录页面。
|
||||

|
||||
4. 输入用户名和密码。(默认:admin\admin)
|
||||
5. 单击“登录”进入平台首页。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 2. 平台内部网络设置
|
||||
|
||||
## 2.1 网络修改
|
||||
- 默认IP 地址 | 平台或设备在局域网中的地址 | `10.20.1.1` |
|
||||
- 首次通过 | `10.20.1.1` | 进入平台内部设置lan0(内部局域网)
|
||||
- 修改之后通过内网网址访问即可
|
||||
- 修改lan0(2.2)与修改WIFI(2.3)可选其一设置,但是ip地址不可以一样
|
||||
## 2.2 修改lan0
|
||||
1. 开始修改之前还要做一项准备工作,win+s搜索cmd打开命令行,输入 `ipconfig`
|
||||

|
||||
|
||||
查询到当前网络处于什么网段
|
||||
2. 进入平台前端页面,根据左侧选项栏选择网络设置
|
||||
|
||||

|
||||
|
||||
3. 点击编辑开始编辑lan1网络(如图所示),ip的第三位也就是`192.168.1.123`中的x.x.1.x应该填写操作步骤中的1,获取到的第三位网段
|
||||

|
||||
确认无误之后选择保存
|
||||
## 2.3 修改WIFI
|
||||
- 点击编辑之后选择当前内部网络的WiFi,然后输入密码保存即可
|
||||

|
||||
|
||||
---
|
||||
|
||||
# 3. 摄像机管理
|
||||
平台内部已经设置成功默认摄像头,无特殊需要请勿自己添加摄像头
|
||||
|
||||
## 3.1 摄像机编辑
|
||||
|
||||
|
||||
1. 选择编辑
|
||||
|
||||

|
||||
|
||||
2. 仅需要添加rtsp地址`rtsp://usr(摄像头账户):摄像头(密码)@ 摄像头IP地址:554(摄像头默认rtsp端口,未修改无需改动)`
|
||||
3. 确认修改无误之后点击保存
|
||||
|
||||
## 3.2 摄像机配置
|
||||
1. 动火离人区域检测配置:
|
||||
- 1.1 蓝色方框为工作目标区域框选
|
||||
- 1.2 根据现场摄像头设置工作区域之后点击保存
|
||||
- 1.3 新增区域可
|
||||

|
||||
2. 动火离人参数配置:
|
||||
- 2.1 检测人数:检测当前工作区域内人员数量,当人员数量低于预设值则判定为异常
|
||||
- 2.2 检测时间:当工作区域地狱检测人数时,持续达到预设时间后报警
|
||||
- 2.3 温度阈值:测温系统检测到当前环境温度超过预设值则为异常,与2.1\2.2同步触发时报警
|
||||
- 2.4 置信度:该值为模型识别到目标的准确值,70是值70%,一般预设为70不可空填。当识别到的目标置信度大于等于70时则判定当前工作区域内识别到目标(识别到工作人员)
|
||||
- 2.5 报警间隔时间:当触发一次报警后,会进入到报警冷却时间,这期间如果再次触发上述报警逻辑后不会发送告警信息。该值根据当前管理标准设置,可设置为0,那么就不会有冷却时间,只要符合报警逻辑会立即上报。
|
||||

|
||||
|
||||
3. 火焰检测检测区域配置:
|
||||
- 与3.2.1配置方式一致
|
||||
4. 火焰检测参数设置:
|
||||
- 4.1 检测时间:当识别到目标持续时间达到预设值则报警
|
||||
- 4.2 置信度:与3.2.2.4含义一致
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 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 | `待填写` | 首次编制 | `待填写` |
|
||||
@@ -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,31 +60,37 @@ 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)
|
||||
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() && strings.HasSuffix(f.Name(), ".mp4") && !strings.Contains(f.Name(), "_joined") {
|
||||
if !f.IsDir() && isSupportedRawVideoFile(f.Name()) {
|
||||
if ts, err := parseSimpleTimestamp(f.Name()); err == nil {
|
||||
candidates = append(candidates, struct {
|
||||
path string
|
||||
@@ -64,6 +99,7 @@ func MergeVideo(deviceUUID string, alarmTime int64) (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -72,89 +108,230 @@ func MergeVideo(deviceUUID string, alarmTime int64) (string, error) {
|
||||
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] Using fallback: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), filepath.Base(closest), deviceUUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||