diff --git a/README.md b/README.md index 76f493d..30dbc9a 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,64 @@ go2rtc: Replace `CAMERA_SERIAL` with your camera serial number and `192.168.1.10` with the IP address of your go2rtc or Frigate server. +### Repairing packet loss + +WHIP media runs over UDP by default, and go2rtc does not negotiate +retransmission. `RegisterDefaultCodecs` in `pkg/webrtc/api.go` registers no RTX +(RFC 4588) codec, so go2rtc strips `rtx/90000` out of its SDP answer while still +advertising `a=rtcp-fb:96 nack`. Harbor cameras offer RTX correctly, but the +answer deletes the channel those retransmissions would travel on — go2rtc asks +the camera to retransmit over something it just removed, so nothing is ever +repaired. This is true as of go2rtc 1.9.10 and current master. + +The symptom is occasional blocky or smeared frames that clear on the next +keyframe. Each lost packet damages the frame it belonged to, so even a very low +loss rate is visible — 0.03% loss on a 20 fps stream is a handful of damaged +frames every few minutes. + +Running the WHIP session over ICE-TCP sidesteps this. The kernel retransmits +lost segments and delivers them in order, with no SDP negotiation involved: + +```yaml +webrtc: + listen: ":8555/tcp" + candidates: + - 192.168.1.10:8555 + filters: + networks: [tcp4] +``` + +Nest that block under `go2rtc:` if you are configuring Frigate. + +Both settings are required. `filters` on its own is not enough: with a bare +`listen: ":8555"`, go2rtc registers every entry in `candidates` as **both** a TCP +and a UDP candidate, so it would keep advertising a UDP candidate that the filter +had stopped anything from listening on. + +Confirm it took effect by checking the producer's transport, which should read +`http+tcp`: + +```bash +curl -s "http://192.168.1.10:1984/api/streams?src=CAMERA_SERIAL" | grep protocol +``` + +Frigate does not expose port 1984 by default — use +`http://FRIGATE_HOST:5000/api/go2rtc/api/streams?src=CAMERA_SERIAL` instead. + +What this costs: + +- Loss becomes latency instead of corruption. A sustained wifi dropout shows up + as a stall rather than a glitch. +- `filters` applies to all inbound WebRTC, so browser live view moves to TCP as + well. If you watch streams from outside your network, forward **8555/tcp**, not + only UDP. +- The camera's congestion control stops having an effect, since TCP handles + pacing. That is not a concern at the camera's bitrate on a local network, but + it does mean the camera will not lower quality if the link saturates. + +Measured on one Harbor camera pushing 1728x1080 HEVC over wifi, video packet loss +over a 3 minute sample went from 0.039% to 0.000%. + ## Development ```bash diff --git a/harbor/__init__.py b/harbor/__init__.py index 3c9e56e..00c5767 100644 --- a/harbor/__init__.py +++ b/harbor/__init__.py @@ -5,6 +5,7 @@ HeartbeatEvent, LocalLivekitHeartbeatEvent, MotionDetectedEvent, + NoiseDetectedEvent, Settings, SettingsEvent, SettingsState, @@ -29,6 +30,7 @@ HeartbeatUpdate, LocalLivekitHeartbeatUpdate, MotionDetectedUpdate, + NoiseDetectedUpdate, RawEventUpdate, SettingsUpdate, ViewerInfo, @@ -72,6 +74,7 @@ "SettingsUpdate", "CameraEventUpdate", "MotionDetectedUpdate", + "NoiseDetectedUpdate", "ViewerInfo", "parse_message", "GetCameraSettingsRequest", @@ -84,6 +87,7 @@ "ViewerJoinedEvent", "ViewerLeftEvent", "MotionDetectedEvent", + "NoiseDetectedEvent", "HarborSourceType", "HarborViewer", "HarborEventState", diff --git a/harbor/data/mqtt_models.py b/harbor/data/mqtt_models.py index 0bfc82b..8b0e231 100644 --- a/harbor/data/mqtt_models.py +++ b/harbor/data/mqtt_models.py @@ -157,13 +157,53 @@ class ViewerLeftEvent(HarborMQTTPayload): class MotionDetectedEvent(HarborMQTTPayload): - """Payload for a motion detection event.""" + """Payload for a ``motion-detected`` event. + + Keys arrive in snake_case. ``duration`` is a unit-suffixed string such as + ``"10s"`` -- never parse it as a bare number, and never treat it as a hold + time; see :class:`NoiseDetectedEvent` for why. + """ + + active_config: str | None = None + duration: str | None = None + file_duration: str | None = None + filename: str | None = None + level: str | None = None + sensitivity: str | None = None + threshold: str | None = None + thumbnail: str | None = None + timestamp: str | None = None + + +class NoiseDetectedEvent(HarborMQTTPayload): + """Payload for a ``sound-anomaly-detected`` event. + + The firmware topic calls this a sound anomaly; the app presents it as an + alert for "sudden, loud sounds" and "sustained noises", hence the noise + naming used here. + + Field set verified against a live camera. The audio fields are dB strings + (``"-36.401137dB"``) and pair with the ``volume_baseline_current`` / + ``volume_baseline_reference`` / ``volume_threshold_effective`` values on + :class:`SettingsState`. + + ``duration`` is a unit-suffixed string (``"10s"``) that matches + ``file_duration``, the length of the recorded clip named by ``filename``. + It describes a detection window that has already closed by the time this + message is published, so it must not be used to decide how long the + detection "stays on" -- the camera never publishes a cleared counterpart at + all. + """ active_config: str | None = None - duration: str | float | int | None = None + baseline: str | None = None + baseline_reference: str | None = None + duration: str | None = None + file_duration: str | None = None filename: str | None = None level: str | None = None sensitivity: str | None = None threshold: str | None = None thumbnail: str | None = None timestamp: str | None = None + user_offset: str | None = None diff --git a/harbor/devices/camera.py b/harbor/devices/camera.py index 84f184d..29b74aa 100644 --- a/harbor/devices/camera.py +++ b/harbor/devices/camera.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging from ..config import HarborCameraConfig @@ -19,8 +18,10 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_CAMERA_EVENT_KEYS = ("motion_detection", "cry_detection", "noise_detection") -DEFAULT_EVENT_ACTIVE_SECONDS = 5.0 +#: Detection events the camera can report. The firmware publishes exactly two +#: detection topics (``motion-detected`` and ``sound-anomaly-detected``), so +#: seeding anything else would create an entity that can never fire. +DEFAULT_CAMERA_EVENT_KEYS = ("motion_detection", "noise_detection") # Known values for enumerated state fields. The device reports these in # mixed/upper case (e.g. "PLAYING", "GOOD"); they are normalized to the @@ -47,7 +48,6 @@ def __init__(self, config: HarborCameraConfig) -> None: """Initialize the camera device.""" super().__init__(config.serial, "camera") self.config = config - self._event_reset_handles: dict[str, asyncio.TimerHandle] = {} self._unexpected_enum_values: set[tuple[str, str]] = set() for event_key in DEFAULT_CAMERA_EVENT_KEYS: @@ -221,29 +221,24 @@ def _apply_viewer_left(self, viewer_id: str | None) -> None: self.state.values["num_viewers"] = len(self.state.viewers) def _apply_camera_event(self, event: CameraEventUpdate) -> None: - """Apply a transient camera event update.""" + """Record a camera detection event. + + Detections are edge triggers with no cleared counterpart, so this only + stamps when the event last fired. + + Earlier versions synthesized an ``is_on`` flag and held it open for a + duration parsed from the payload. That never worked: the firmware + reports ``"10s"``, which failed to parse and silently fell back to a + fixed 5s, so a 10-second detection was reported as a 5-second one and + every event looked identical. Holding the real value would not have + helped either -- the window has already closed by the time the message + is published. + """ event_state = self._ensure_camera_event(event.event_key, topic=event.topic) event_state.topic = event.topic event_state.last_seen = event.timestamp event_state.last_payload = event.raw_payload - if handle := self._event_reset_handles.pop(event.event_key, None): - handle.cancel() - - if event.explicit_state is False: - event_state.is_on = False - return - - event_state.is_on = True - active_seconds = event.active_seconds - if active_seconds <= 0: - active_seconds = DEFAULT_EVENT_ACTIVE_SECONDS - - loop = asyncio.get_running_loop() - self._event_reset_handles[event.event_key] = loop.call_later( - active_seconds, - lambda: asyncio.create_task(self._async_reset_event(event.event_key)), - ) _LOGGER.debug( "Camera %s received event on topic %s: %s", self.serial, @@ -251,19 +246,6 @@ def _apply_camera_event(self, event: CameraEventUpdate) -> None: event.raw_payload, ) - def shutdown(self) -> None: - """Release camera resources.""" - for handle in self._event_reset_handles.values(): - handle.cancel() - self._event_reset_handles.clear() - - async def _async_reset_event(self, event_key: str) -> None: - """Reset a transient event to off after its active period.""" - self._event_reset_handles.pop(event_key, None) - if event_state := self.state.events.get(event_key): - event_state.is_on = False - await self._emit_update() - def _ensure_camera_event( self, event_key: str, @@ -287,8 +269,6 @@ def _ensure_camera_event( def _event_name_from_key(event_key: str) -> str: """Return a user-facing event name for an event key.""" - if event_key == "cry_detection": - return "Cry detected" if event_key == "motion_detection": return "Motion detected" if event_key == "noise_detection": diff --git a/harbor/events.py b/harbor/events.py index 617046f..086817d 100644 --- a/harbor/events.py +++ b/harbor/events.py @@ -16,6 +16,7 @@ HeartbeatEvent, LocalLivekitHeartbeatEvent, MotionDetectedEvent, + NoiseDetectedEvent, SettingsEvent, ViewerJoinedEvent, ViewerLeftEvent, @@ -35,6 +36,7 @@ class EventType(StrEnum): VIEWER_LEFT = "viewer_left" SETTINGS = "settings" MOTION_DETECTION = "motion_detection" + NOISE_DETECTION = "noise_detection" CAMERA_EVENT = "camera_event" RAW = "raw" @@ -118,22 +120,33 @@ class SettingsUpdate(HarborEvent): @dataclass(slots=True, frozen=True, kw_only=True) class CameraEventUpdate(HarborEvent): - """A camera event that should be treated like a transient trigger.""" + """A camera detection event. + + These are edge triggers. The camera never publishes a matching "cleared" + message, so this update carries no duration or on/off state -- ``timestamp`` + is the whole signal. + """ payload: Any - active_seconds: float - explicit_state: bool | None event_type: EventType = field(init=False, default=EventType.CAMERA_EVENT) @dataclass(slots=True, frozen=True, kw_only=True) class MotionDetectedUpdate(CameraEventUpdate): - """A typed motion detection update.""" + """A typed ``motion-detected`` update.""" payload: MotionDetectedEvent event_type: EventType = field(init=False, default=EventType.MOTION_DETECTION) +@dataclass(slots=True, frozen=True, kw_only=True) +class NoiseDetectedUpdate(CameraEventUpdate): + """A typed ``sound-anomaly-detected`` update.""" + + payload: NoiseDetectedEvent + event_type: EventType = field(init=False, default=EventType.NOISE_DETECTION) + + EVENT_MESSAGE_MAP: dict[type[BaseModel], type[HarborEvent]] = { HeartbeatEvent: HeartbeatUpdate, LocalLivekitHeartbeatEvent: LocalLivekitHeartbeatUpdate, @@ -141,6 +154,7 @@ class MotionDetectedUpdate(CameraEventUpdate): ViewerLeftEvent: ViewerLeftUpdate, SettingsEvent: SettingsUpdate, MotionDetectedEvent: MotionDetectedUpdate, + NoiseDetectedEvent: NoiseDetectedUpdate, } @@ -148,13 +162,23 @@ class MotionDetectedUpdate(CameraEventUpdate): EventT = TypeVar("EventT", bound=HarborEvent) +#: Detection topics the camera actually publishes, mapped to their event key. +#: The firmware emits exactly two -- ``motion-detected`` and +#: ``sound-anomaly-detected``. There is no ``cry-detected`` or +#: ``noise-detected`` topic; the sound anomaly is the noise detector, and the +#: app presents it as an alert for "sudden, loud sounds" and "sustained +#: noises", hence ``noise_detection``. +DETECTION_TOPIC_EVENT_KEYS = { + "motion_detected": "motion_detection", + "sound_anomaly_detected": "noise_detection", +} + + def event_key_from_topic(event_name: str) -> str: """Normalize an event topic segment into a stable key.""" event_key = event_name.strip().replace("-", "_") - if event_key in {"motion_detected", "cry_detected", "noise_detected"}: - return event_key.removesuffix("_detected") + "_detection" - return event_key + return DETECTION_TOPIC_EVENT_KEYS.get(event_key, event_key) def parse_payload(payload: Any) -> Any: @@ -190,81 +214,6 @@ def parse_topic( return None, None, None -def extract_event_duration_seconds(payload: Any) -> float: - """Extract an event duration from a payload.""" - - default = 5.0 - if not isinstance(payload, Mapping): - return default - - duration = payload.get("duration") - if duration is None or isinstance(duration, bool): - return default - - if isinstance(duration, int | float): - return float(duration) if duration > 0 else default - - if not isinstance(duration, str): - return default - - duration = duration.strip() - if not duration: - return default - - try: - parsed = float(duration) - except ValueError: - parts = duration.split(":") - if len(parts) not in (2, 3): - return default - try: - numbers = [float(part) for part in parts] - except ValueError: - return default - if len(numbers) == 2: - minutes, seconds = numbers - total = minutes * 60 + seconds - else: - hours, minutes, seconds = numbers - total = hours * 3600 + minutes * 60 + seconds - return total if total > 0 else default - - return parsed if parsed > 0 else default - - -def extract_explicit_event_state(payload: Any) -> bool | None: - """Extract an explicit on/off state from a payload when available.""" - - if not isinstance(payload, Mapping): - return None - - for key in ( - "active", - "detected", - "is_active", - "is_detected", - "motion", - "motion_detected", - "cry", - "cry_detected", - "noise", - "noise_detected", - ): - value = payload.get(key) - if isinstance(value, bool): - return value - - state_value = payload.get("state") - if isinstance(state_value, str): - normalized = state_value.strip().lower() - if normalized in {"active", "detected", "on", "true"}: - return True - if normalized in {"inactive", "off", "false", "idle"}: - return False - - return None - - def parse_message( topic: str, payload: Any, @@ -342,24 +291,15 @@ def parse_message( return RawEventUpdate(payload=raw_payload, **base_kwargs) if source_type == "camera": - active_seconds = extract_event_duration_seconds(raw_payload) - explicit_state = extract_explicit_event_state(raw_payload) - if event_key == "motion_detection": if motion_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic): - return MotionDetectedUpdate( - payload=motion_payload, - active_seconds=active_seconds, - explicit_state=explicit_state, - **base_kwargs, - ) + return MotionDetectedUpdate(payload=motion_payload, **base_kwargs) - return CameraEventUpdate( - payload=raw_payload, - active_seconds=active_seconds, - explicit_state=explicit_state, - **base_kwargs, - ) + if event_key == "noise_detection": + if noise_payload := _validate_payload(NoiseDetectedEvent, raw_payload, topic): + return NoiseDetectedUpdate(payload=noise_payload, **base_kwargs) + + return CameraEventUpdate(payload=raw_payload, **base_kwargs) return RawEventUpdate(payload=raw_payload, **base_kwargs) diff --git a/harbor/state.py b/harbor/state.py index 2a06e74..681c938 100644 --- a/harbor/state.py +++ b/harbor/state.py @@ -22,12 +22,21 @@ class HarborViewer: @dataclass(slots=True) class HarborEventState: - """State for a transient Harbor camera event.""" + """Record of a Harbor camera detection event. + + Detections are edge triggers: the camera publishes ``motion-detected`` and + ``sound-anomaly-detected`` when something fires and never publishes a + counterpart to clear them. There is deliberately no ``is_on`` here -- an + on/off reading would have to be synthesized from a timer the device knows + nothing about, and the payload carries nothing that could size one. + ``last_seen`` is the authoritative signal; consumers that + need a momentary entity (Home Assistant's ``event`` platform, a device + trigger) should drive it from a change in ``last_seen``. + """ key: str topic: str friendly_name: str - is_on: bool = False last_seen: datetime | None = None last_payload: Any = None diff --git a/mqtt_home_assistant.md b/mqtt_home_assistant.md new file mode 100644 index 0000000..6a9d72b --- /dev/null +++ b/mqtt_home_assistant.md @@ -0,0 +1,395 @@ +# Harbor MQTT — Home Assistant Integration Guide + +This document describes the **safe subset** of Harbor's MQTT interface intended for a +Home Assistant (HA) integration. It covers the telemetry a camera publishes (for HA +*sensors*) and the commands it is safe to send (for HA *switches / buttons*). + +> ⚠️ **Scope note for whoever builds the HA integration:** Harbor exposes many more +> MQTT commands than are listed here. The ones omitted change WiFi/access-point +> credentials, firmware, boot partitions, remote-access tunnels, run shell commands, +> etc. **Do not add HA entities for anything not in this document.** A short +> deny-list is included at the end so you know what to avoid, but none of those should +> be wired into HA. If you think you need one, ask the Harbor team first. + +--- + +## 1. Connection + +Home Assistant connects to the camera's on-device NanoMQ broker on your LAN over +**mutual TLS**. The broker exposes two TLS listeners; **HA must use the external +listener** — it has its own certificate chain intended for third-party clients: + +| Listener | Bind | Cert chain | Use | +|----------|------|-----------|-----| +| Internal | `:8883` | camera cert, `root_ca.pem` CA | Harbor's own on-device services — **do not use** | +| **External** | **`:8884`** | external server cert + external CA | **This is the one for Home Assistant** | + +**How to connect (external listener):** +- **Host:** the camera on your LAN, **port `8884`**. +- **Protocol:** MQTT v5, TLS required. +- **Client certificate:** HA must present a client cert **signed by the external CA** + (the cert chain behind `/mnt/settings/ssl/external/...` on the device), *not* the + internal `camera.crt`. The broker sets `verify_peer = true` and + `fail_if_no_peer_cert = true`, so a client cert is mandatory — a connection without a + valid external cert is dropped. +- **Username/password:** none. The broker allows anonymous auth; the client + **certificate** is what authenticates HA. + +Other broker facts: +- **QoS:** `0` by default (configurable via the `MQTT_QOS` env var on the device). +- **Retain:** Harbor never publishes retained messages. HA must be subscribed *before* + an event fires to see it. Do not expect to read the "last state" from a retained topic. +- **Client ID:** pick a stable one for HA (e.g. `home-assistant`). + +> Note: the camera also mirrors this traffic to a separate Harbor cloud broker, but that +> is internal to Harbor — the HA integration neither connects to it nor needs it. + +Source: broker config `meta-harbor` → +`recipes-connectivity/nanomq/files/nanomq.conf` (listeners `ssl` :8883 and +`ssl.external` :8884); app side `src/messaging_protocols/mqtt_pubsub.cc`. + +--- + +## 2. Topic structure + +Every topic is namespaced under the camera's resource id, which is: + +``` +cameras/ +``` + +`` is the per-device id (env `CAMERA_ID`). From that root: + +| Purpose | Topic pattern | Direction | +|---------|---------------|-----------| +| Command (HA → camera) | `cameras//` | HA publishes | +| Command response | `cameras//responses/` | Camera publishes reply | +| Event / telemetry | `cameras//events/` | Camera publishes | +| Panic event | `cameras//events/panic/` | Camera publishes | + +The camera subscribes to `cameras//#` and, for any command it handles, +publishes the handler's result to `cameras//responses/`. + +**Request/response pattern:** send a JSON payload to the command topic; if you include a +`seq` field it is echoed back on the matching `responses/...` topic. Responses generally +carry a `status` string (`"OK"`, `"REQUEST_MALFORMED"`, etc.) and, on error, an `error` +message. + +Source: `src/application.cc:486` (subscribe), `:566-604` (routing + response), +`harbor-common/include/harbor-common/utils/topics.hh`. + +--- + +## 3. Telemetry to subscribe to (HA sensors) + +Subscribe to these `events/...` topics. All are safe to consume — they are read-only +signals the camera already emits. + +### `cameras//events/heartbeat` +Periodic device health. Good for temperature sensors and an "online" heartbeat. +```json +{ + "temperature": 42, + "raw_temperature": 42.3, + "sensor_temperature": 41.8, + "ntc_temperature": 40.1, + "ntc_adc_voltage": 0.83, + "image_sensor_temperature": 45.0, + "efuse_voltage": 0.91, + "os_version": "1.2.3", + "app_version": "2.7.1" +} +``` +Source: `src/application.cc:410`. + +### `cameras//events/local-livekit-heartbeat` +Streaming/network heartbeat (only sent while the stream is active and not updating). +Contains media-capture statistics plus: +```json +{ + "app_version": "2.7.1", + "os_version": "1.2.3", + "network_bars": 4 +} +``` +Use `network_bars` for a WiFi-signal sensor. Source: `src/application.cc:461`. + +### `cameras//events/up` +Published when the camera comes online / (re)subscribes. Useful as an availability +signal and to read current versions and state. +```json +{ + "os_version": "1.2.3", + "app_version": "2.7.1", + "release_version": "...", + "golden_image_crc32": "...", + "local_ip_address": "192.168.1.50", + "settings": { "...full settings object..." }, + "state": { "...video/stream state..." }, + "reboot_type": "...", "reboot_reason": "...", "reboot_timestamp": 0 +} +``` +> Note: the `settings` object here is the full device configuration. It's fine to read, +> but treat it as informational — surface only the fields you actually need in HA. +Source: `src/application.cc:515-565`. + +### `cameras//events/down` +Last-will + graceful-shutdown message; best signal for an "offline" binary sensor. +```json +{ "reason": "unexpected_disconnect", "app_version": "...", "os_version": "...", "release_version": "..." } +``` +Source: `src/messaging_protocols/mqtt_pubsub.cc:42-43,136,207`. + +### `cameras//events/settings` +Emitted whenever settings change. Mirror of the settings/state you'd get from +`get-settings`. Source: `src/application.cc:729`. + +### `cameras//events/sound-anomaly-detected` +Fires when the camera detects a sound anomaly (e.g. crying). Great for HA automations / +event entities. Payload is produced by the media pipeline (anomaly metadata). +Source: `src/messaging_protocols/health_monitor_server.cc:149`. + +### `cameras//events/motion-detected` +Fires on motion detection. Same shape/usage as the sound anomaly above. +Source: `src/messaging_protocols/health_monitor_server.cc:165`. + +### `cameras//events/operating-mode-changed` +Emitted when the operating mode changes. + +### `cameras//events/stream-status-updated` +Emitted when the live-stream status changes (started/stopped/paused). + +### `cameras//events/sleep-insights` +Emitted when a sleep-insights inference completes. + +### `cameras//events/update-event` +Software-update progress. +```json +{ "state": "...", "progress": 0, "reason": "..." } +``` +Source: `src/update_manager.cc` (UPDATE_EVENT). + +> Event names above map to the `EVENT(...)` macros in +> `harbor-common/include/harbor-common/utils/topics.hh` (lines 22-48). + +--- + +## 4. Commands that are safe to send (HA switches / buttons) + +Publish JSON to `cameras//`; read the reply on +`cameras//responses/`. Include a `seq` to correlate the response. + +### `ping` +Health check. Empty payload `{}`. Response: `{ "status": "OK" }`. +Source: `handlers.cc:830`. + +### `get-settings` +Read current settings + state. Empty payload `{}`. +```json +{ + "settings": { "..." }, + "state": { "..." }, + "is_updating": false +} +``` +Source: `handlers.cc:94`. + +### `pause-stream` / `unpause-stream` +Privacy toggle — turns the camera stream off / on. This is the natural HA "camera +on/off" switch. +```json +{ "viewer_id": "home-assistant" } +``` +`unpause-stream` returns an error if an update is in progress. Sources: +`handlers.cc:768` / `handlers.cc:791`. + +### `update-settings` +Write camera preferences. This is the command the official app uses for every +settings change — there is no per-setting command. +```json +{ + "seq": "", + "settings": { "preference_video_night_mode": "auto" }, + "client": "home-assistant", + "triggeredBy": "users/" +} +``` +`settings` carries only the keys being changed; the camera merges them and +echoes back the applied subset alongside `"status": "OK"`. On a bad value it +returns `status` `REQUEST_MALFORMED` plus an `errors` array that includes the +accepted schema: +```json +{ "errors": [ { "error_code": "INVALID_VALUE", + "key": "/preference_video_night_mode", + "schema": { "default": "auto", "options": ["auto","on","off"], "type": "string" }, + "value": "bogus-mode" } ], + "status": "REQUEST_MALFORMED" } +``` + +#### Boolean preferences (HA switches) +These two are genuine booleans and map directly onto switches: + +| Key | Default | Meaning | +|-----|---------|---------| +| `preference_video_flip` | `false` | Rotate the image 180° | +| `preference_video_has_clock_display` | `true` | Clock overlay burned into the video | + +```json +{ "settings": { "preference_video_flip": true } } +``` + +Send JSON booleans, not `1`/`0` — the firmware types these as `boolean` and +rejects a number. + +#### Enumerated preferences (HA selects) +Every enum below is validated by the firmware against a fixed option list, and +matched **verbatim** — `"f"` is rejected where `"F"` is accepted. + +| Key | Options | Default | Notes | +|-----|---------|---------|-------| +| `preference_video_night_mode` | `auto`, `on`, `off` | `auto` | See below | +| `preference_temperature_scale` | `F`, `C` | `F` | Safe | +| `log_level` | `trace`, `verbose`, `debug`, `info`, `warning`, `error`, `fatal` | `info` | Diagnostic — not worth an HA entity | +| `preference_operating_mode` | `global`, `direct` | `global` | ⚠️ `direct` pairs the camera with a monitor off the home LAN — can drop it off the network | +| `preference_connection_band` | `auto`, `a`, `bg` | `auto` | ⚠️ WiFi radio config — see the deny-list | +| `preference_stream_config.resolution` | `2688x1520`, `2432x1520`, `1920x1080`, `1728x1080`, `1280x720`, `1152x720` | `1728x1080` | Nested; restarts the stream | +| `preference_anomaly_configs.active_config` | `care`, `primary` | `primary` | Nested; swaps the whole detection profile | +| `preference_anomaly_configs.*.motion[].motion_level` | `low`, `medium`, `high` | — | Nested inside an array | + +#### Night mode +Night mode is set through `update-settings`, **not** a dedicated command: + +```json +{ "settings": { "preference_video_night_mode": "on" } } +``` + +> ⚠️ There is no `update-night-mode` command. Publishing to it returns +> `RESOURCE_NOT_FOUND` on firmware 2.8.0, and the string appears nowhere in the +> official app — the app sends `update-settings`. + +**Night mode is a three-way preference, not a boolean.** Accepted values are +`"auto"`, `"on"` and `"off"`, default `"auto"`. In Home Assistant this must be +a **select**, not a switch. + +Do not confuse the two night-mode fields in a settings payload: + +| Field | Where | Type | Meaning | +|-------|-------|------|---------| +| `preference_video_night_mode` | `settings` | `"auto"`\|`"on"`\|`"off"` | The **setting**. Writable; this is what you read back after a write. | +| `video_night_mode` | `state` | bool | Whether IR is engaged **right now**. Read-only, device-driven — under `auto` it flips by itself as light changes. | + +Under `"auto"` the preference stays `"auto"` while the runtime bool moves on its +own, so an entity whose state is derived from the runtime bool will not match +the command that set it. + +### `set-night-mode-ir-brightness` +Set IR LED brightness for night mode. Value is range-checked on the device. +```json +{ "ir_brightness": 50 } +``` +Response: `{ "message": "Night mode IR brightness updated successfully" }` or an +`error`. Source: `handlers.cc:1258`. + +### `update-operating-mode` +Change the camera's operating mode. +```json +{ "operating_mode": "..." } +``` +Source: `handlers.cc:818`. + +### `set-scheduled-reboot` +Enable/disable a daily scheduled reboot. +```json +{ "enabled": true, "reboot_time": "03:30" } +``` +`reboot_time` must be `hh:mm` (24h) and is validated. Send `{ "enabled": false }` to +disable. Source: `handlers.cc:1048`. + +### Moments (recorded clips) +- `list-moments` — list saved clips. +- `save-moment` — save a clip for a time range: `{ "start": , "end": }`. +- `list-viewers` — list current stream viewers. +- `run-sleep-insights` — trigger a sleep-insights run. Empty payload. + +Sources: `handlers.cc` (`save_moment:131`, and the handler map at `handlers.cc:405-545`). + +> These are the user-facing "public" commands. The full command constants are in +> `harbor-common/include/harbor-common/utils/topics.hh` lines 56-111. + +--- + +## 5. Do NOT expose these (sensitive — for awareness only) + +The camera also handles the commands below. **None of these should be wired into a +Home Assistant entity.** They can change credentials, network config, firmware, or run +privileged operations, and would expose the device if triggered from HA. Listed so the +integration author knows to steer clear. + +**WiFi / access point / network credentials** +`connect-to-network`, `create-ap`, `start-ap`, `get-secure-ap-info`, +`get-onboarding-info`, `update-interfaces`, `get-wifi-band-settings`, +`set-wifi-band-settings`, `list-networks`, `list-scanned-networks`, `remove-network`, +`pin-bssid`, `unpin-all-bssids`, `rescan-networks`, `get-roaming-settings`, +`set-roaming-settings`, `set-auto-pinning-settings`. + +**Firmware / boot / destructive** +`update-now`, `swap-partitions`, `internal/reset` (factory reset), +`internal/software-restart`, `internal/hardware-restart`, +`remove-certificate-not-in-secure-storage-flag`. + +**Remote access / privileged execution** +`start-frpc`, `stop-frpc` (reverse tunnel), `restart-service` (runs `systemctl`), +`ap-isolation-test` (runs a network probe). + +> The `internal/...` commands live under `cameras//internal/...` and are meant +> for the monitor/service tooling, not end users. Source: +> `harbor-common/include/harbor-common/utils/topics.hh:113-131`. +> +> Separately, the on-device HTTP server (not MQTT) has a `/client-certs` route that +> returns the device's client certificate and key — never proxy or expose that. + +--- + +## 6. Quick reference + +| I want to… | Topic | Payload | +|------------|-------|---------| +| Know the temperature | sub `events/heartbeat` | — | +| Know WiFi signal | sub `events/local-livekit-heartbeat` | — | +| Know online/offline | sub `events/up` / `events/down` | — | +| Get motion events | sub `events/motion-detected` | — | +| Get sound/cry events | sub `events/sound-anomaly-detected` | — | +| Turn camera off/on | pub `pause-stream` / `unpause-stream` | `{"viewer_id":"home-assistant"}` | +| Set night mode | pub `update-settings` | `{"settings":{"preference_video_night_mode":"auto"}}` | +| Set IR brightness | pub `set-night-mode-ir-brightness` | `{"ir_brightness":50}` | +| Change any preference | pub `update-settings` | `{"settings":{"":}}` | +| Read all settings | pub `get-settings` | `{}` | +| Health check | pub `ping` | `{}` | +| Schedule nightly reboot | pub `set-scheduled-reboot` | `{"enabled":true,"reboot_time":"03:30"}` | + +All topics are prefixed with `cameras//`. + +--- + +## 7. Verified against firmware + +Every command below was published to a real camera (serial `2409001608`, +`os_version` **2.8.0**, `app_version` **2.8.0-rc1+c1b0a32**). Writes echoed the +value the camera already held, so each probe was a no-op. + +| Command | Result | +|---------|--------| +| `ping` | ✅ `OK` | +| `get-settings` | ✅ `OK` | +| `update-settings` | ✅ `OK` — echoes the applied subset | +| `set-night-mode-ir-brightness` | ✅ `OK` — `"Night mode IR brightness updated successfully"` | +| `update-operating-mode` | ✅ `OK` | +| `set-scheduled-reboot` | ✅ `OK` | +| `list-viewers` | ✅ `OK` | +| `pause-stream` / `unpause-stream` | ✅ known working (not re-probed — would interrupt the stream) | +| `update-night-mode` | ❌ **`RESOURCE_NOT_FOUND` — no such command** | +| `list-moments` | ⚠️ no response within 8s; may not be implemented on this build | + +A `RESOURCE_NOT_FOUND` status means the firmware has no handler for that +command. It is permanent for the build, not transient, so a client should stop +offering the feature rather than retry. diff --git a/tests/test_subscription.py b/tests/test_subscription.py index 0cc1bbd..d4011b4 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio + from harbor.config import HarborCameraConfig from harbor.devices.camera import HarborCamera from harbor.events import ( @@ -9,11 +11,26 @@ HarborEventBus, HeartbeatUpdate, MotionDetectedUpdate, + NoiseDetectedUpdate, SettingsUpdate, ViewerJoinedUpdate, - extract_explicit_event_state, ) +REAL_NOISE_PAYLOAD = { + "active_config": "primary", + "baseline": "-47.838640dB", + "baseline_reference": "-47.838640dB", + "duration": "10s", + "file_duration": "10.000000s", + "filename": "sound-anomaly-2026-08-18_19-10-24-516024038.mp4", + "level": "-36.401137dB", + "sensitivity": "0", + "threshold": "-30.000000dB", + "thumbnail": "sound-anomaly-2026-08-18_19-10-24-516024038.jpeg", + "timestamp": "2026-08-18T19:10:24.516024038Z", + "user_offset": "-30.000000dB", +} + def _create_camera() -> HarborCamera: """Create a test camera.""" @@ -132,8 +149,8 @@ async def test_get_settings_response_updates_camera_display_name() -> None: assert camera.state.values["temperature"] == 22.5 -def test_event_bus_parses_generic_camera_events() -> None: - """The package should normalize generic camera events.""" +def test_event_bus_parses_unknown_camera_events() -> None: + """Camera topics without a typed payload should still normalize.""" events_received: list[CameraEventUpdate] = [] event_bus = HarborEventBus() @@ -146,8 +163,8 @@ def on_camera_event(event: HarborEvent) -> None: async def _run() -> None: await event_bus.async_process_message( - "cameras/TEST123/events/noise-detection", - {"duration": "2", "detected": True}, + "cameras/TEST123/events/operating-mode-changed", + {"mode": "care"}, ) import asyncio @@ -155,13 +172,11 @@ async def _run() -> None: asyncio.run(_run()) assert len(events_received) == 1 - assert events_received[0].event_key == "noise_detection" - assert events_received[0].active_seconds == 2.0 - assert events_received[0].explicit_state is True + assert events_received[0].event_key == "operating_mode_changed" async def test_motion_detection_keeps_typed_payload() -> None: - """Known trigger events should keep their typed payload and trigger base subscribers.""" + """A motion-detected topic should keep its typed payload and reach base subscribers.""" camera = _create_camera() typed_events: list[MotionDetectedUpdate] = [] @@ -171,15 +186,76 @@ async def test_motion_detection_keeps_typed_payload() -> None: camera.subscribe(CameraEventUpdate, lambda event: camera_events.append(event)) await camera.handle_message( - "cameras/TEST123/events/motion-detection", - {"duration": "1", "timestamp": "2026-03-07T16:00:00Z"}, + "cameras/TEST123/events/motion-detected", + { + "active_config": "primary", + "duration": "10s", + "file_duration": "10.000000s", + "filename": "motion-2026-03-07_16-00-00.mp4", + "level": "medium", + "sensitivity": "0", + "threshold": "40", + "thumbnail": "motion-2026-03-07_16-00-00.jpeg", + "timestamp": "2026-03-07T16:00:00Z", + }, ) assert len(typed_events) == 1 assert len(camera_events) == 1 + payload = typed_events[0].payload assert typed_events[0].event_type is EventType.MOTION_DETECTION - assert typed_events[0].payload.timestamp == "2026-03-07T16:00:00Z" - assert typed_events[0].active_seconds == 1.0 + assert payload.timestamp == "2026-03-07T16:00:00Z" + assert payload.filename == "motion-2026-03-07_16-00-00.mp4" + assert payload.active_config == "primary" + assert payload.sensitivity == "0" + assert payload.thumbnail is not None + assert payload.thumbnail.endswith(".jpeg") + + +async def test_noise_detection_keeps_typed_payload() -> None: + """A real sound-anomaly-detected payload should bind to the typed model.""" + + camera = _create_camera() + typed_events: list[NoiseDetectedUpdate] = [] + + camera.subscribe(NoiseDetectedUpdate, lambda event: typed_events.append(event)) + + await camera.handle_message( + "cameras/TEST123/events/sound-anomaly-detected", + REAL_NOISE_PAYLOAD, + ) + + assert len(typed_events) == 1 + payload = typed_events[0].payload + assert typed_events[0].event_type is EventType.NOISE_DETECTION + assert typed_events[0].event_key == "noise_detection" + assert payload.active_config == "primary" + assert payload.baseline == "-47.838640dB" + assert payload.baseline_reference == "-47.838640dB" + assert payload.file_duration == "10.000000s" + assert payload.level == "-36.401137dB" + assert payload.sensitivity == "0" + assert payload.threshold == "-30.000000dB" + assert payload.thumbnail is not None + assert payload.thumbnail.endswith(".jpeg") + assert payload.user_offset == "-30.000000dB" + + +async def test_noise_duration_is_not_parsed_as_a_hold() -> None: + """The unit-suffixed duration is kept verbatim and drives no timer.""" + + camera = _create_camera() + + await camera.handle_message( + "cameras/TEST123/events/sound-anomaly-detected", + REAL_NOISE_PAYLOAD, + ) + + typed = camera.state.events["noise_detection"].last_payload + assert typed["duration"] == "10s" + # No task or handle may outlive the message: nothing schedules a reset. + pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + assert pending == [] async def test_local_livekit_heartbeat_does_not_store_monitor_connected_state() -> None: @@ -202,58 +278,62 @@ async def test_local_livekit_heartbeat_does_not_store_monitor_connected_state() assert "monitor_connected" not in camera.state.values -async def test_default_camera_events_include_noise_detection() -> None: - """Noise detection should be initialized like motion and cry detection.""" +async def test_default_camera_events_match_firmware_topics() -> None: + """Only the two detections the firmware actually publishes should be seeded.""" camera = _create_camera() - assert set(camera.state.events) == { - "cry_detection", - "motion_detection", - "noise_detection", - } + assert set(camera.state.events) == {"motion_detection", "noise_detection"} -async def test_detected_topic_aliases_trigger_camera_events() -> None: - """Detected topic aliases should normalize to the HA entity keys.""" +async def test_detection_events_record_last_seen_without_holding_state() -> None: + """Detections are edge triggers: they stamp last_seen and nothing more.""" camera = _create_camera() + assert camera.state.events["noise_detection"].last_seen is None + await camera.handle_message( - "cameras/TEST123/events/cry-detected", - {"duration": "1", "detected": True}, - ) - await camera.handle_message( - "cameras/TEST123/events/noise-detected", - {"duration": "1", "detected": True}, + "cameras/TEST123/events/sound-anomaly-detected", + { + "activeConfig": "primary", + "duration": "90", + "level": "loud", + "threshold": "60", + "timestamp": "2026-03-07T16:00:00Z", + "filename": "anomaly-002.mp4", + }, ) - assert camera.state.events["cry_detection"].is_on is True - assert camera.state.events["noise_detection"].is_on is True - - -def test_detection_boolean_payloads_provide_explicit_state() -> None: - """Detection-specific boolean payloads should be treated as explicit state.""" - - assert extract_explicit_event_state({"motion_detected": False}) is False - assert extract_explicit_event_state({"cry_detected": False}) is False - assert extract_explicit_event_state({"noise_detected": False}) is False - assert extract_explicit_event_state({"motion": True}) is True - assert extract_explicit_event_state({"cry": True}) is True - assert extract_explicit_event_state({"noise": True}) is True + event_state = camera.state.events["noise_detection"] + assert event_state.last_seen is not None + assert event_state.topic == "cameras/TEST123/events/sound-anomaly-detected" + # A long ``duration`` is the configured trigger threshold, not a hold time, + # so it must not produce any lingering on/off state. + assert not hasattr(event_state, "is_on") -async def test_explicit_false_detection_payload_keeps_event_off() -> None: - """Explicit false detection payloads should not pulse the event on.""" +async def test_repeated_detection_advances_last_seen() -> None: + """Each detection should re-stamp last_seen so consumers can fire again.""" camera = _create_camera() + payload = { + "activeConfig": "primary", + "level": "medium", + "threshold": "40", + "timestamp": "2026-03-07T16:00:00Z", + "filename": "motion-002.mp4", + } - await camera.handle_message( - "cameras/TEST123/events/noise-detected", - {"noise_detected": False}, - ) + await camera.handle_message("cameras/TEST123/events/motion-detected", payload) + first = camera.state.events["motion_detection"].last_seen + + await camera.handle_message("cameras/TEST123/events/motion-detected", payload) + second = camera.state.events["motion_detection"].last_seen - assert camera.state.events["noise_detection"].is_on is False + assert first is not None + assert second is not None + assert second >= first async def test_viewer_events_accept_nested_payloads() -> None: