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
2 changes: 2 additions & 0 deletions harbor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
ViewerLeftUpdate,
parse_message,
)
from .exceptions import HarborCommandError
from .mqtt import HarborMQTTClient
from .state import HarborDeviceState, HarborEventState, HarborSourceType, HarborViewer

__all__ = [
"Harbor",
"HarborCameraConfig",
"HarborCommandError",
"HarborMQTTClient",
"HarborDevice",
"HarborCamera",
Expand Down
28 changes: 28 additions & 0 deletions harbor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ async def get_camera_settings(
"""Request the camera settings payload."""
return await self._get_client(serial).get_settings(timeout=timeout)

async def set_camera_on(
self,
serial: str,
camera_on: bool,
*,
viewer_id: str | None = None,
timeout: float = 10.0,
) -> None:
"""Turn a camera stream on or off and refresh its settings."""
await self._get_client(serial).set_camera_on(
camera_on,
viewer_id=viewer_id,
timeout=timeout,
)

async def set_night_mode(
self,
serial: str,
night_mode: bool,
*,
timeout: float = 10.0,
) -> None:
"""Turn camera night mode on or off and refresh its settings."""
await self._get_client(serial).set_night_mode(
night_mode,
timeout=timeout,
)

async def handle_message(self, topic: str, payload: Any) -> None:
"""
Central message handler.
Expand Down
11 changes: 11 additions & 0 deletions harbor/devices/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import logging

from ..config import HarborCameraConfig
from ..data.mqtt_models import SettingsEvent
from ..device import HarborDevice
from ..events import (
CameraEventUpdate,
HarborEvent,
LocalLivekitHeartbeatUpdate,
SettingsUpdate,
ViewerInfo,
ViewerJoinedUpdate,
ViewerLeftUpdate,
Expand Down Expand Up @@ -58,13 +60,22 @@ def _apply_event(self, event: HarborEvent) -> None:
match event:
case LocalLivekitHeartbeatUpdate(payload=payload, viewers=viewers):
self._apply_local_livekit_heartbeat(payload, viewers)
case SettingsUpdate(payload=payload):
self._apply_camera_settings(payload)
case ViewerJoinedUpdate(viewer=viewer):
self._apply_viewer_joined(viewer)
case ViewerLeftUpdate(viewer_id=viewer_id):
self._apply_viewer_left(viewer_id)
case CameraEventUpdate():
self._apply_camera_event(event)

def _apply_camera_settings(self, payload: SettingsEvent) -> None:
"""Apply camera controls exposed by a settings payload."""
if payload.settings is not None and payload.settings.preference_stream_paused is not None:
self.state.values["camera_on"] = not payload.settings.preference_stream_paused
if payload.state is not None and payload.state.video_night_mode is not None:
self.state.values["night_mode"] = payload.state.video_night_mode

def _apply_local_livekit_heartbeat(
self,
payload,
Expand Down
42 changes: 21 additions & 21 deletions harbor/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,62 +293,62 @@ def parse_message(
}

if event_key == "heartbeat":
if typed_payload := _validate_payload(HeartbeatEvent, raw_payload, topic):
return HeartbeatUpdate(payload=typed_payload, **base_kwargs)
if heartbeat_payload := _validate_payload(HeartbeatEvent, raw_payload, topic):
return HeartbeatUpdate(payload=heartbeat_payload, **base_kwargs)
return RawEventUpdate(payload=raw_payload, **base_kwargs)

if event_key == "local_livekit_heartbeat":
if typed_payload := _validate_payload(LocalLivekitHeartbeatEvent, raw_payload, topic):
if livekit_payload := _validate_payload(LocalLivekitHeartbeatEvent, raw_payload, topic):
return LocalLivekitHeartbeatUpdate(
payload=typed_payload,
viewers=_extract_viewers_from_local_livekit(typed_payload),
payload=livekit_payload,
viewers=_extract_viewers_from_local_livekit(livekit_payload),
**base_kwargs,
)
return RawEventUpdate(payload=raw_payload, **base_kwargs)

if event_key == "viewer_joined":
if typed_payload := _validate_payload(ViewerJoinedEvent, raw_payload, topic):
if viewer_joined_payload := _validate_payload(ViewerJoinedEvent, raw_payload, topic):
return ViewerJoinedUpdate(
payload=typed_payload,
payload=viewer_joined_payload,
viewer=_extract_viewer_info_from_payload(raw_payload)
or _extract_viewer_info(
typed_payload.viewer_id,
typed_payload.identity,
typed_payload.client,
typed_payload.is_local,
typed_payload.role,
viewer_joined_payload.viewer_id,
viewer_joined_payload.identity,
viewer_joined_payload.client,
viewer_joined_payload.is_local,
viewer_joined_payload.role,
),
**base_kwargs,
)
return RawEventUpdate(payload=raw_payload, **base_kwargs)

if event_key == "viewer_left":
if typed_payload := _validate_payload(ViewerLeftEvent, raw_payload, topic):
if viewer_left_payload := _validate_payload(ViewerLeftEvent, raw_payload, topic):
return ViewerLeftUpdate(
payload=typed_payload,
payload=viewer_left_payload,
viewer_id=_extract_viewer_id_from_payload(raw_payload)
or _coalesce_string(
typed_payload.viewer_id,
typed_payload.identity,
typed_payload.client,
viewer_left_payload.viewer_id,
viewer_left_payload.identity,
viewer_left_payload.client,
),
**base_kwargs,
)
return RawEventUpdate(payload=raw_payload, **base_kwargs)

if event_key in {"settings", "get_settings"}:
if typed_payload := _validate_payload(SettingsEvent, raw_payload, topic):
return SettingsUpdate(payload=typed_payload, **base_kwargs)
if settings_payload := _validate_payload(SettingsEvent, raw_payload, topic):
return SettingsUpdate(payload=settings_payload, **base_kwargs)
return RawEventUpdate(payload=raw_payload, **base_kwargs)

if source_type == "camera":
active_seconds = extract_event_duration_seconds(raw_payload)
explicit_state = extract_explicit_event_state(raw_payload)

if event_key == "motion_detection":
if typed_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic):
if motion_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic):
return MotionDetectedUpdate(
payload=typed_payload,
payload=motion_payload,
active_seconds=active_seconds,
explicit_state=explicit_state,
**base_kwargs,
Expand Down
12 changes: 12 additions & 0 deletions harbor/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from __future__ import annotations

from typing import Any


class HarborCommandError(Exception):
"""Raised when a Harbor camera rejects a command."""

def __init__(self, command: str, response: Any) -> None:
self.command = command
self.response = response
super().__init__(f"Harbor camera rejected command {command!r}: {response!r}")
71 changes: 70 additions & 1 deletion harbor/mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .config import HarborCameraConfig
from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent
from .exceptions import HarborCommandError
from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context

if TYPE_CHECKING:
Expand All @@ -23,6 +24,9 @@
DEFAULT_COMMAND_QOS = 2
DEFAULT_REQUEST_TIMEOUT = 10.0
GET_SETTINGS_COMMAND = "get-settings"
PAUSE_STREAM_COMMAND = "pause-stream"
UNPAUSE_STREAM_COMMAND = "unpause-stream"
UPDATE_NIGHT_MODE_COMMAND = "update-night-mode"
DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,)


Expand Down Expand Up @@ -360,7 +364,7 @@ def _build_get_settings_payload(
request = GetCameraSettingsRequest(
seq=seq or _generate_seq(),
client=client or self.client_id or f"harbor-client-{self.config.serial}",
triggered_by=triggered_by or "harbor-python",
triggeredBy=triggered_by or "harbor-python",
)
return request.model_dump(by_alias=True)

Expand Down Expand Up @@ -410,6 +414,71 @@ async def get_settings(
)
return SettingsEvent.model_validate(response)

async def set_camera_on(
self,
camera_on: bool,
*,
viewer_id: str | None = None,
timeout: float = DEFAULT_REQUEST_TIMEOUT,
) -> None:
"""Turn the camera stream on or off and refresh its settings."""
command = UNPAUSE_STREAM_COMMAND if camera_on else PAUSE_STREAM_COMMAND
await self._request_camera_control(
command,
{"viewer_id": viewer_id or self.client_id or f"harbor-client-{self.config.serial}"},
timeout=timeout,
)
await self._refresh_settings_after_command(command, timeout=timeout)

async def set_night_mode(
self,
night_mode: bool,
*,
timeout: float = DEFAULT_REQUEST_TIMEOUT,
) -> None:
"""Turn camera night mode on or off and refresh its settings."""
await self._request_camera_control(
UPDATE_NIGHT_MODE_COMMAND,
{"night_mode": night_mode},
timeout=timeout,
)
await self._refresh_settings_after_command(
UPDATE_NIGHT_MODE_COMMAND,
timeout=timeout,
)

async def _request_camera_control(
self,
command: str,
payload: dict[str, Any],
*,
timeout: float,
) -> Any:
"""Run a camera control command and reject error responses."""
response = await self.request_command(command, payload, timeout=timeout)
if isinstance(response, dict) and (
response.get("error") or ((status := response.get("status")) is not None and str(status).upper() != "OK")
):
raise HarborCommandError(command, response)
return response

async def _refresh_settings_after_command(
self,
command: str,
*,
timeout: float,
) -> None:
"""Refresh settings without turning a successful command into a failure."""
try:
await self.get_settings(timeout=timeout)
except (ConnectionError, MqttError, TimeoutError):
_LOGGER.debug(
"Unable to refresh settings after Harbor command %s for camera %s",
command,
self.config.serial,
exc_info=True,
)

def __del__(self) -> None:
if self._stop_event and not self._stop_event.is_set():
self._stop_event.set()
Expand Down
38 changes: 38 additions & 0 deletions tests/test_camera_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,41 @@ async def test_missing_enum_values_do_not_clear_state() -> None:

assert camera.state.values["speaker_state"] == "idle"
assert camera.state.values["stream_quality"] == "poor"


async def test_settings_update_maps_camera_control_state() -> None:
"""Camera settings should expose normalized stream and night-mode state."""

camera = _create_camera()

await camera.handle_message(
"cameras/TEST123/responses/get-settings",
{
"settings": {"preference_stream_paused": True},
"state": {"video_night_mode": False},
},
)

assert camera.state.values["camera_on"] is False
assert camera.state.values["night_mode"] is False


async def test_missing_camera_settings_do_not_clear_control_state() -> None:
"""Partial settings responses should preserve previously known controls."""

camera = _create_camera()

await camera.handle_message(
"cameras/TEST123/responses/get-settings",
{
"settings": {"preference_stream_paused": False},
"state": {"video_night_mode": True},
},
)
await camera.handle_message(
"cameras/TEST123/responses/get-settings",
{"settings": {"preference_display_name": "Nursery"}},
)

assert camera.state.values["camera_on"] is True
assert camera.state.values["night_mode"] is True
41 changes: 41 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from unittest.mock import AsyncMock, patch

from harbor.config import HarborCameraConfig
from harbor.core import Harbor
from harbor.mqtt import DEFAULT_INITIAL_COMMANDS
Expand All @@ -18,3 +20,42 @@ def test_camera_connection_populates_settings_on_connect() -> None:
harbor.add_camera_connection(config)

assert harbor._clients["TEST123"].initial_commands == DEFAULT_INITIAL_COMMANDS


async def test_camera_control_helpers_delegate_to_camera_client() -> None:
"""The high-level API should expose camera controls by serial number."""

harbor = Harbor()
config = HarborCameraConfig(
serial="TEST123",
cert_path="/path/to/cert.pem",
key_path="/path/to/key.pem",
)
harbor.add_camera_connection(config)
client = harbor._clients["TEST123"]
with (
patch.object(
client,
"set_camera_on",
AsyncMock(),
) as set_camera_on,
patch.object(
client,
"set_night_mode",
AsyncMock(),
) as set_night_mode,
):
await harbor.set_camera_on(
"TEST123",
False,
viewer_id="home-assistant",
timeout=3,
)
await harbor.set_night_mode("TEST123", True, timeout=4)

set_camera_on.assert_awaited_once_with(
False,
viewer_id="home-assistant",
timeout=3,
)
set_night_mode.assert_awaited_once_with(True, timeout=4)
Loading
Loading