diff --git a/README.md b/README.md index a767604..76f493d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,84 @@ settings = await harbor.get_camera_settings("CAMERA_SERIAL") print(settings.settings) ``` +### Camera controls + +```python +await harbor.set_camera_on("CAMERA_SERIAL", False) # privacy: pause the stream +await harbor.set_night_mode("CAMERA_SERIAL", "auto") # "auto" | "on" | "off" +await harbor.set_video_flip("CAMERA_SERIAL", True) # rotate the image 180° +await harbor.set_clock_display("CAMERA_SERIAL", False) # clock overlay on the video +await harbor.set_temperature_scale("CAMERA_SERIAL", "C") # "F" | "C" +await harbor.update_camera_settings( + "CAMERA_SERIAL", {"preference_video_ir_brightness": 40} +) +``` + +Each control writes one preference and refreshes device state, so +`camera.state.values` reflects the change once the call returns: + +| `camera.state.values[...]` | Type | Setting | +|---------------------------|------|---------| +| `camera_on` | `bool` | `preference_stream_paused` (inverted) | +| `night_mode_preference` | `"auto"` \| `"on"` \| `"off"` | `preference_video_night_mode` | +| `night_mode` | `bool` | runtime IR state (read-only, see below) | +| `video_flip` | `bool` | `preference_video_flip` | +| `clock_display` | `bool` | `preference_video_has_clock_display` | +| `temperature_scale` | `"F"` \| `"C"` | `preference_temperature_scale` | + +`set_video_flip` and `set_clock_display` take real booleans — `1`/`0` and +`"true"` raise `ValueError` rather than being sent as a number or string, +which the firmware would reject. + +The enum setters validate against the firmware's own option list, exported as +`NIGHT_MODE_MODES` and `TEMPERATURE_SCALES`. Matching is exact: `"f"` raises +`ValueError`, because the device compares the string verbatim. State values +preserve case for the same reason — what you read back is always something you +can write. + +#### Night mode + +Night mode is a **three-way preference**, not a boolean — `"auto"`, `"on"` or +`"off"` (default `"auto"`). Passing a bool raises `ValueError` rather than +guessing at a mode. A camera exposes two separate night-mode values: + +| `camera.state.values[...]` | Type | Meaning | +|---------------------------|------|---------| +| `night_mode_preference` | `"auto"` \| `"on"` \| `"off"` | The setting. This is what `set_night_mode` writes and what reads back. | +| `night_mode` | `bool` | Whether IR is engaged *right now*. Read-only and device-driven — under `"auto"` it flips on its own as light levels change. | + +Consumers building a UI entity should bind it to `night_mode_preference`, since +`night_mode` moves independently of any command. + +### Command errors + +A rejected command raises `HarborCommandError`, which carries the parsed +`status` and the firmware's per-field `errors` list: + +```python +from harbor import HarborCommandError, HarborUnsupportedCommandError + +try: + await harbor.set_night_mode("CAMERA_SERIAL", "on") +except HarborUnsupportedCommandError: + ... # firmware has no such command; permanent, so stop offering the feature +except HarborCommandError as err: + print(err.status, err.errors) # e.g. "REQUEST_MALFORMED", [{"error_code": "INVALID_VALUE", ...}] +``` + +`HarborUnsupportedCommandError` is a subclass of `HarborCommandError`, raised +only on a `RESOURCE_NOT_FOUND` status. That means the firmware has no handler +for the command at all, so retrying can never succeed. + +### Firmware compatibility + +Commands are verified against real hardware, most recently a camera running +`os_version` **2.8.0** / `app_version` **2.8.0-rc1+c1b0a32**: `ping`, +`get-settings`, `update-settings`, `pause-stream`, `unpause-stream`, +`set-night-mode-ir-brightness`, `update-operating-mode`, `set-scheduled-reboot` +and `list-viewers` all respond `OK`. See `mqtt_home_assistant.md` for the full +audit and payload shapes. + ## WHIP Endpoint Harbor cameras allow custom WHIP endpoints. This tells the camera where to stream and works with tools that support WHIP, including go2rtc and Frigate. diff --git a/harbor/__init__.py b/harbor/__init__.py index 128ad22..3c9e56e 100644 --- a/harbor/__init__.py +++ b/harbor/__init__.py @@ -5,12 +5,21 @@ HeartbeatEvent, LocalLivekitHeartbeatEvent, MotionDetectedEvent, + Settings, SettingsEvent, + SettingsState, + UpdateCameraSettingsRequest, ViewerJoinedEvent, ViewerLeftEvent, ) from .device import HarborDevice -from .devices.camera import SPEAKER_STATES, STREAM_QUALITIES, HarborCamera +from .devices.camera import ( + NIGHT_MODE_PREFERENCES, + SPEAKER_STATES, + STREAM_QUALITIES, + TEMPERATURE_SCALE_VALUES, + HarborCamera, +) from .devices.monitor import HarborMonitor from .events import ( CameraEventUpdate, @@ -27,14 +36,27 @@ ViewerLeftUpdate, parse_message, ) -from .exceptions import HarborCommandError -from .mqtt import HarborMQTTClient +from .exceptions import HarborCommandError, HarborUnsupportedCommandError +from .mqtt import ( + CLOCK_DISPLAY_PREFERENCE_KEY, + DEFAULT_NIGHT_MODE, + DEFAULT_TEMPERATURE_SCALE, + NIGHT_MODE_MODES, + NIGHT_MODE_PREFERENCE_KEY, + TEMPERATURE_SCALE_PREFERENCE_KEY, + TEMPERATURE_SCALES, + VIDEO_FLIP_PREFERENCE_KEY, + HarborMQTTClient, + NightMode, + TemperatureScale, +) from .state import HarborDeviceState, HarborEventState, HarborSourceType, HarborViewer __all__ = [ "Harbor", "HarborCameraConfig", "HarborCommandError", + "HarborUnsupportedCommandError", "HarborMQTTClient", "HarborDevice", "HarborCamera", @@ -53,9 +75,12 @@ "ViewerInfo", "parse_message", "GetCameraSettingsRequest", + "UpdateCameraSettingsRequest", "HeartbeatEvent", "LocalLivekitHeartbeatEvent", + "Settings", "SettingsEvent", + "SettingsState", "ViewerJoinedEvent", "ViewerLeftEvent", "MotionDetectedEvent", @@ -63,6 +88,18 @@ "HarborViewer", "HarborEventState", "HarborDeviceState", + "NightMode", + "NIGHT_MODE_MODES", + "NIGHT_MODE_PREFERENCE_KEY", + "NIGHT_MODE_PREFERENCES", + "DEFAULT_NIGHT_MODE", + "VIDEO_FLIP_PREFERENCE_KEY", + "CLOCK_DISPLAY_PREFERENCE_KEY", + "TemperatureScale", + "TEMPERATURE_SCALES", + "TEMPERATURE_SCALE_PREFERENCE_KEY", + "TEMPERATURE_SCALE_VALUES", + "DEFAULT_TEMPERATURE_SCALE", "SPEAKER_STATES", "STREAM_QUALITIES", ] diff --git a/harbor/core.py b/harbor/core.py index 1720047..cf5dbd0 100644 --- a/harbor/core.py +++ b/harbor/core.py @@ -4,7 +4,12 @@ from .config import HarborCameraConfig from .data.mqtt_models import SettingsEvent from .device import HarborDevice -from .mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient +from .mqtt import ( + DEFAULT_INITIAL_COMMANDS, + HarborMQTTClient, + NightMode, + TemperatureScale, +) _LOGGER = logging.getLogger(__name__) @@ -103,16 +108,71 @@ async def set_camera_on( async def set_night_mode( self, serial: str, - night_mode: bool, + night_mode: NightMode, *, timeout: float = 10.0, ) -> None: - """Turn camera night mode on or off and refresh its settings.""" + """Set the camera night-mode preference and refresh its settings. + + ``night_mode`` is one of ``"auto"``, ``"on"`` or ``"off"``. + """ await self._get_client(serial).set_night_mode( night_mode, timeout=timeout, ) + async def set_temperature_scale( + self, + serial: str, + temperature_scale: TemperatureScale, + *, + timeout: float = 10.0, + ) -> None: + """Set the camera temperature unit (``"F"`` or ``"C"``).""" + await self._get_client(serial).set_temperature_scale( + temperature_scale, + timeout=timeout, + ) + + async def set_video_flip( + self, + serial: str, + video_flip: bool, + *, + timeout: float = 10.0, + ) -> None: + """Rotate the camera image 180 degrees and refresh its settings.""" + await self._get_client(serial).set_video_flip( + video_flip, + timeout=timeout, + ) + + async def set_clock_display( + self, + serial: str, + clock_display: bool, + *, + timeout: float = 10.0, + ) -> None: + """Show or hide the video clock overlay and refresh its settings.""" + await self._get_client(serial).set_clock_display( + clock_display, + timeout=timeout, + ) + + async def update_camera_settings( + self, + serial: str, + settings: dict[str, Any], + *, + timeout: float = 10.0, + ) -> None: + """Write camera preferences and refresh its settings.""" + await self._get_client(serial).update_settings( + settings, + timeout=timeout, + ) + async def handle_message(self, topic: str, payload: Any) -> None: """ Central message handler. diff --git a/harbor/data/mqtt_models.py b/harbor/data/mqtt_models.py index 0a66444..0bfc82b 100644 --- a/harbor/data/mqtt_models.py +++ b/harbor/data/mqtt_models.py @@ -45,7 +45,12 @@ class HeartbeatEvent(HarborMQTTPayload): class Settings(HarborMQTTPayload): - """Camera settings included with a settings event.""" + """Camera settings included with a settings event. + + These are the writable *preferences*. Send changes with the + ``update-settings`` command; do not confuse them with the device-driven + runtime values in :class:`SettingsState`. + """ log_level: str | None = None preference_ai: dict[str, Any] = Field(default_factory=dict) @@ -56,36 +61,55 @@ class Settings(HarborMQTTPayload): preference_connection_bssid: str | None = None preference_display_name: str | None = None preference_moment_length: int | None = None + preference_no_alert_windows: list[Any] = Field(default_factory=list) preference_operating_mode: str | None = None preference_scheduled_reboot: str | None = None preference_silence_alerting_until: str | None = None + preference_stream_config: dict[str, Any] = Field(default_factory=dict) preference_stream_paused: bool | None = None + preference_temperature_calibration_offset: float | None = None + preference_temperature_scale: str | None = None + preference_video_brightness_low_light: int | None = None preference_video_clock_display_tz_abbrev: str | None = None preference_video_clock_display_tz_offset: int | None = None preference_video_flip: bool | None = None preference_video_has_clock_display: bool | None = None preference_video_ir_brightness: int | None = None + #: Night-mode *preference*: one of ``"auto"``, ``"on"`` or ``"off"`` + #: (default ``"auto"``). This is what ``update-settings`` writes. preference_video_night_mode: str | None = None class SettingsState(HarborMQTTPayload): - """Runtime state attached to a settings event.""" + """Runtime state attached to a settings event. + + Read-only observations owned by the device. Nothing here can be written; + use :class:`Settings` for that. + """ application_state: int | None = None network_bars: int | None = None stream_state: int | None = None temperature: float | None = None + #: Whether IR night vision is engaged *right now*. Device-driven: under + #: the ``"auto"`` preference it flips on its own as light levels change, + #: so it reflects the camera, not the last command sent. video_night_mode: bool | None = None + volume_baseline_current: float | None = None + volume_baseline_reference: float | None = None + volume_threshold_effective: float | None = None class SettingsEvent(HarborMQTTPayload): """Payload for a settings event.""" client: str | None = None + errors: list[Any] = Field(default_factory=list) is_updating: bool | None = Field(default=None, alias="isUpdating") seq: str | None = None settings: Settings | None = None state: SettingsState | None = None + status: str | None = None triggered_by: str | None = Field(default=None, alias="triggeredBy") updated: dict[str, Any] = Field(default_factory=dict) @@ -98,6 +122,20 @@ class GetCameraSettingsRequest(HarborMQTTPayload): triggered_by: str = Field(alias="triggeredBy") +class UpdateCameraSettingsRequest(HarborMQTTPayload): + """Payload for the update-settings camera command. + + ``settings`` carries only the preference keys being changed; the camera + merges them into its existing configuration and echoes back the applied + subset. + """ + + seq: str + settings: dict[str, Any] + client: str + triggered_by: str = Field(alias="triggeredBy") + + class ViewerJoinedEvent(HarborMQTTPayload): """Payload for a viewer joined event.""" diff --git a/harbor/devices/camera.py b/harbor/devices/camera.py index b9ca6bd..84f184d 100644 --- a/harbor/devices/camera.py +++ b/harbor/devices/camera.py @@ -31,6 +31,13 @@ UNKNOWN_ENUM_VALUE = "unknown" SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"}) STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"}) +# Accepted values for the writable night-mode preference, per the schema the +# firmware returns when it rejects an invalid value. +NIGHT_MODE_PREFERENCES = frozenset({"auto", "on", "off", "unknown"}) +# Settings whose value is matched verbatim by the device, so unlike the state +# enums above they are stored with their case intact -- a consumer must be able +# to write back exactly what it read. +TEMPERATURE_SCALE_VALUES = frozenset({"F", "C"}) class HarborCamera(HarborDevice): @@ -70,12 +77,65 @@ def _apply_event(self, event: HarborEvent) -> None: 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 + """Apply camera controls exposed by a settings payload. + + Night mode is two distinct values and both are surfaced: + ``night_mode_preference`` is the ``auto``/``on``/``off`` setting a + command writes and reads back, while ``night_mode`` is the runtime + observation of whether IR is engaged right now — which the device + flips on its own under the ``auto`` preference. + """ + if payload.settings is not None: + if payload.settings.preference_stream_paused is not None: + self.state.values["camera_on"] = not payload.settings.preference_stream_paused + if payload.settings.preference_video_flip is not None: + self.state.values["video_flip"] = payload.settings.preference_video_flip + if payload.settings.preference_video_has_clock_display is not None: + self.state.values["clock_display"] = payload.settings.preference_video_has_clock_display + if payload.settings.preference_video_night_mode is not None: + self.state.values["night_mode_preference"] = self._normalize_enum_value( + "night_mode_preference", + payload.settings.preference_video_night_mode, + NIGHT_MODE_PREFERENCES, + ) + if payload.settings.preference_temperature_scale is not None: + self.state.values["temperature_scale"] = self._normalize_choice_value( + "temperature_scale", + payload.settings.preference_temperature_scale, + TEMPERATURE_SCALE_VALUES, + ) 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 _normalize_choice_value( + self, + field_name: str, + value: str | None, + known_values: frozenset[str], + ) -> str | None: + """Clamp a settings value to a known member, preserving its case. + + Used for preferences the device matches verbatim (``"F"``, not + ``"f"``), so what a consumer reads back is exactly what it can write. + Unlike :meth:`_normalize_enum_value` this never lowercases. + """ + if value is None: + return None + if not value.strip(): + return None + if value not in known_values: + if (field_name, value) not in self._unexpected_enum_values: + self._unexpected_enum_values.add((field_name, value)) + _LOGGER.warning( + "Camera %s reported unexpected %s value %r (known values: %s)", + self.serial, + field_name, + value, + sorted(known_values), + ) + return UNKNOWN_ENUM_VALUE + return value + def _apply_local_livekit_heartbeat( self, payload, diff --git a/harbor/exceptions.py b/harbor/exceptions.py index 8b008e5..8b2e57f 100644 --- a/harbor/exceptions.py +++ b/harbor/exceptions.py @@ -2,11 +2,54 @@ from typing import Any +#: Status returned when the firmware has no handler registered for a command. +#: This is permanent for a given firmware build, not a transient failure. +RESOURCE_NOT_FOUND_STATUS = "RESOURCE_NOT_FOUND" + class HarborCommandError(Exception): - """Raised when a Harbor camera rejects a command.""" + """Raised when a Harbor camera rejects a command. + + ``status`` is the parsed ``status`` field of the camera response (upper + cased), or ``None`` when the camera reported a bare ``error`` with no + status. ``errors`` holds the per-field ``errors`` array the firmware + returns for validation failures, each entry carrying ``error_code``, + ``key``, ``value`` and the accepted ``schema``. + """ def __init__(self, command: str, response: Any) -> None: self.command = command self.response = response + self.status = _parse_status(response) + self.errors = _parse_errors(response) super().__init__(f"Harbor camera rejected command {command!r}: {response!r}") + + +class HarborUnsupportedCommandError(HarborCommandError): + """Raised when the camera firmware has no handler for a command. + + The camera answered ``RESOURCE_NOT_FOUND``, which means this firmware + build does not implement the command at all. Retrying cannot succeed, so + consumers should skip the feature (for example, decline to create an + entity) rather than surface a failure on every use. + """ + + +def _parse_status(response: Any) -> str | None: + """Return the upper-cased status string from a camera response.""" + if not isinstance(response, dict): + return None + status = response.get("status") + if status is None: + return None + return str(status).upper() + + +def _parse_errors(response: Any) -> list[Any]: + """Return the per-field error details from a camera response.""" + if not isinstance(response, dict): + return [] + errors = response.get("errors") + if isinstance(errors, list): + return errors + return [] diff --git a/harbor/mqtt.py b/harbor/mqtt.py index d03dcf2..6013758 100644 --- a/harbor/mqtt.py +++ b/harbor/mqtt.py @@ -5,14 +5,22 @@ import logging import sys from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from uuid import uuid4 from aiomqtt import Client, MqttError from .config import HarborCameraConfig -from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent -from .exceptions import HarborCommandError +from .data.mqtt_models import ( + GetCameraSettingsRequest, + SettingsEvent, + UpdateCameraSettingsRequest, +) +from .exceptions import ( + RESOURCE_NOT_FOUND_STATUS, + HarborCommandError, + HarborUnsupportedCommandError, +) from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context if TYPE_CHECKING: @@ -20,15 +28,44 @@ _LOGGER = logging.getLogger(__name__) +#: Night mode is a three-way preference on the device, not an on/off switch. +NightMode = Literal["auto", "on", "off"] + +#: Unit the camera reports temperatures in. Case-sensitive on the wire. +TemperatureScale = Literal["F", "C"] + DEFAULT_CONNECTION_GRACE_PERIOD = 90.0 DEFAULT_COMMAND_QOS = 2 DEFAULT_REQUEST_TIMEOUT = 10.0 GET_SETTINGS_COMMAND = "get-settings" +UPDATE_SETTINGS_COMMAND = "update-settings" PAUSE_STREAM_COMMAND = "pause-stream" UNPAUSE_STREAM_COMMAND = "unpause-stream" -UPDATE_NIGHT_MODE_COMMAND = "update-night-mode" DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,) +#: Settings key holding the writable night-mode preference. +NIGHT_MODE_PREFERENCE_KEY = "preference_video_night_mode" + +#: Accepted night-mode values, as reported by the firmware itself: rejecting an +#: invalid value returns ``{"options": ["auto", "on", "off"], "default": "auto"}``. +#: Night mode is a three-way preference, not a boolean. +NIGHT_MODE_MODES: tuple[NightMode, ...] = ("auto", "on", "off") +DEFAULT_NIGHT_MODE: NightMode = "auto" + +#: Settings key for rotating the video image 180 degrees. Genuinely a boolean: +#: the firmware reports ``{"default": false, "type": "boolean"}``. +VIDEO_FLIP_PREFERENCE_KEY = "preference_video_flip" + +#: Settings key for the on-video clock overlay. Genuinely a boolean: +#: the firmware reports ``{"default": true, "type": "boolean"}``. +CLOCK_DISPLAY_PREFERENCE_KEY = "preference_video_has_clock_display" + +#: Settings key for the temperature unit. Firmware schema: +#: ``{"default": "F", "options": ["F", "C"], "type": "string"}``. +TEMPERATURE_SCALE_PREFERENCE_KEY = "preference_temperature_scale" +TEMPERATURE_SCALES: tuple[TemperatureScale, ...] = ("F", "C") +DEFAULT_TEMPERATURE_SCALE: TemperatureScale = "F" + class HarborMQTTClient: def __init__( @@ -432,18 +469,98 @@ async def set_camera_on( async def set_night_mode( self, - night_mode: bool, + night_mode: NightMode, + *, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Set the camera night-mode preference and refresh its settings. + + ``night_mode`` is one of ``"auto"``, ``"on"`` or ``"off"`` — the + device models night mode as a three-way preference, so booleans are + rejected rather than guessed at. ``"auto"`` lets the camera engage IR + on its own in low light; the resulting runtime state is reported + separately as ``SettingsState.video_night_mode``. + """ + _require_choice( + "night_mode", + night_mode, + NIGHT_MODE_MODES, + "Night mode is a three-way preference, not a boolean.", + ) + await self.update_settings({NIGHT_MODE_PREFERENCE_KEY: night_mode}, timeout=timeout) + + async def set_temperature_scale( + self, + temperature_scale: TemperatureScale, + *, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Set the unit the camera reports temperatures in. + + ``temperature_scale`` is ``"F"`` or ``"C"``, upper case — the device + matches the value exactly. + """ + _require_choice("temperature_scale", temperature_scale, TEMPERATURE_SCALES) + await self.update_settings( + {TEMPERATURE_SCALE_PREFERENCE_KEY: temperature_scale}, + timeout=timeout, + ) + + async def set_video_flip( + self, + video_flip: bool, + *, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Rotate the camera image 180 degrees, or restore it upright.""" + await self.update_settings( + {VIDEO_FLIP_PREFERENCE_KEY: _require_bool("video_flip", video_flip)}, + timeout=timeout, + ) + + async def set_clock_display( + self, + clock_display: bool, *, timeout: float = DEFAULT_REQUEST_TIMEOUT, ) -> None: - """Turn camera night mode on or off and refresh its settings.""" + """Show or hide the clock overlay burned into the video.""" + await self.update_settings( + {CLOCK_DISPLAY_PREFERENCE_KEY: _require_bool("clock_display", clock_display)}, + timeout=timeout, + ) + + async def update_settings( + self, + settings: dict[str, Any], + *, + client: str | None = None, + triggered_by: str | None = None, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Write camera preferences via the update-settings command. + + ``settings`` maps preference keys (as they appear under ``settings`` + in a ``get-settings`` response) to their new values. The camera + validates each key and returns a per-field ``errors`` array when a + value is out of range, which is surfaced on + :class:`~harbor.exceptions.HarborCommandError`. + """ + request = UpdateCameraSettingsRequest( + seq=_generate_seq(), + settings=settings, + client=client or self.client_id or f"harbor-client-{self.config.serial}", + triggeredBy=triggered_by or "harbor-python", + ) + payload = request.model_dump(by_alias=True) await self._request_camera_control( - UPDATE_NIGHT_MODE_COMMAND, - {"night_mode": night_mode}, + UPDATE_SETTINGS_COMMAND, + payload, + seq=payload["seq"], timeout=timeout, ) await self._refresh_settings_after_command( - UPDATE_NIGHT_MODE_COMMAND, + UPDATE_SETTINGS_COMMAND, timeout=timeout, ) @@ -452,15 +569,33 @@ async def _request_camera_control( command: str, payload: dict[str, Any], *, + seq: str | None = None, 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 + """Run a camera control command and reject error responses. + + A ``RESOURCE_NOT_FOUND`` status means this firmware build has no + handler for the command, which no amount of retrying will fix, so it + is raised as :class:`HarborUnsupportedCommandError` to let consumers + skip the feature instead of failing on every use. + """ + response = await self.request_command(command, payload, seq=seq, timeout=timeout) + if not isinstance(response, dict): + return response + + status = response.get("status") + status_text = None if status is None else str(status).upper() + if not (response.get("error") or response.get("errors") or (status_text is not None and status_text != "OK")): + return response + + if status_text == RESOURCE_NOT_FOUND_STATUS: + _LOGGER.warning( + "Harbor: camera %s firmware does not support command %s", + self.config.serial, + command, + ) + raise HarborUnsupportedCommandError(command, response) + raise HarborCommandError(command, response) async def _refresh_settings_after_command( self, @@ -487,3 +622,26 @@ def __del__(self) -> None: def _generate_seq() -> str: """Generate a request sequence string echoed by Harbor responses.""" return uuid4().hex + + +def _require_choice(name: str, value: Any, choices: tuple[str, ...], hint: str = "") -> str: + """Reject a value outside the set the firmware accepts for a setting. + + Matching is exact: the device compares the string verbatim, so a + differently-cased value would be rejected on the wire anyway. + """ + if not isinstance(value, str) or value not in choices: + message = f"{name} must be one of {choices!r}, got {value!r}" + raise ValueError(f"{message}. {hint}" if hint else message) + return value + + +def _require_bool(name: str, value: Any) -> bool: + """Reject non-boolean input for a setting the device types as a boolean. + + ``1``/``0`` are ``int`` and would serialize as numbers, which the firmware + rejects, so they are refused here with a clearer message. + """ + if not isinstance(value, bool): + raise ValueError(f"{name} must be a bool, got {value!r}") + return value diff --git a/tests/test_camera_state.py b/tests/test_camera_state.py index 447edce..d110263 100644 --- a/tests/test_camera_state.py +++ b/tests/test_camera_state.py @@ -1,7 +1,12 @@ from __future__ import annotations from harbor.config import HarborCameraConfig -from harbor.devices.camera import SPEAKER_STATES, STREAM_QUALITIES, HarborCamera +from harbor.devices.camera import ( + NIGHT_MODE_PREFERENCES, + SPEAKER_STATES, + STREAM_QUALITIES, + HarborCamera, +) def _create_camera() -> HarborCamera: @@ -95,6 +100,128 @@ async def test_settings_update_maps_camera_control_state() -> None: assert camera.state.values["night_mode"] is False +async def test_night_mode_preference_and_runtime_state_are_separate_keys() -> None: + """The writable preference and the runtime observation must not collide. + + Under the "auto" preference the camera decides for itself whether IR is + engaged, so a consumer reading back what it wrote needs the preference, + not the runtime bool. + """ + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + { + "settings": {"preference_video_night_mode": "auto"}, + "state": {"video_night_mode": False}, + }, + ) + + assert camera.state.values["night_mode_preference"] == "auto" + assert camera.state.values["night_mode"] is False + + +async def test_night_mode_preference_tracks_written_value() -> None: + """Writing "on" must be readable back even before IR actually engages.""" + + camera = _create_camera() + + for mode in ("auto", "on", "off"): + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_video_night_mode": mode}}, + ) + assert camera.state.values["night_mode_preference"] == mode + assert camera.state.values["night_mode_preference"] in NIGHT_MODE_PREFERENCES + + +async def test_boolean_settings_are_exposed_as_state_values() -> None: + """Image flip and clock overlay should read back as plain booleans.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_video_flip": True, "preference_video_has_clock_display": False}}, + ) + + assert camera.state.values["video_flip"] is True + assert camera.state.values["clock_display"] is False + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_video_flip": False, "preference_video_has_clock_display": True}}, + ) + + assert camera.state.values["video_flip"] is False + assert camera.state.values["clock_display"] is True + + +async def test_missing_boolean_settings_do_not_clear_state() -> None: + """A partial settings payload must not drop known switch state.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_video_flip": True, "preference_video_has_clock_display": True}}, + ) + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_display_name": "Nursery"}}, + ) + + assert camera.state.values["video_flip"] is True + assert camera.state.values["clock_display"] is True + + +async def test_choice_settings_preserve_case_in_state() -> None: + """Verbatim-matched settings must read back exactly as they are written.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_temperature_scale": "F"}}, + ) + + # "F", not "f" -- writing back a lowercased value would be rejected. + assert camera.state.values["temperature_scale"] == "F" + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_temperature_scale": "C"}}, + ) + assert camera.state.values["temperature_scale"] == "C" + + +async def test_unexpected_choice_settings_map_to_unknown() -> None: + """Values outside the firmware option list are clamped, not stored raw.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_temperature_scale": "K"}}, + ) + + assert camera.state.values["temperature_scale"] == "unknown" + + +async def test_unexpected_night_mode_preference_maps_to_unknown() -> None: + """An unrecognized preference is clamped like other enum state values.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/responses/get-settings", + {"settings": {"preference_video_night_mode": "SCHEDULED"}}, + ) + + assert camera.state.values["night_mode_preference"] == "unknown" + + async def test_missing_camera_settings_do_not_clear_control_state() -> None: """Partial settings responses should preserve previously known controls.""" @@ -103,7 +230,7 @@ async def test_missing_camera_settings_do_not_clear_control_state() -> None: await camera.handle_message( "cameras/TEST123/responses/get-settings", { - "settings": {"preference_stream_paused": False}, + "settings": {"preference_stream_paused": False, "preference_video_night_mode": "on"}, "state": {"video_night_mode": True}, }, ) @@ -114,3 +241,4 @@ async def test_missing_camera_settings_do_not_clear_control_state() -> None: assert camera.state.values["camera_on"] is True assert camera.state.values["night_mode"] is True + assert camera.state.values["night_mode_preference"] == "on" diff --git a/tests/test_core.py b/tests/test_core.py index 333c806..375a840 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -51,11 +51,77 @@ async def test_camera_control_helpers_delegate_to_camera_client() -> None: viewer_id="home-assistant", timeout=3, ) - await harbor.set_night_mode("TEST123", True, timeout=4) + await harbor.set_night_mode("TEST123", "auto", 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) + set_night_mode.assert_awaited_once_with("auto", timeout=4) + + +async def test_boolean_control_helpers_delegate_to_camera_client() -> None: + """Switch-style controls should be reachable 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_video_flip", AsyncMock()) as set_video_flip, + patch.object(client, "set_clock_display", AsyncMock()) as set_clock_display, + ): + await harbor.set_video_flip("TEST123", True, timeout=3) + await harbor.set_clock_display("TEST123", False) + + set_video_flip.assert_awaited_once_with(True, timeout=3) + set_clock_display.assert_awaited_once_with(False, timeout=10.0) + + +async def test_choice_control_helpers_delegate_to_camera_client() -> None: + """Select-style controls should be reachable 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_temperature_scale", AsyncMock()) as set_temperature_scale: + await harbor.set_temperature_scale("TEST123", "C", timeout=3) + + set_temperature_scale.assert_awaited_once_with("C", timeout=3) + + +async def test_update_camera_settings_delegates_to_camera_client() -> None: + """Arbitrary preference writes should be reachable 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, "update_settings", AsyncMock()) as update_settings: + await harbor.update_camera_settings( + "TEST123", + {"preference_video_ir_brightness": 40}, + timeout=5, + ) + + update_settings.assert_awaited_once_with( + {"preference_video_ir_brightness": 40}, + timeout=5, + ) diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index cd66541..56c3e77 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -5,16 +5,24 @@ from typing import Any, cast from unittest.mock import AsyncMock, patch +import pytest + from harbor.config import HarborCameraConfig -from harbor.data.mqtt_models import Settings, SettingsEvent, SettingsState +from harbor.data.mqtt_models import Settings, SettingsEvent from harbor.events import HarborEvent -from harbor.exceptions import HarborCommandError +from harbor.exceptions import HarborCommandError, HarborUnsupportedCommandError from harbor.mqtt import ( + CLOCK_DISPLAY_PREFERENCE_KEY, GET_SETTINGS_COMMAND, + NIGHT_MODE_MODES, + NIGHT_MODE_PREFERENCE_KEY, PAUSE_STREAM_COMMAND, + TEMPERATURE_SCALE_PREFERENCE_KEY, UNPAUSE_STREAM_COMMAND, - UPDATE_NIGHT_MODE_COMMAND, + UPDATE_SETTINGS_COMMAND, + VIDEO_FLIP_PREFERENCE_KEY, HarborMQTTClient, + NightMode, ) @@ -180,6 +188,126 @@ async def publish(self, topic: str, payload: str, *, qos: int, retain: bool) -> self.published.append((topic, payload, qos, retain)) +def _connected_client( + client_id: str | None = "test-client", +) -> tuple[HarborMQTTClient, _FakePublishClient]: + """Create a client wired to a fake transport that records publishes.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + client_id=client_id, + ) + fake_client = _FakePublishClient() + client.connected = True + client._client = cast(Any, fake_client) + return client, fake_client + + +async def _respond(client: HarborMQTTClient, command: str, payload: dict) -> None: + """Feed a camera response back in on the matching responses topic.""" + + await client._handle_message( + f"cameras/TEST123/responses/{command}", + json.dumps(payload), + ) + + +async def test_set_camera_on_pins_topic_and_payload() -> None: + """unpause-stream must reach the wire with the documented payload.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(client.set_camera_on(True, viewer_id="home-assistant", timeout=1)) + await asyncio.sleep(0) + + topic, payload_raw, qos, retain = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/unpause-stream" + assert payload["viewer_id"] == "home-assistant" + assert isinstance(payload["seq"], str) + assert qos == 2 + assert retain is False + + await _respond(client, "unpause-stream", {"seq": payload["seq"], "status": "OK"}) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +async def test_set_camera_off_pins_topic_and_payload() -> None: + """pause-stream must reach the wire with the documented payload.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(client.set_camera_on(False, timeout=1)) + await asyncio.sleep(0) + + topic, payload_raw, _, _ = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/pause-stream" + assert payload["viewer_id"] == "test-client" + + await _respond(client, "pause-stream", {"seq": payload["seq"], "status": "OK"}) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +async def test_update_settings_pins_topic_and_payload() -> None: + """update-settings must match the shape the Harbor app publishes.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task( + client.update_settings( + {NIGHT_MODE_PREFERENCE_KEY: "auto", "preference_video_ir_brightness": 18}, + client="home-assistant", + triggered_by="users/user1", + timeout=1, + ) + ) + await asyncio.sleep(0) + + topic, payload_raw, qos, retain = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/update-settings" + assert payload["settings"] == { + NIGHT_MODE_PREFERENCE_KEY: "auto", + "preference_video_ir_brightness": 18, + } + assert payload["client"] == "home-assistant" + assert payload["triggeredBy"] == "users/user1" + assert isinstance(payload["seq"], str) + assert set(payload) == {"seq", "settings", "client", "triggeredBy"} + assert qos == 2 + assert retain is False + + # Response echoes back only the applied subset, as the firmware does. + await _respond( + client, + "update-settings", + {"seq": payload["seq"], "status": "OK", "settings": {NIGHT_MODE_PREFERENCE_KEY: "auto"}}, + ) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +async def test_update_settings_response_seq_must_match() -> None: + """A response carrying a different seq must not resolve the request.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(client.set_night_mode("on", timeout=0.15)) + await asyncio.sleep(0) + payload = json.loads(fake_client.published[0][1]) + + await _respond(client, "update-settings", {"seq": "some-other-seq", "status": "OK"}) + + with pytest.raises(TimeoutError): + await task + assert payload["seq"] != "some-other-seq" + + async def test_request_command_publishes_and_waits_for_matching_response() -> None: """Requests should publish to camera commands and resolve from response seq.""" @@ -308,6 +436,7 @@ async def test_set_camera_on_runs_protocol_command_and_refreshes_settings() -> N request_command.assert_awaited_once_with( UNPAUSE_STREAM_COMMAND, {"viewer_id": "home-assistant"}, + seq=None, timeout=3, ) get_settings.assert_awaited_once_with(timeout=3) @@ -340,19 +469,20 @@ async def test_set_camera_off_uses_default_viewer_id() -> None: request_command.assert_awaited_once_with( PAUSE_STREAM_COMMAND, {"viewer_id": "test-client"}, + seq=None, 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.""" + """Night-mode control should write the preference and refresh state.""" client = HarborMQTTClient( config=_create_config(), topics=[], message_handler=_noop_handler, + client_id="test-client", ) - refreshed_settings = SettingsEvent(state=SettingsState(video_night_mode=True)) with ( patch.object( @@ -363,19 +493,157 @@ async def test_set_night_mode_runs_protocol_command_and_refreshes_settings() -> patch.object( client, "get_settings", - AsyncMock(return_value=refreshed_settings), + AsyncMock(return_value=SettingsEvent()), ) as get_settings, ): - await client.set_night_mode(True, timeout=4) + await client.set_night_mode("on", timeout=4) - request_command.assert_awaited_once_with( - UPDATE_NIGHT_MODE_COMMAND, - {"night_mode": True}, - timeout=4, - ) + assert request_command.await_args is not None + command, payload = request_command.await_args.args + assert command == UPDATE_SETTINGS_COMMAND + assert payload["settings"] == {NIGHT_MODE_PREFERENCE_KEY: "on"} + assert payload["client"] == "test-client" + assert payload["triggeredBy"] == "harbor-python" get_settings.assert_awaited_once_with(timeout=4) +@pytest.mark.parametrize("mode", NIGHT_MODE_MODES) +async def test_set_night_mode_accepts_every_supported_mode(mode: NightMode) -> None: + """All three firmware-accepted modes should reach the wire verbatim.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(client.set_night_mode(mode, timeout=1)) + await asyncio.sleep(0) + + topic, payload_raw, _, _ = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/update-settings" + assert payload["settings"] == {NIGHT_MODE_PREFERENCE_KEY: mode} + + await _respond(client, "update-settings", {"seq": payload["seq"], "status": "OK"}) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +@pytest.mark.parametrize("mode", [True, False, "ON", "enabled", None, 1]) +async def test_set_night_mode_rejects_non_enum_values(mode: object) -> None: + """Booleans must not be silently coerced into a string mode.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with patch.object(client, "request_command", AsyncMock()) as request_command: + with pytest.raises(ValueError, match="night_mode must be one of"): + await client.set_night_mode(mode) # type: ignore[arg-type] + + request_command.assert_not_awaited() + + +@pytest.mark.parametrize( + ("method", "key", "value"), + [ + ("set_temperature_scale", TEMPERATURE_SCALE_PREFERENCE_KEY, "F"), + ("set_temperature_scale", TEMPERATURE_SCALE_PREFERENCE_KEY, "C"), + ], +) +async def test_choice_setting_pins_topic_and_payload(method: str, key: str, value: str) -> None: + """Enum settings reach the wire verbatim, case included.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(getattr(client, method)(value, timeout=1)) + await asyncio.sleep(0) + + topic, payload_raw, _, _ = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/update-settings" + assert payload["settings"] == {key: value} + + await _respond(client, "update-settings", {"seq": payload["seq"], "status": "OK"}) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +@pytest.mark.parametrize( + ("method", "value"), + [ + # Temperature scale is matched verbatim, so case matters. + ("set_temperature_scale", "f"), + ("set_temperature_scale", "c"), + ("set_temperature_scale", "celsius"), + ("set_temperature_scale", True), + ("set_temperature_scale", None), + ], +) +async def test_choice_setting_rejects_unknown_value(method: str, value: object) -> None: + """Values outside the firmware's option list never reach the wire.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with patch.object(client, "request_command", AsyncMock()) as request_command: + with pytest.raises(ValueError, match="must be one of"): + await getattr(client, method)(value) + + request_command.assert_not_awaited() + + +@pytest.mark.parametrize( + ("method", "key"), + [ + ("set_video_flip", VIDEO_FLIP_PREFERENCE_KEY), + ("set_clock_display", CLOCK_DISPLAY_PREFERENCE_KEY), + ], +) +@pytest.mark.parametrize("value", [True, False]) +async def test_boolean_setting_pins_topic_and_payload(method: str, key: str, value: bool) -> None: + """Boolean settings are written as JSON booleans on the update-settings topic.""" + + client, fake_client = _connected_client() + + task = asyncio.create_task(getattr(client, method)(value, timeout=1)) + await asyncio.sleep(0) + + topic, payload_raw, qos, retain = fake_client.published[0] + payload = json.loads(payload_raw) + assert topic == "cameras/TEST123/update-settings" + assert payload["settings"] == {key: value} + # A JSON bool, not 1/0 -- the firmware types these as boolean. + assert f'"{key}":{"true" if value else "false"}' in payload_raw + assert set(payload) == {"seq", "settings", "client", "triggeredBy"} + assert qos == 2 + assert retain is False + + await _respond(client, "update-settings", {"seq": payload["seq"], "status": "OK"}) + with patch.object(client, "get_settings", AsyncMock(return_value=SettingsEvent())): + await task + + +@pytest.mark.parametrize("method", ["set_video_flip", "set_clock_display"]) +@pytest.mark.parametrize("value", [1, 0, "true", "on", None]) +async def test_boolean_setting_rejects_non_bool(method: str, value: object) -> None: + """Truthy stand-ins must not be sent as numbers or strings.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with patch.object(client, "request_command", AsyncMock()) as request_command: + with pytest.raises(ValueError, match="must be a bool"): + await getattr(client, method)(value) + + request_command.assert_not_awaited() + + async def test_settings_refresh_failure_does_not_mask_successful_command() -> None: """A post-command refresh failure should not report command failure.""" @@ -397,7 +665,7 @@ async def test_settings_refresh_failure_does_not_mask_successful_command() -> No AsyncMock(side_effect=TimeoutError), ), ): - await client.set_night_mode(True) + await client.set_night_mode("auto") async def test_camera_control_rejection_raises_library_error() -> None: @@ -408,26 +676,98 @@ async def test_camera_control_rejection_raises_library_error() -> None: topics=[], message_handler=_noop_handler, ) + response = {"status": "ERROR", "error": "not allowed"} with ( - patch.object( - client, - "request_command", - AsyncMock(return_value={"status": "ERROR", "error": "not allowed"}), - ), + patch.object(client, "request_command", AsyncMock(return_value=response)), + patch.object(client, "get_settings", AsyncMock()) as get_settings, + ): + with pytest.raises(HarborCommandError) as excinfo: + await client.set_night_mode("on") + + assert excinfo.value.command == UPDATE_SETTINGS_COMMAND + assert excinfo.value.response == response + assert excinfo.value.status == "ERROR" + assert not isinstance(excinfo.value, HarborUnsupportedCommandError) + get_settings.assert_not_awaited() + + +async def test_unsupported_command_raises_distinct_error() -> None: + """RESOURCE_NOT_FOUND is permanent and must be distinguishable.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + response = {"seq": "seq-1", "status": "RESOURCE_NOT_FOUND"} + + with ( + patch.object(client, "request_command", AsyncMock(return_value=response)), 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") + with pytest.raises(HarborUnsupportedCommandError) as excinfo: + await client.set_night_mode("on") + # Still a HarborCommandError, so existing handlers keep working. + assert isinstance(excinfo.value, HarborCommandError) + assert excinfo.value.status == "RESOURCE_NOT_FOUND" + assert excinfo.value.command == UPDATE_SETTINGS_COMMAND get_settings.assert_not_awaited() +async def test_invalid_setting_value_surfaces_field_errors() -> None: + """The firmware's per-field errors array should reach the caller parsed.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + # Shape captured from firmware when writing an out-of-range value. + response = { + "status": "REQUEST_MALFORMED", + "errors": [ + { + "error_code": "INVALID_VALUE", + "key": f"/{NIGHT_MODE_PREFERENCE_KEY}", + "schema": {"default": "auto", "options": ["auto", "on", "off"], "type": "string"}, + "value": "bogus-mode", + } + ], + } + + with ( + patch.object(client, "request_command", AsyncMock(return_value=response)), + patch.object(client, "get_settings", AsyncMock()), + ): + with pytest.raises(HarborCommandError) as excinfo: + await client.update_settings({NIGHT_MODE_PREFERENCE_KEY: "bogus-mode"}) + + assert excinfo.value.status == "REQUEST_MALFORMED" + assert excinfo.value.errors[0]["error_code"] == "INVALID_VALUE" + assert not isinstance(excinfo.value, HarborUnsupportedCommandError) + + +async def test_error_without_status_still_raises_with_null_status() -> None: + """A bare error payload has no status but is still a rejection.""" + + client = HarborMQTTClient( + config=_create_config(), + topics=[], + message_handler=_noop_handler, + ) + + with ( + patch.object(client, "request_command", AsyncMock(return_value={"error": "nope"})), + patch.object(client, "get_settings", AsyncMock()), + ): + with pytest.raises(HarborCommandError) as excinfo: + await client.set_night_mode("off") + + assert excinfo.value.status is None + + async def test_initial_commands_publish_get_settings_without_waiting() -> None: """Initial populate commands should request settings after connection."""