360 lines
11 KiB
Python
360 lines
11 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from motion_agent.cameras import (
|
|
MotionAgentCameraError,
|
|
MotionAgentDependencyError,
|
|
UrlCameraInput,
|
|
UrlCameraSpec,
|
|
UsbCameraInput,
|
|
UsbCameraSpec,
|
|
)
|
|
import motion_agent.cameras as motion_cameras
|
|
from motion_agent.config import MotionAgentConfig
|
|
from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint
|
|
from motion_agent.recognizer import GestureObservation
|
|
from motion_agent.server import MotionAgentServer
|
|
from motion_agent.state import GestureStateMachine
|
|
from motion_agent import cli as motion_cli
|
|
|
|
|
|
def test_gesture_event_serializes_stable_protocol_fields():
|
|
event = GestureEvent(
|
|
gesture="rotate_left",
|
|
confidence=0.91,
|
|
intensity=0.75,
|
|
timestamp_ms=1000,
|
|
seq=7,
|
|
mode="single",
|
|
)
|
|
|
|
payload = json.loads(event.to_json())
|
|
|
|
assert payload["type"] == "gesture"
|
|
assert payload["gesture"] == "rotate_left"
|
|
assert payload["phase"] == "discrete"
|
|
assert payload["confidence"] == 0.91
|
|
assert payload["intensity"] == 0.75
|
|
assert payload["timestamp_ms"] == 1000
|
|
assert payload["seq"] == 7
|
|
assert payload["source"] == "motion-agent"
|
|
assert payload["mode"] == "single"
|
|
assert payload["protocol_version"] == "motion.v2"
|
|
assert payload["input_mode"] == "single"
|
|
assert payload["camera_id"] == "unknown"
|
|
assert payload["payload"] == {}
|
|
|
|
|
|
def test_state_machine_ignores_low_confidence_observations():
|
|
state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400)
|
|
|
|
event = state.accept(
|
|
GestureObservation(
|
|
gesture="confirm",
|
|
confidence=0.79,
|
|
intensity=1,
|
|
timestamp_ms=1000,
|
|
)
|
|
)
|
|
|
|
assert event is None
|
|
|
|
|
|
def test_state_machine_applies_per_gesture_cooldown():
|
|
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400)
|
|
|
|
first = state.accept(
|
|
GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
|
)
|
|
repeated = state.accept(
|
|
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200)
|
|
)
|
|
later = state.accept(
|
|
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500)
|
|
)
|
|
|
|
assert first is not None
|
|
assert first.seq == 1
|
|
assert repeated is None
|
|
assert later is not None
|
|
assert later.seq == 2
|
|
|
|
|
|
def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
|
|
|
status = json.loads(server.status_event().to_json())
|
|
heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json())
|
|
|
|
assert status["type"] == "status"
|
|
assert status["camera_count"] == 1
|
|
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
|
assert status["recognizer"] == "dry-run"
|
|
assert status["protocol_version"] == "motion.v2"
|
|
assert status["armed"] is False
|
|
assert status["paused"] is False
|
|
assert status["devices_open"] is False
|
|
assert heartbeat == {
|
|
"timestamp_ms": 123,
|
|
"protocol_version": "motion.v2",
|
|
"source": "motion-agent",
|
|
"type": "heartbeat",
|
|
}
|
|
|
|
|
|
def test_skeleton_event_serializes_without_raw_image_fields():
|
|
event = SkeletonEvent(
|
|
joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)],
|
|
bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")],
|
|
matched_gesture="rotate_left",
|
|
confidence=0.91,
|
|
camera_id="usb:0",
|
|
timestamp_ms=1000,
|
|
mode="single",
|
|
)
|
|
|
|
payload = json.loads(event.to_json())
|
|
|
|
assert payload["type"] == "skeleton"
|
|
assert payload["protocol_version"] == "motion.v2"
|
|
assert payload["matched_gesture"] == "rotate_left"
|
|
assert payload["confidence"] == 0.91
|
|
assert payload["camera_id"] == "usb:0"
|
|
assert payload["joints"] == [
|
|
{"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98}
|
|
]
|
|
assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]]
|
|
assert "image" not in payload
|
|
assert "frame" not in payload
|
|
|
|
|
|
def test_v2_gesture_set_accepts_frontend_motion_gestures():
|
|
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=0)
|
|
|
|
for gesture in [
|
|
"rotate_up",
|
|
"rotate_down",
|
|
"focus_prev",
|
|
"focus_next",
|
|
"layer_prev",
|
|
"layer_next",
|
|
]:
|
|
event = state.accept(
|
|
GestureObservation(gesture, confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
|
)
|
|
|
|
assert event is not None
|
|
assert event.gesture == gesture
|
|
|
|
|
|
def test_dry_run_recognizer_produces_debug_skeleton():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
|
|
|
skeleton = server.recognizer.debug_skeleton(
|
|
None,
|
|
camera_id="dry-run:null-camera",
|
|
mode="single",
|
|
)
|
|
|
|
assert skeleton is not None
|
|
assert skeleton.type == "skeleton"
|
|
assert skeleton.camera_id == "dry-run:null-camera"
|
|
assert skeleton.joints
|
|
assert skeleton.bones
|
|
|
|
|
|
class ServerRecognizerStub:
|
|
name = "stub"
|
|
|
|
def recognize(self, frame):
|
|
_ = frame
|
|
return None
|
|
|
|
def debug_skeleton(self, frame, **kwargs):
|
|
_ = frame, kwargs
|
|
return None
|
|
|
|
|
|
def test_motion_server_prefers_camera_urls_over_usb_indexes():
|
|
server = MotionAgentServer(
|
|
MotionAgentConfig(
|
|
dry_run=False,
|
|
camera_indexes=(0,),
|
|
camera_urls=("rtsp://camera.example/live", "http://camera.example/video"),
|
|
),
|
|
recognizer=ServerRecognizerStub(),
|
|
)
|
|
|
|
assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"]
|
|
assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras)
|
|
|
|
|
|
def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch):
|
|
import builtins
|
|
|
|
original_import = builtins.__import__
|
|
original_exists = motion_cameras.Path.exists
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == "cv2":
|
|
raise ImportError("cv2 missing")
|
|
return original_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
monkeypatch.setattr(
|
|
motion_cameras.Path,
|
|
"exists",
|
|
lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self),
|
|
)
|
|
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
|
|
|
with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"):
|
|
camera.open()
|
|
|
|
|
|
def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch):
|
|
original_exists = motion_cameras.Path.exists
|
|
|
|
monkeypatch.setattr(
|
|
motion_cameras.Path,
|
|
"exists",
|
|
lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self),
|
|
)
|
|
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
|
|
|
with pytest.raises(MotionAgentCameraError, match="/dev/video0"):
|
|
camera.open()
|
|
|
|
|
|
def test_url_camera_reports_unreachable_stream(monkeypatch):
|
|
class BrokenCapture:
|
|
def __init__(self, _url):
|
|
pass
|
|
|
|
def isOpened(self):
|
|
return False
|
|
|
|
class Cv2Stub:
|
|
VideoCapture = BrokenCapture
|
|
|
|
import builtins
|
|
|
|
original_import = builtins.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == "cv2":
|
|
return Cv2Stub()
|
|
return original_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live"))
|
|
|
|
with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"):
|
|
camera.open()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys):
|
|
class BrokenServer:
|
|
def __init__(self, _config):
|
|
raise MotionAgentDependencyError("missing cv stack")
|
|
|
|
monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer)
|
|
|
|
exit_code = await motion_cli.async_main([])
|
|
|
|
captured = capsys.readouterr()
|
|
assert exit_code == 2
|
|
assert "Motion agent failed: missing cv stack" in captured.err
|
|
assert "Traceback" not in captured.err
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_motion_agent_command_updates_control_state():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
|
|
|
armed = await server.handle_command(
|
|
json.dumps(
|
|
{
|
|
"type": "command",
|
|
"command": "set_armed",
|
|
"request_id": "req-armed",
|
|
"payload": {"armed": True},
|
|
}
|
|
)
|
|
)
|
|
paused = await server.handle_command(
|
|
{
|
|
"type": "command",
|
|
"command": "set_paused",
|
|
"request_id": "req-paused",
|
|
"payload": {"paused": True},
|
|
}
|
|
)
|
|
|
|
assert armed.ok is True
|
|
assert armed.request_id == "req-armed"
|
|
assert armed.status["armed"] is True
|
|
assert paused.ok is True
|
|
assert paused.status["paused"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_motion_agent_open_devices_command_accepts_dual_mode():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
|
|
|
try:
|
|
result = await server.handle_command(
|
|
{
|
|
"type": "command",
|
|
"command": "open_devices",
|
|
"request_id": "req-open",
|
|
"payload": {"input_mode": "dual_redundant"},
|
|
}
|
|
)
|
|
|
|
assert result.ok is True
|
|
assert result.status["input_mode"] == "dual_redundant"
|
|
assert result.status["active_camera_ids"] == ("dry-run:null-camera",)
|
|
assert server._recognition_subprocess is not None
|
|
assert server._recognition_subprocess.returncode is None
|
|
finally:
|
|
await server.stop_recognition_subprocess()
|
|
|
|
|
|
def test_motion_agent_dual_fusion_merges_matching_observations():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
|
server.state.mode = "dual_redundant"
|
|
|
|
selected, fusion = server._fuse_observations(
|
|
[
|
|
GestureObservation("zoom_in", confidence=0.82, intensity=0.4, camera_id="usb:0"),
|
|
GestureObservation("zoom_in", confidence=0.86, intensity=0.8, camera_id="usb:1"),
|
|
]
|
|
)
|
|
|
|
assert selected.gesture == "zoom_in"
|
|
assert selected.camera_id == "fusion"
|
|
assert selected.confidence > 0.86
|
|
assert fusion == {
|
|
"source_cameras": ["usb:1", "usb:0"],
|
|
"window_ms": 120,
|
|
"reason": "matched_observations",
|
|
}
|
|
|
|
|
|
def test_motion_agent_dual_fusion_suppresses_close_conflict():
|
|
server = MotionAgentServer(MotionAgentConfig(dry_run=True, confidence_threshold=0.7))
|
|
|
|
selected, fusion = server._fuse_observations(
|
|
[
|
|
GestureObservation("zoom_in", confidence=0.82, intensity=0.5, camera_id="usb:0"),
|
|
GestureObservation("zoom_out", confidence=0.78, intensity=0.5, camera_id="usb:1"),
|
|
]
|
|
)
|
|
|
|
assert selected.gesture == "zoom_in"
|
|
assert selected.confidence == 0
|
|
assert fusion["reason"] == "conflict_ignored"
|