算法逻辑修改: - OFF_DUTY状态只告警一次,不再每600秒重复告警 - 人员回岗后需经CONFIRMING(10秒)重新确认才恢复ON_DUTY - 确认在岗后清除冷却记录,允许新一轮离岗检测 - 非工作时间进入时清除冷却记录 持续时长追踪(新增resolve机制): - 离岗告警记录alarm_id和leave_start_time - 人员回岗确认后生成resolve事件(duration_ms + last_frame_time) - 进入非工作时间时也生成resolve事件 - ResultReporter新增report_alarm_resolve()写入Redis队列 - AlarmUploadWorker新增_process_resolve() HTTP POST到云端 - main.py区分普通告警和resolve事件,回填alarm_id到算法实例 - 告警ext_data附加first_frame_time(离岗开始时间)
235 lines
7.6 KiB
Python
235 lines
7.6 KiB
Python
"""
|
||
结果上报模块
|
||
使用本地 Redis 缓冲 + HTTP 上报替代 MQTT
|
||
|
||
告警流程:
|
||
算法产出告警 → report_alarm() LPUSH 到 Redis → AlarmUploadWorker 异步消费
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
from dataclasses import dataclass, field
|
||
|
||
import numpy as np
|
||
import redis
|
||
|
||
from config.settings import get_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class AlarmInfo:
|
||
"""告警信息类(新格式,对齐云端 alarm_event 表)"""
|
||
alarm_id: str
|
||
alarm_type: str
|
||
device_id: str
|
||
scene_id: str
|
||
event_time: str # ISO8601
|
||
alarm_level: int # 1-4
|
||
snapshot_local_path: Optional[str] = None
|
||
algorithm_code: Optional[str] = None
|
||
confidence_score: Optional[float] = None
|
||
ext_data: Optional[Dict[str, Any]] = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""转换为字典(写入 Redis 的 JSON 格式)"""
|
||
return {
|
||
"alarm_id": self.alarm_id,
|
||
"alarm_type": self.alarm_type,
|
||
"device_id": self.device_id,
|
||
"scene_id": self.scene_id,
|
||
"event_time": self.event_time,
|
||
"alarm_level": self.alarm_level,
|
||
"snapshot_local_path": self.snapshot_local_path,
|
||
"algorithm_code": self.algorithm_code,
|
||
"confidence_score": self.confidence_score,
|
||
"ext_data": self.ext_data,
|
||
}
|
||
|
||
|
||
def generate_alarm_id(device_id: str) -> str:
|
||
"""
|
||
生成告警ID
|
||
格式: edge_{device_id}_{YYYYMMDDHHmmss}_{6位uuid}
|
||
"""
|
||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||
uid = uuid.uuid4().hex[:6]
|
||
return f"edge_{device_id}_{timestamp}_{uid}"
|
||
|
||
|
||
# Redis 队列 Key 常量
|
||
REDIS_KEY_ALARM_PENDING = "local:alarm:pending"
|
||
REDIS_KEY_ALARM_RETRY = "local:alarm:retry"
|
||
REDIS_KEY_ALARM_DEAD = "local:alarm:dead"
|
||
|
||
|
||
class ResultReporter:
|
||
"""结果上报器
|
||
|
||
将告警写入本地 Redis 队列,由 AlarmUploadWorker 异步消费上传。
|
||
report_alarm() 方法使用 LPUSH,算法线程零阻塞。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._settings = get_settings()
|
||
self._redis: Optional[redis.Redis] = None
|
||
self._logger = logging.getLogger("result_reporter")
|
||
|
||
self._performance_stats = {
|
||
"alerts_generated": 0,
|
||
"alerts_queued": 0,
|
||
"queue_failures": 0,
|
||
}
|
||
|
||
# 图片存储(本地保存截图供 worker 读取上传)
|
||
self._image_storage = None
|
||
self._db_manager = None
|
||
|
||
self._logger.info("ResultReporter 初始化完成(Redis 缓冲模式)")
|
||
|
||
def initialize(self):
|
||
"""初始化 Redis 连接和本地存储"""
|
||
# 初始化本地存储(截图保存)
|
||
try:
|
||
from core.storage_manager import get_image_storage
|
||
self._image_storage = get_image_storage()
|
||
except Exception as e:
|
||
self._logger.warning(f"本地图片存储初始化失败: {e}")
|
||
|
||
try:
|
||
from config.database import get_sqlite_manager
|
||
self._db_manager = get_sqlite_manager()
|
||
except Exception as e:
|
||
self._logger.warning(f"本地数据库初始化失败: {e}")
|
||
|
||
# 初始化 Redis 连接(使用本地 Redis)
|
||
redis_cfg = self._settings.local_redis
|
||
try:
|
||
self._redis = redis.Redis(
|
||
host=redis_cfg.host,
|
||
port=redis_cfg.port,
|
||
db=redis_cfg.db,
|
||
password=redis_cfg.password,
|
||
decode_responses=True,
|
||
socket_connect_timeout=5,
|
||
)
|
||
self._redis.ping()
|
||
self._logger.info(
|
||
f"Redis 连接成功: {redis_cfg.host}:{redis_cfg.port}/{redis_cfg.db}"
|
||
)
|
||
except Exception as e:
|
||
self._logger.error(f"Redis 连接失败: {e}")
|
||
self._redis = None
|
||
|
||
def report_alarm(self, alarm_info: AlarmInfo, screenshot: Optional[np.ndarray] = None) -> bool:
|
||
"""
|
||
上报告警(写入 Redis 队列)
|
||
|
||
Args:
|
||
alarm_info: 告警信息
|
||
screenshot: 抓拍图片(numpy 数组)
|
||
|
||
Returns:
|
||
是否成功写入队列
|
||
"""
|
||
self._performance_stats["alerts_generated"] += 1
|
||
|
||
# 保存截图到本地,获取本地路径
|
||
if screenshot is not None and self._image_storage:
|
||
try:
|
||
local_path = self._image_storage.save_capture(
|
||
image=screenshot,
|
||
camera_id=alarm_info.device_id,
|
||
alert_id=alarm_info.alarm_id,
|
||
timestamp=datetime.now(),
|
||
)
|
||
if local_path:
|
||
alarm_info.snapshot_local_path = local_path
|
||
except Exception as e:
|
||
self._logger.error(f"保存截图失败: {e}")
|
||
|
||
# 写入 Redis 队列
|
||
if self._redis is None:
|
||
self._performance_stats["queue_failures"] += 1
|
||
self._logger.error("Redis 未连接,无法写入告警队列")
|
||
return False
|
||
|
||
try:
|
||
alarm_json = json.dumps(alarm_info.to_dict(), ensure_ascii=False)
|
||
self._redis.lpush(REDIS_KEY_ALARM_PENDING, alarm_json)
|
||
self._performance_stats["alerts_queued"] += 1
|
||
|
||
self._logger.info(
|
||
f"告警已入队: alarm_id={alarm_info.alarm_id}, "
|
||
f"type={alarm_info.alarm_type}, device={alarm_info.device_id}"
|
||
)
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._performance_stats["queue_failures"] += 1
|
||
self._logger.error(f"写入 Redis 队列失败: {e}")
|
||
return False
|
||
|
||
def report_alarm_resolve(self, resolve_data: dict) -> bool:
|
||
"""上报告警结束事件(写入 Redis 队列)"""
|
||
if self._redis is None:
|
||
self._logger.error("Redis 未连接,无法写入 resolve 队列")
|
||
return False
|
||
try:
|
||
resolve_data["_type"] = "resolve" # 标记类型,worker 据此分流
|
||
resolve_json = json.dumps(resolve_data, ensure_ascii=False)
|
||
self._redis.lpush(REDIS_KEY_ALARM_PENDING, resolve_json)
|
||
self._logger.info(f"告警结束事件已入队: alarm_id={resolve_data.get('alarm_id')}")
|
||
return True
|
||
except Exception as e:
|
||
self._logger.error(f"写入 resolve 队列失败: {e}")
|
||
return False
|
||
|
||
def get_statistics(self) -> Dict[str, Any]:
|
||
"""获取统计信息"""
|
||
stats = self._performance_stats.copy()
|
||
|
||
if self._redis:
|
||
try:
|
||
stats["pending_count"] = self._redis.llen(REDIS_KEY_ALARM_PENDING)
|
||
stats["retry_count"] = self._redis.llen(REDIS_KEY_ALARM_RETRY)
|
||
stats["dead_count"] = self._redis.llen(REDIS_KEY_ALARM_DEAD)
|
||
except Exception:
|
||
pass
|
||
|
||
stats["redis_connected"] = self._redis is not None
|
||
|
||
return stats
|
||
|
||
def close(self):
|
||
"""关闭上报器"""
|
||
self._logger.info("ResultReporter 资源清理")
|
||
|
||
if self._image_storage:
|
||
try:
|
||
self._image_storage.close()
|
||
except Exception:
|
||
pass
|
||
|
||
if self._db_manager:
|
||
try:
|
||
self._db_manager.close()
|
||
except Exception:
|
||
pass
|
||
|
||
if self._redis:
|
||
try:
|
||
self._redis.close()
|
||
except Exception:
|
||
pass
|
||
|
||
self._logger.info("ResultReporter 清理完成")
|
||
|
||
def cleanup(self):
|
||
"""清理资源(别名)"""
|
||
self.close()
|