Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 40 additions & 3 deletions harbor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -53,16 +75,31 @@
"ViewerInfo",
"parse_message",
"GetCameraSettingsRequest",
"UpdateCameraSettingsRequest",
"HeartbeatEvent",
"LocalLivekitHeartbeatEvent",
"Settings",
"SettingsEvent",
"SettingsState",
"ViewerJoinedEvent",
"ViewerLeftEvent",
"MotionDetectedEvent",
"HarborSourceType",
"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",
]
66 changes: 63 additions & 3 deletions harbor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.
Expand Down
42 changes: 40 additions & 2 deletions harbor/data/mqtt_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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."""

Expand Down
Loading
Loading