""" [已弃用 / DEPRECATED] 本模块自 2026-08-22 起不再被调用。 架构约束:甲骨文 FAM-Edge 不得反向访问 NAS。原 video_processor 在此处主动查询 NAS 的 Surveillance Station(甲骨文 -> NAS),违反该约束,已停用。 替代方案:由 NAS 端 fam-core 的 MotionNotifier 轮询/接收 SS 事件后,主动 POST 推送到甲骨文的 /api/ss/motion,落库 ss_motion_events;video_processor 改用 db.has_motion_in_range_local() 做本地预过滤。本文件保留仅作参考,请勿再实例化。 --- DsmMotionClient(旧实现,仅作历史参考) - 查询群晖 Surveillance Station 的运动侦测事件(预过滤用) 背景: fam-edge 原来不管这段 30 分钟录像里有没有人走动,一律整段送云端 VLM 分析, 配额/耗时都花在长期无人的空转时段上。DSM 的 Surveillance Station 用 SYNO.SurveillanceStation.EventCenter.Event 这个未公开文档的内部 API 记录了摄像 头真实的运动侦测窗口(start_time + duration,event_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