Files
security-ai-edge/core/result_reporter.py
16337 ecebdd514f feat(aiot): 离岗检测重写 - 单次告警 + 回岗确认 + 持续时长追踪
算法逻辑修改:
- 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(离岗开始时间)
2026-02-11 17:55:35 +08:00

235 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
结果上报模块
使用本地 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()