diff --git a/harbor/__init__.py b/harbor/__init__.py index 89ac2f7..128ad22 100644 --- a/harbor/__init__.py +++ b/harbor/__init__.py @@ -27,12 +27,14 @@ ViewerLeftUpdate, parse_message, ) +from .exceptions import HarborCommandError from .mqtt import HarborMQTTClient from .state import HarborDeviceState, HarborEventState, HarborSourceType, HarborViewer __all__ = [ "Harbor", "HarborCameraConfig", + "HarborCommandError", "HarborMQTTClient", "HarborDevice", "HarborCamera", diff --git a/harbor/core.py b/harbor/core.py index 6200c42..1720047 100644 --- a/harbor/core.py +++ b/harbor/core.py @@ -85,6 +85,34 @@ async def get_camera_settings( """Request the camera settings payload.""" return await self._get_client(serial).get_settings(timeout=timeout) + async def set_camera_on( + self, + serial: str, + camera_on: bool, + *, + viewer_id: str | None = None, + timeout: float = 10.0, + ) -> None: + """Turn a camera stream on or off and refresh its settings.""" + await self._get_client(serial).set_camera_on( + camera_on, + viewer_id=viewer_id, + timeout=timeout, + ) + + async def set_night_mode( + self, + serial: str, + night_mode: bool, + *, + timeout: float = 10.0, + ) -> None: + """Turn camera night mode on or off and refresh its settings.""" + await self._get_client(serial).set_night_mode( + night_mode, + timeout=timeout, + ) + async def handle_message(self, topic: str, payload: Any) -> None: """ Central message handler. diff --git a/harbor/devices/camera.py b/harbor/devices/camera.py index e20402a..b9ca6bd 100644 --- a/harbor/devices/camera.py +++ b/harbor/devices/camera.py @@ -4,11 +4,13 @@ import logging from ..config import HarborCameraConfig +from ..data.mqtt_models import SettingsEvent from ..device import HarborDevice from ..events import ( CameraEventUpdate, HarborEvent, LocalLivekitHeartbeatUpdate, + SettingsUpdate, ViewerInfo, ViewerJoinedUpdate, ViewerLeftUpdate, @@ -58,6 +60,8 @@ def _apply_event(self, event: HarborEvent) -> None: match event: case LocalLivekitHeartbeatUpdate(payload=payload, viewers=viewers): self._apply_local_livekit_heartbeat(payload, viewers) + case SettingsUpdate(payload=payload): + self._apply_camera_settings(payload) case ViewerJoinedUpdate(viewer=viewer): self._apply_viewer_joined(viewer) case ViewerLeftUpdate(viewer_id=viewer_id): @@ -65,6 +69,13 @@ def _apply_event(self, event: HarborEvent) -> None: case CameraEventUpdate(): self._apply_camera_event(event) + def _apply_camera_settings(self, payload: SettingsEvent) -> None: + """Apply camera controls exposed by a settings payload.""" + if payload.settings is not None and payload.settings.preference_stream_paused is not None: + self.state.values["camera_on"] = not payload.settings.preference_stream_paused + if payload.state is not None and payload.state.video_night_mode is not None: + self.state.values["night_mode"] = payload.state.video_night_mode + def _apply_local_livekit_heartbeat( self, payload, diff --git a/harbor/events.py b/harbor/events.py index 822155a..617046f 100644 --- a/harbor/events.py +++ b/harbor/events.py @@ -293,52 +293,52 @@ def parse_message( } if event_key == "heartbeat": - if typed_payload := _validate_payload(HeartbeatEvent, raw_payload, topic): - return HeartbeatUpdate(payload=typed_payload, **base_kwargs) + if heartbeat_payload := _validate_payload(HeartbeatEvent, raw_payload, topic): + return HeartbeatUpdate(payload=heartbeat_payload, **base_kwargs) return RawEventUpdate(payload=raw_payload, **base_kwargs) if event_key == "local_livekit_heartbeat": - if typed_payload := _validate_payload(LocalLivekitHeartbeatEvent, raw_payload, topic): + if livekit_payload := _validate_payload(LocalLivekitHeartbeatEvent, raw_payload, topic): return LocalLivekitHeartbeatUpdate( - payload=typed_payload, - viewers=_extract_viewers_from_local_livekit(typed_payload), + payload=livekit_payload, + viewers=_extract_viewers_from_local_livekit(livekit_payload), **base_kwargs, ) return RawEventUpdate(payload=raw_payload, **base_kwargs) if event_key == "viewer_joined": - if typed_payload := _validate_payload(ViewerJoinedEvent, raw_payload, topic): + if viewer_joined_payload := _validate_payload(ViewerJoinedEvent, raw_payload, topic): return ViewerJoinedUpdate( - payload=typed_payload, + payload=viewer_joined_payload, viewer=_extract_viewer_info_from_payload(raw_payload) or _extract_viewer_info( - typed_payload.viewer_id, - typed_payload.identity, - typed_payload.client, - typed_payload.is_local, - typed_payload.role, + viewer_joined_payload.viewer_id, + viewer_joined_payload.identity, + viewer_joined_payload.client, + viewer_joined_payload.is_local, + viewer_joined_payload.role, ), **base_kwargs, ) return RawEventUpdate(payload=raw_payload, **base_kwargs) if event_key == "viewer_left": - if typed_payload := _validate_payload(ViewerLeftEvent, raw_payload, topic): + if viewer_left_payload := _validate_payload(ViewerLeftEvent, raw_payload, topic): return ViewerLeftUpdate( - payload=typed_payload, + payload=viewer_left_payload, viewer_id=_extract_viewer_id_from_payload(raw_payload) or _coalesce_string( - typed_payload.viewer_id, - typed_payload.identity, - typed_payload.client, + viewer_left_payload.viewer_id, + viewer_left_payload.identity, + viewer_left_payload.client, ), **base_kwargs, ) return RawEventUpdate(payload=raw_payload, **base_kwargs) if event_key in {"settings", "get_settings"}: - if typed_payload := _validate_payload(SettingsEvent, raw_payload, topic): - return SettingsUpdate(payload=typed_payload, **base_kwargs) + if settings_payload := _validate_payload(SettingsEvent, raw_payload, topic): + return SettingsUpdate(payload=settings_payload, **base_kwargs) return RawEventUpdate(payload=raw_payload, **base_kwargs) if source_type == "camera": @@ -346,9 +346,9 @@ def parse_message( explicit_state = extract_explicit_event_state(raw_payload) if event_key == "motion_detection": - if typed_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic): + if motion_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic): return MotionDetectedUpdate( - payload=typed_payload, + payload=motion_payload, active_seconds=active_seconds, explicit_state=explicit_state, **base_kwargs, diff --git a/harbor/exceptions.py b/harbor/exceptions.py new file mode 100644 index 0000000..8b008e5 --- /dev/null +++ b/harbor/exceptions.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + + +class HarborCommandError(Exception): + """Raised when a Harbor camera rejects a command.""" + + def __init__(self, command: str, response: Any) -> None: + self.command = command + self.response = response + super().__init__(f"Harbor camera rejected command {command!r}: {response!r}") diff --git a/harbor/mqtt.py b/harbor/mqtt.py index 18f539c..d03dcf2 100644 --- a/harbor/mqtt.py +++ b/harbor/mqtt.py @@ -12,6 +12,7 @@ from .config import HarborCameraConfig from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent +from .exceptions import HarborCommandError from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context if TYPE_CHECKING: @@ -23,6 +24,9 @@ DEFAULT_COMMAND_QOS = 2 DEFAULT_REQUEST_TIMEOUT = 10.0 GET_SETTINGS_COMMAND = "get-settings" +PAUSE_STREAM_COMMAND = "pause-stream" +UNPAUSE_STREAM_COMMAND = "unpause-stream" +UPDATE_NIGHT_MODE_COMMAND = "update-night-mode" DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,) @@ -360,7 +364,7 @@ def _build_get_settings_payload( request = GetCameraSettingsRequest( seq=seq or _generate_seq(), client=client or self.client_id or f"harbor-client-{self.config.serial}", - triggered_by=triggered_by or "harbor-python", + triggeredBy=triggered_by or "harbor-python", ) return request.model_dump(by_alias=True) @@ -410,6 +414,71 @@ async def get_settings( ) return SettingsEvent.model_validate(response) + async def set_camera_on( + self, + camera_on: bool, + *, + viewer_id: str | None = None, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Turn the camera stream on or off and refresh its settings.""" + command = UNPAUSE_STREAM_COMMAND if camera_on else PAUSE_STREAM_COMMAND + await self._request_camera_control( + command, + {"viewer_id": viewer_id or self.client_id or f"harbor-client-{self.config.serial}"}, + timeout=timeout, + ) + await self._refresh_settings_after_command(command, timeout=timeout) + + async def set_night_mode( + self, + night_mode: bool, + *, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Turn camera night mode on or off and refresh its settings.""" + await self._request_camera_control( + UPDATE_NIGHT_MODE_COMMAND, + {"night_mode": night_mode}, + timeout=timeout, + ) + await self._refresh_settings_after_command( + UPDATE_NIGHT_MODE_COMMAND, + timeout=timeout, + ) + + async def _request_camera_control( + self, + command: str, + payload: dict[str, Any], + *, + timeout: float, + ) -> Any: + """Run a camera control command and reject error responses.""" + response = await self.request_command(command, payload, timeout=timeout) + if isinstance(response, dict) and ( + response.get("error") or ((status := response.get("status")) is not None and str(status).upper() != "OK") + ): + raise HarborCommandError(command, response) + return response + + async def _refresh_settings_after_command( + self, + command: str, + *, + timeout: float, + ) -> None: + """Refresh settings without turning a successful command into a failure.""" + try: + await self.get_settings(timeout=timeout) + except (ConnectionError, MqttError, TimeoutError): + _LOGGER.debug( + "Unable to refresh settings after Harbor command %s for camera %s", + command, + self.config.serial, + exc_info=True, + ) + def __del__(self) -> None: if self._stop_event and not self._stop_event.is_set(): self._stop_event.set() diff --git a/tests/test_camera_state.py b/tests/test_camera_state.py index 713e4cd..447edce 100644 --- a/tests/test_camera_state.py +++ b/tests/test_camera_state.py @@ -76,3 +76,41 @@ async def test_missing_enum_values_do_not_clear_state() -> None: assert camera.state.values["speaker_state"] == "idle" assert camera.state.values["stream_quality"] == "poor" + + +async def test_settings_update_maps_camera_control_state() -> None: + """Camera settings should expose normalized stream and night-mode state.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + { + "settings": {"preference_stream_paused": True}, + "state": {"video_night_mode": False}, + }, + ) + + assert camera.state.values["camera_on"] is False + assert camera.state.values["night_mode"] is False + + +async def test_missing_camera_settings_do_not_clear_control_state() -> None: + """Partial settings responses should preserve previously known controls.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + { + "settings": {"preference_stream_paused": False}, + "state": {"video_night_mode": True}, + }, + ) + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_display_name": "Nursery"}}, + ) + + assert camera.state.values["camera_on"] is True + assert camera.state.values["night_mode"] is True diff --git a/tests/test_core.py b/tests/test_core.py index 49face1..333c806 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import AsyncMock, patch + from harbor.config import HarborCameraConfig from harbor.core import Harbor from harbor.mqtt import DEFAULT_INITIAL_COMMANDS @@ -18,3 +20,42 @@ def test_camera_connection_populates_settings_on_connect() -> None: harbor.add_camera_connection(config) assert harbor._clients["TEST123"].initial_commands == DEFAULT_INITIAL_COMMANDS + + +async def test_camera_control_helpers_delegate_to_camera_client() -> None: + """The high-level API should expose camera controls by serial number.""" + + harbor = Harbor() + config = HarborCameraConfig( + serial="TEST123", + cert_path="/path/to/cert.pem", + key_path="/path/to/key.pem", + ) + harbor.add_camera_connection(config) + client = harbor._clients["TEST123"] + with ( + patch.object( + client, + "set_camera_on", + AsyncMock(), + ) as set_camera_on, + patch.object( + client, + "set_night_mode", + AsyncMock(), + ) as set_night_mode, + ): + await harbor.set_camera_on( + "TEST123", + False, + viewer_id="home-assistant", + timeout=3, + ) + await harbor.set_night_mode("TEST123", True, timeout=4) + + set_camera_on.assert_awaited_once_with( + False, + viewer_id="home-assistant", + timeout=3, + ) + set_night_mode.assert_awaited_once_with(True, timeout=4) diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index a897b94..cd66541 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -2,10 +2,20 @@ import asyncio import json +from typing import Any, cast +from unittest.mock import AsyncMock, patch from harbor.config import HarborCameraConfig +from harbor.data.mqtt_models import Settings, SettingsEvent, SettingsState from harbor.events import HarborEvent -from harbor.mqtt import GET_SETTINGS_COMMAND, HarborMQTTClient +from harbor.exceptions import HarborCommandError +from harbor.mqtt import ( + GET_SETTINGS_COMMAND, + PAUSE_STREAM_COMMAND, + UNPAUSE_STREAM_COMMAND, + UPDATE_NIGHT_MODE_COMMAND, + HarborMQTTClient, +) def _create_config() -> HarborCameraConfig: @@ -185,7 +195,7 @@ async def message_handler(topic: str, payload: object) -> None: ) fake_client = _FakePublishClient() client.connected = True - client._client = fake_client + client._client = cast(Any, fake_client) task = asyncio.create_task( client.request_command( @@ -231,7 +241,7 @@ async def message_handler(topic: str, payload: object) -> None: ) fake_client = _FakePublishClient() client.connected = True - client._client = fake_client + client._client = cast(Any, fake_client) task = asyncio.create_task(client.get_settings(client="test-client", triggered_by="users/user1", timeout=1)) await asyncio.sleep(0) @@ -266,6 +276,158 @@ async def message_handler(topic: str, payload: object) -> None: assert settings.settings.preference_display_name == "Nursery" +async def test_set_camera_on_runs_protocol_command_and_refreshes_settings() -> None: + """Camera control should hide command details and return current settings.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + client_id="test-client", + ) + refreshed_settings = SettingsEvent(settings=Settings(preference_stream_paused=False)) + + with ( + patch.object( + client, + "request_command", + AsyncMock(return_value={"status": "OK"}), + ) as request_command, + patch.object( + client, + "get_settings", + AsyncMock(return_value=refreshed_settings), + ) as get_settings, + ): + await client.set_camera_on( + True, + viewer_id="home-assistant", + timeout=3, + ) + + request_command.assert_awaited_once_with( + UNPAUSE_STREAM_COMMAND, + {"viewer_id": "home-assistant"}, + timeout=3, + ) + get_settings.assert_awaited_once_with(timeout=3) + + +async def test_set_camera_off_uses_default_viewer_id() -> None: + """Camera control should provide a stable viewer ID when none is supplied.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + client_id="test-client", + ) + + with ( + patch.object( + client, + "request_command", + AsyncMock(return_value={"message": "stream paused"}), + ) as request_command, + patch.object( + client, + "get_settings", + AsyncMock(return_value=SettingsEvent()), + ), + ): + await client.set_camera_on(False) + + request_command.assert_awaited_once_with( + PAUSE_STREAM_COMMAND, + {"viewer_id": "test-client"}, + timeout=10.0, + ) + + +async def test_set_night_mode_runs_protocol_command_and_refreshes_settings() -> None: + """Night-mode control should hide command details and refresh state.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + refreshed_settings = SettingsEvent(state=SettingsState(video_night_mode=True)) + + with ( + patch.object( + client, + "request_command", + AsyncMock(return_value={"status": "OK"}), + ) as request_command, + patch.object( + client, + "get_settings", + AsyncMock(return_value=refreshed_settings), + ) as get_settings, + ): + await client.set_night_mode(True, timeout=4) + + request_command.assert_awaited_once_with( + UPDATE_NIGHT_MODE_COMMAND, + {"night_mode": True}, + timeout=4, + ) + get_settings.assert_awaited_once_with(timeout=4) + + +async def test_settings_refresh_failure_does_not_mask_successful_command() -> None: + """A post-command refresh failure should not report command failure.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with ( + patch.object( + client, + "request_command", + AsyncMock(return_value={"status": "OK"}), + ), + patch.object( + client, + "get_settings", + AsyncMock(side_effect=TimeoutError), + ), + ): + await client.set_night_mode(True) + + +async def test_camera_control_rejection_raises_library_error() -> None: + """Rejected commands should not be delegated to library consumers.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with ( + patch.object( + client, + "request_command", + AsyncMock(return_value={"status": "ERROR", "error": "not allowed"}), + ), + patch.object(client, "get_settings", AsyncMock()) as get_settings, + ): + try: + await client.set_night_mode(True) + except HarborCommandError as err: + assert err.command == UPDATE_NIGHT_MODE_COMMAND + assert err.response == {"status": "ERROR", "error": "not allowed"} + else: + raise AssertionError("Expected HarborCommandError") + + get_settings.assert_not_awaited() + + async def test_initial_commands_publish_get_settings_without_waiting() -> None: """Initial populate commands should request settings after connection.""" @@ -281,7 +443,7 @@ async def message_handler(topic: str, payload: object) -> None: ) fake_client = _FakePublishClient() client.connected = True - client._client = fake_client + client._client = cast(Any, fake_client) await client._publish_initial_commands()