Files
sentinel-home-ai/fam-edge/src/fam_edge/dsm_motion_client.py

136 lines
6.3 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.
"""
[已弃用 / DEPRECATED] 本模块自 2026-08-22 起不再被调用。
架构约束:甲骨文 FAM-Edge 不得反向访问 NAS。原 video_processor 在此处主动查询
NAS 的 Surveillance Station甲骨文 -> NAS违反该约束已停用。
替代方案:由 NAS 端 fam-core 的 MotionNotifier 轮询/接收 SS 事件后,主动 POST
推送到甲骨文的 /api/ss/motion落库 ss_motion_eventsvideo_processor 改用
db.has_motion_in_range_local() 做本地预过滤。本文件保留仅作参考,请勿再实例化。
---
DsmMotionClient旧实现仅作历史参考 - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用)
背景: fam-edge 原来不管这段 30 分钟录像里有没有人走动,一律整段送云端 VLM 分析,
配额/耗时都花在长期无人的空转时段上。DSM 的 Surveillance Station 用
SYNO.SurveillanceStation.EventCenter.Event 这个未公开文档的内部 API 记录了摄像
头真实的运动侦测窗口start_time + durationevent_type=10 即运动),比自己在本地
用 ffmpeg 帧差分更准,也不需要额外算力。
用法: process_video() 分析前,用视频的 [event_start, event_start+duration] 时间窗
查一次这个接口——窗口内一条运动事件都没有,就跳过云端分析(标记 done
compute_provider='skipped_no_motion'),有任何一条就正常送去分析。
失败即放行fail-open: 网络错误/认证失败/账号未授权(401)等任何异常都视为
"无法判断",返回 None调用方必须当作"照常分析"处理,而不是当作"跳过"处理——
宁可多花一次配额,也不能因为这个可选的省钱层漏检真实事件。
未公开文档的内部 API字段名/权限模型可能随群晖套件升级变化,出问题时表现为
每次都 fail-open不再省钱不会导致漏检。
"""
import time
from datetime import datetime
from typing import Optional
import requests
from .logger import setup_logger
logger = setup_logger('fam-edge.dsm_motion_client')
class DsmMotionClient:
def __init__(self, config: dict):
self.enabled = bool(config.get('enabled', False))
self.host = config.get('host', '')
self.port = config.get('port', 5000)
self.account = self._resolve(config.get('account', ''))
self.password = self._resolve(config.get('password', ''))
self.camera_id = config.get('camera_id')
self.min_motion_seconds = float(config.get('min_motion_seconds', 0))
self.timeout = config.get('timeout_sec', 10)
self._sid: Optional[str] = None
self._base = f"http://{self.host}:{self.port}/webapi"
@staticmethod
def _resolve(raw: str) -> str:
import os
if raw.startswith('${') and raw.endswith('}'):
return os.environ.get(raw[2:-1], '')
return raw
def _login(self) -> Optional[str]:
try:
resp = requests.get(
f"{self._base}/auth.cgi",
params={
"api": "SYNO.API.Auth", "version": 6, "method": "login",
"account": self.account, "passwd": self.password,
"session": "SurveillanceStation", "format": "sid",
},
timeout=self.timeout,
)
data = resp.json()
if data.get('success'):
return data['data']['sid']
logger.warning(f"DSM 登录失败: {data.get('error')}")
except Exception as e:
logger.warning(f"DSM 登录异常: {e}")
return None
def has_motion_in_range(self, start_dt: datetime, duration_sec: float) -> Optional[bool]:
"""查询 [start_dt, start_dt+duration_sec] 窗口内是否有运动事件。
返回 True/False 为确定结果;返回 None 表示查询本身失败,调用方必须
fail-open当作"有运动"处理,照常送云端分析),不能当作"无运动"跳过。
"""
if not self.enabled or not self.host or not self.account or not self.password \
or self.camera_id is None:
return None
if self._sid is None:
self._sid = self._login()
if self._sid is None:
return None
start_ts = int(start_dt.timestamp())
end_ts = int(start_dt.timestamp() + max(duration_sec, 0))
for attempt in range(2): # 第 2 次仅用于 session 过期后重新登录重试一次
try:
resp = requests.get(
f"{self._base}/entry.cgi",
params={
"api": "SYNO.SurveillanceStation.EventCenter.Event",
"version": 1, "method": "List",
"camera_ids": str(self.camera_id),
"start_time": start_ts, "end_time": end_ts,
"limit": 50, "_sid": self._sid,
},
timeout=self.timeout,
)
data = resp.json()
except Exception as e:
logger.warning(f"DSM 运动事件查询异常: {e}")
return None
if not data.get('success'):
err_code = (data.get('error') or {}).get('code')
if err_code in (106, 107, 119) and attempt == 0:
# session 过期/被顶掉,重新登录重试一次
self._sid = self._login()
if self._sid is None:
return None
continue
logger.warning(f"DSM 运动事件查询失败: {data.get('error')}")
return None
# data 按 ds_id群晖多主机 CMS 场景的服务器 id单机固定是 "0")分组,
# 不是按 camera_id 分组——这里把所有 ds_id 下的事件拉平,
# 用事件自带的 camera_id 字段再确认一次(双保险,服务端已按 camera_ids 过滤)。
events = [
e for group in (data.get('data') or {}).values()
for e in (group or [])
if e.get('camera_id') == self.camera_id
]
if not events:
return False
total_motion = sum(float(e.get('duration', 0) or 0) for e in events)
return total_motion >= self.min_motion_seconds
return None