feat(fam-edge): DSM 运动侦测预过滤 - 空转时段跳过云端视频分析
群晖 Surveillance Station 用 SYNO.SurveillanceStation.EventCenter.Event(未公开 文档的内部接口,参数是下划线风格 camera_ids/start_time/end_time,公开文档里的 cameraIds/fromTime/toTime 是旧版 Event API 的参数名,两者不通用)记录了摄像头 真实的运动侦测事件(event_type=10=运动,start_time+duration 精确到秒),比自己 本地跑 ffmpeg 帧差分更准、不需要额外算力,之前一直在这台 NAS 上验证可行性。 新增 DsmMotionClient:process_video() 分析每段视频前,先用视频的 [event_start, event_start+duration] 时间窗查一次这个接口,窗口内一条运动事件都 没有就跳过云端分析(标记 done,compute_provider=skipped_no_motion),省掉长期 无人时段白白消耗的 Gemini/NVIDIA 配额。 安全设计:查询本身失败/未配置/账号密码没填一律 fail-open(当作"有运动",照常 分析),不会因为这层可选优化漏检真实事件——这是一个纯粹的省配额优化,不能反过来 影响监控系统的可靠性。 新增 12 个单测覆盖:禁用/缺凭证时的 fail-open、登录失败、网络异常、无事件、有 事件、按 camera_id 过滤(响应是按 ds_id 分组不是按 camera_id,容易搞混)、 最短运动时长阈值、session 过期重登录重试、env 变量解析。 账号密码走 .env 的 DSM_ACCOUNT/DSM_PASSWORD,不明文入库。
This commit is contained in:
157
fam-edge/tests/test_dsm_motion_client.py
Normal file
157
fam-edge/tests/test_dsm_motion_client.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fam_edge.dsm_motion_client import DsmMotionClient
|
||||
|
||||
|
||||
def _cfg(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"host": "192.168.50.64",
|
||||
"port": 5000,
|
||||
"account": "ericwyuan",
|
||||
"password": "secret",
|
||||
"camera_id": 2,
|
||||
"min_motion_seconds": 0,
|
||||
"timeout_sec": 5,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _events_payload(events, ds_id="0"):
|
||||
return {"success": True, "data": {ds_id: events}}
|
||||
|
||||
|
||||
def test_disabled_config_always_returns_none():
|
||||
c = DsmMotionClient(_cfg(enabled=False))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None
|
||||
|
||||
|
||||
def test_missing_credentials_returns_none():
|
||||
c = DsmMotionClient(_cfg(account="", password=""))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None
|
||||
|
||||
|
||||
def test_login_failure_returns_none(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
return _FakeResp({"success": False, "error": {"code": 400}})
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg())
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None
|
||||
|
||||
|
||||
def test_login_network_exception_returns_none(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
raise ConnectionError("boom")
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg())
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None
|
||||
|
||||
|
||||
def test_no_motion_events_returns_false(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
calls["n"] += 1
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload([]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg())
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False
|
||||
|
||||
|
||||
def test_motion_event_present_returns_true(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 12}]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg())
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True
|
||||
|
||||
|
||||
def test_events_grouped_by_ds_id_not_camera_id(monkeypatch):
|
||||
"""核心场景: 响应按 ds_id 分组(单机固定 "0"),不是按 camera_id 分组——
|
||||
错把 camera_id 当 dict key 去取会永远拿到空列表。"""
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5}], ds_id="0"))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg(camera_id=2))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True
|
||||
|
||||
|
||||
def test_events_for_other_camera_ignored(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 99, "event_type": 10, "start_time": 1000, "duration": 999}]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg(camera_id=2))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False
|
||||
|
||||
|
||||
def test_total_motion_below_threshold_is_treated_as_no_motion(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 1}]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg(min_motion_seconds=3))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False
|
||||
|
||||
|
||||
def test_total_motion_meeting_threshold_counts_as_motion(monkeypatch):
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
return _FakeResp({"success": True, "data": {"sid": "abc"}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 2},
|
||||
{"camera_id": 2, "event_type": 10, "start_time": 1100, "duration": 2}]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg(min_motion_seconds=3))
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True
|
||||
|
||||
|
||||
def test_expired_session_relogins_and_retries_once(monkeypatch):
|
||||
"""session 过期(错误码 106)时应该重新登录一次再查,而不是直接放弃。"""
|
||||
state = {"logins": 0, "queries": 0}
|
||||
|
||||
def fake_get(url, params=None, timeout=None):
|
||||
if params.get("api") == "SYNO.API.Auth":
|
||||
state["logins"] += 1
|
||||
return _FakeResp({"success": True, "data": {"sid": f"sid{state['logins']}"}})
|
||||
state["queries"] += 1
|
||||
if state["queries"] == 1:
|
||||
return _FakeResp({"success": False, "error": {"code": 106}})
|
||||
return _FakeResp(_events_payload(
|
||||
[{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5}]))
|
||||
monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get)
|
||||
c = DsmMotionClient(_cfg())
|
||||
assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True
|
||||
assert state["logins"] == 2
|
||||
assert state["queries"] == 2
|
||||
|
||||
|
||||
def test_env_var_credentials_resolved():
|
||||
import os
|
||||
os.environ["TEST_DSM_ACCOUNT_XYZ"] = "realaccount"
|
||||
try:
|
||||
c = DsmMotionClient(_cfg(account="${TEST_DSM_ACCOUNT_XYZ}"))
|
||||
assert c.account == "realaccount"
|
||||
finally:
|
||||
del os.environ["TEST_DSM_ACCOUNT_XYZ"]
|
||||
Reference in New Issue
Block a user