harbor-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
harbor/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ from .config import HarborCameraConfig
2
+ from .core import Harbor
3
+ from .data.mqtt_models import (
4
+ HeartbeatEvent,
5
+ LocalLivekitHeartbeatEvent,
6
+ MotionDetectedEvent,
7
+ SettingsEvent,
8
+ ViewerJoinedEvent,
9
+ ViewerLeftEvent,
10
+ )
11
+ from .device import HarborDevice
12
+ from .devices.camera import HarborCamera
13
+ from .devices.monitor import HarborMonitor
14
+ from .events import (
15
+ CameraEventUpdate,
16
+ EventType,
17
+ HarborEvent,
18
+ HarborEventBus,
19
+ HeartbeatUpdate,
20
+ LocalLivekitHeartbeatUpdate,
21
+ MotionDetectedUpdate,
22
+ RawEventUpdate,
23
+ SettingsUpdate,
24
+ ViewerInfo,
25
+ ViewerJoinedUpdate,
26
+ ViewerLeftUpdate,
27
+ parse_message,
28
+ )
29
+ from .mqtt import HarborMQTTClient
30
+ from .state import HarborDeviceState, HarborEventState, HarborSourceType, HarborViewer
31
+
32
+ __all__ = [
33
+ "Harbor",
34
+ "HarborCameraConfig",
35
+ "HarborMQTTClient",
36
+ "HarborDevice",
37
+ "HarborCamera",
38
+ "HarborMonitor",
39
+ "HarborEvent",
40
+ "HarborEventBus",
41
+ "EventType",
42
+ "RawEventUpdate",
43
+ "HeartbeatUpdate",
44
+ "LocalLivekitHeartbeatUpdate",
45
+ "ViewerJoinedUpdate",
46
+ "ViewerLeftUpdate",
47
+ "SettingsUpdate",
48
+ "CameraEventUpdate",
49
+ "MotionDetectedUpdate",
50
+ "ViewerInfo",
51
+ "parse_message",
52
+ "HeartbeatEvent",
53
+ "LocalLivekitHeartbeatEvent",
54
+ "SettingsEvent",
55
+ "ViewerJoinedEvent",
56
+ "ViewerLeftEvent",
57
+ "MotionDetectedEvent",
58
+ "HarborSourceType",
59
+ "HarborViewer",
60
+ "HarborEventState",
61
+ "HarborDeviceState",
62
+ ]
harbor/config.py ADDED
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class HarborCameraConfig:
6
+ serial: str
7
+ cert_path: str
8
+ key_path: str
9
+ cert_dir: str
10
+
11
+ ip_address: str | None = None
harbor/core.py ADDED
@@ -0,0 +1,63 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from .config import HarborCameraConfig
5
+ from .device import HarborDevice
6
+ from .mqtt import HarborMQTTClient
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class Harbor:
12
+ """
13
+ High-level API for managing Harbor devices and MQTT connections.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ self._devices: dict[str, HarborDevice] = {}
18
+ self._clients: dict[str, HarborMQTTClient] = {}
19
+ self._topics_cache: set[str] = set()
20
+
21
+ def add_device(self, device: HarborDevice) -> None:
22
+ """Add a device (camera or monitor) to be managed."""
23
+ self._devices[device.serial] = device
24
+ self._topics_cache.update(device.get_topics())
25
+ _LOGGER.debug("Added device: %s (%s)", device.serial, type(device).__name__)
26
+
27
+ def add_camera_connection(self, config: HarborCameraConfig) -> None:
28
+ """
29
+ Add a camera connection configuration.
30
+ This creates an MQTT client for the specified camera.
31
+ """
32
+ if config.serial in self._clients:
33
+ _LOGGER.warning("Camera connection already exists: %s", config.serial)
34
+ return
35
+ topics = list(self._topics_cache)
36
+ client = HarborMQTTClient(
37
+ config=config,
38
+ topics=topics,
39
+ message_handler=self.handle_message,
40
+ client_id=f"harbor-client-{config.serial}",
41
+ )
42
+ self._clients[config.serial] = client
43
+ _LOGGER.info("Added MQTT client for camera: %s", config.serial)
44
+
45
+ async def start(self) -> None:
46
+ """Start all MQTT clients."""
47
+ for client in self._clients.values():
48
+ await client.start()
49
+
50
+ async def stop(self) -> None:
51
+ """Stop all MQTT clients and release device resources."""
52
+ for client in self._clients.values():
53
+ await client.stop()
54
+ for device in self._devices.values():
55
+ device.shutdown()
56
+
57
+ async def handle_message(self, topic: str, payload: Any) -> None:
58
+ """
59
+ Central message handler.
60
+ Dispatches messages to all interested devices.
61
+ """
62
+ for device in self._devices.values():
63
+ await device.handle_message(topic, payload)
File without changes
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class HarborMQTTPayload(BaseModel):
9
+ """Base model for Harbor MQTT payloads."""
10
+
11
+ model_config = ConfigDict(extra="allow")
12
+
13
+
14
+ class LocalLivekitHeartbeatEvent(HarborMQTTPayload):
15
+ """Payload for a local LiveKit heartbeat."""
16
+
17
+ app_start_time: str | None = None
18
+ app_version: str | None = None
19
+ bitrate: float | None = None
20
+ camera_present: bool | None = None
21
+ camera_state: str | None = None
22
+ is_healthy: dict[str, Any] = Field(default_factory=dict)
23
+ network_bars: int | None = None
24
+ os_version: str | None = None
25
+ receiver_present: bool | None = None
26
+ speaker_state: str | None = None
27
+ stream_quality: str | None = None
28
+ stream_start_time: str | None = None
29
+ viewers_by_identity: dict[str, Any] = Field(default_factory=dict)
30
+ viewers_by_identity_full: dict[str, Any] = Field(default_factory=dict)
31
+
32
+
33
+ class HeartbeatEvent(HarborMQTTPayload):
34
+ """Payload for a device heartbeat."""
35
+
36
+ app_version: str | None = None
37
+ efuse_voltage: int | None = None
38
+ image_sensor_temperature: float | None = None
39
+ ntc_adc_voltage: int | None = None
40
+ ntc_temperature: float | None = None
41
+ os_version: str | None = None
42
+ raw_temperature: float | None = None
43
+ sensor_temperature: float | None = None
44
+ temperature: float | None = None
45
+
46
+
47
+ class Settings(HarborMQTTPayload):
48
+ """Camera settings included with a settings event."""
49
+
50
+ log_level: str | None = None
51
+ preference_ai: dict[str, Any] = Field(default_factory=dict)
52
+ preference_anomaly_configs: dict[str, Any] = Field(default_factory=dict)
53
+ preference_anomaly_throttle_duration_seconds: int | None = None
54
+ preference_auto_pinning: bool | None = None
55
+ preference_connection_band: str | None = None
56
+ preference_connection_bssid: str | None = None
57
+ preference_display_name: str | None = None
58
+ preference_moment_length: int | None = None
59
+ preference_operating_mode: str | None = None
60
+ preference_scheduled_reboot: str | None = None
61
+ preference_silence_alerting_until: str | None = None
62
+ preference_stream_paused: bool | None = None
63
+ preference_video_clock_display_tz_abbrev: str | None = None
64
+ preference_video_clock_display_tz_offset: int | None = None
65
+ preference_video_flip: bool | None = None
66
+ preference_video_has_clock_display: bool | None = None
67
+ preference_video_ir_brightness: int | None = None
68
+ preference_video_night_mode: str | None = None
69
+
70
+
71
+ class SettingsState(HarborMQTTPayload):
72
+ """Runtime state attached to a settings event."""
73
+
74
+ application_state: int | None = None
75
+ network_bars: int | None = None
76
+ stream_state: int | None = None
77
+ temperature: float | None = None
78
+ video_night_mode: bool | None = None
79
+
80
+
81
+ class SettingsEvent(HarborMQTTPayload):
82
+ """Payload for a settings event."""
83
+
84
+ client: str | None = None
85
+ is_updating: bool | None = None
86
+ seq: str | None = None
87
+ settings: Settings | None = None
88
+ state: SettingsState | None = None
89
+ triggered_by: str | None = None
90
+ updated: dict[str, Any] = Field(default_factory=dict)
91
+
92
+
93
+ class ViewerJoinedEvent(HarborMQTTPayload):
94
+ """Payload for a viewer joined event."""
95
+
96
+ client: str | None = None
97
+ identity: str | None = None
98
+ is_local: bool | None = None
99
+ role: str | None = None
100
+ viewer_id: str | None = None
101
+
102
+
103
+ class ViewerLeftEvent(HarborMQTTPayload):
104
+ """Payload for a viewer left event."""
105
+
106
+ client: str | None = None
107
+ identity: str | None = None
108
+ is_local: bool | None = None
109
+ role: str | None = None
110
+ viewer_id: str | None = None
111
+
112
+
113
+ class MotionDetectedEvent(HarborMQTTPayload):
114
+ """Payload for a motion detection event."""
115
+
116
+ active_config: str | None = None
117
+ duration: str | float | int | None = None
118
+ filename: str | None = None
119
+ level: str | None = None
120
+ sensitivity: str | None = None
121
+ threshold: str | None = None
122
+ thumbnail: str | None = None
123
+ timestamp: str | None = None
harbor/device.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable
7
+ from datetime import UTC
8
+ from typing import Any, TypeVar, overload
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from .data.mqtt_models import (
13
+ HeartbeatEvent as HarborHeartbeatPayload,
14
+ )
15
+ from .data.mqtt_models import (
16
+ SettingsEvent as HarborSettingsPayload,
17
+ )
18
+ from .events import (
19
+ HarborEvent,
20
+ HeartbeatUpdate,
21
+ SettingsUpdate,
22
+ SubscriptionManager,
23
+ parse_message,
24
+ )
25
+ from .state import HarborDeviceState, HarborSourceType
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+ UpdateCallbackType = Callable[[HarborDeviceState], Any]
30
+ EventT = TypeVar("EventT", bound=HarborEvent)
31
+
32
+
33
+ class HarborDevice(ABC):
34
+ """Abstract base class for Harbor devices."""
35
+
36
+ def __init__(self, serial: str, source_type: HarborSourceType) -> None:
37
+ self.serial = serial
38
+ self._source_type = source_type
39
+ self.state = HarborDeviceState(serial=serial, source_type=source_type)
40
+ self._subscriptions = SubscriptionManager()
41
+ self._update_subscriptions: list[UpdateCallbackType] = []
42
+
43
+ @property
44
+ def source_type(self) -> HarborSourceType:
45
+ """Return the source type for events from this device."""
46
+ return self._source_type
47
+
48
+ @abstractmethod
49
+ def get_topics(self) -> list[str]:
50
+ """Return a list of topics to subscribe to."""
51
+ pass
52
+
53
+ async def handle_message(self, topic: str, payload: Any) -> HarborEvent | None:
54
+ """Handle an incoming MQTT message."""
55
+ if not (event := parse_message(topic, payload)):
56
+ return None
57
+
58
+ if event.source_type != self.source_type or event.source_sn != self.serial:
59
+ return None
60
+
61
+ self._apply_event(event)
62
+ await self._emit_event(event)
63
+ await self._emit_update()
64
+ return event
65
+
66
+ @overload
67
+ def subscribe(
68
+ self,
69
+ event_type: type[EventT],
70
+ callback: Callable[[EventT], Any],
71
+ ) -> Callable[[], None]: ...
72
+
73
+ @overload
74
+ def subscribe(
75
+ self,
76
+ event_type: type[BaseModel],
77
+ callback: Callable[[HarborEvent], Any],
78
+ ) -> Callable[[], None]: ...
79
+
80
+ @overload
81
+ def subscribe(
82
+ self,
83
+ event_type: None,
84
+ callback: Callable[[HarborEvent], Any],
85
+ ) -> Callable[[], None]: ...
86
+
87
+ def subscribe(
88
+ self,
89
+ event_type: type[HarborEvent] | type[BaseModel] | None,
90
+ callback: Callable[[Any], Any],
91
+ ) -> Callable[[], None]:
92
+ """Subscribe to a specific Harbor event type or all events."""
93
+
94
+ return self._subscriptions.subscribe(event_type, callback)
95
+
96
+ async def _emit_event(self, event: HarborEvent) -> None:
97
+ """Emit a parsed Harbor event to subscribers."""
98
+
99
+ await self._subscriptions.emit(event)
100
+
101
+ def subscribe_updates(
102
+ self,
103
+ callback: UpdateCallbackType,
104
+ ) -> Callable[[], None]:
105
+ """Subscribe to device state updates."""
106
+ self._update_subscriptions.append(callback)
107
+
108
+ def unsubscribe() -> None:
109
+ if callback in self._update_subscriptions:
110
+ self._update_subscriptions.remove(callback)
111
+
112
+ return unsubscribe
113
+
114
+ async def _emit_update(self) -> None:
115
+ """Emit a state update to subscribers."""
116
+ for callback in list(self._update_subscriptions):
117
+ try:
118
+ result = callback(self.state)
119
+ if asyncio.iscoroutine(result):
120
+ await result
121
+ except Exception:
122
+ LOGGER.exception(
123
+ "Error in device update callback %s for %s",
124
+ getattr(callback, "__qualname__", repr(callback)),
125
+ self.serial,
126
+ )
127
+
128
+ def shutdown(self) -> None:
129
+ """Release device resources."""
130
+
131
+ def _apply_event(self, event: HarborEvent) -> None:
132
+ """Apply a parsed Harbor event to device state.
133
+
134
+ Subclasses should override this to handle device-specific event types,
135
+ calling ``super()._apply_event(event)`` to keep the shared handling.
136
+ """
137
+ self.state.last_seen = event.timestamp.astimezone(UTC)
138
+ self._update_shared_metadata(event)
139
+
140
+ match event:
141
+ case HeartbeatUpdate(payload=payload):
142
+ self._apply_heartbeat(payload)
143
+ case SettingsUpdate(payload=payload):
144
+ self._apply_settings(payload)
145
+
146
+ def _update_shared_metadata(self, event: HarborEvent) -> None:
147
+ """Update metadata shared across Harbor event types."""
148
+ if event.os_version is not None:
149
+ self.state.os_version = event.os_version
150
+
151
+ if event.app_version is not None:
152
+ self.state.app_version = event.app_version
153
+
154
+ if event.display_name is not None:
155
+ self.state.display_name = event.display_name
156
+
157
+ def _apply_heartbeat(self, payload: HarborHeartbeatPayload) -> None:
158
+ """Apply a device heartbeat payload."""
159
+ self._set_state_value("temperature", payload.temperature)
160
+ self._set_state_value("sensor_temperature", payload.sensor_temperature)
161
+ self._set_state_value("raw_temperature", payload.raw_temperature)
162
+ self._set_state_value(
163
+ "image_sensor_temperature",
164
+ payload.image_sensor_temperature,
165
+ )
166
+ self._set_state_value("ntc_temperature", payload.ntc_temperature)
167
+
168
+ def _apply_settings(self, payload: HarborSettingsPayload) -> None:
169
+ """Apply a settings payload."""
170
+ if payload.state is None:
171
+ return
172
+
173
+ self._set_state_value("wifi_strength", payload.state.network_bars)
174
+ self._set_state_value("temperature", payload.state.temperature)
175
+
176
+ def _set_state_value(self, key: str, value: Any) -> None:
177
+ """Set a state value when the payload exposed a concrete value."""
178
+ if value is not None:
179
+ self.state.values[key] = value
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from ..config import HarborCameraConfig
7
+ from ..device import HarborDevice
8
+ from ..events import (
9
+ CameraEventUpdate,
10
+ HarborEvent,
11
+ LocalLivekitHeartbeatUpdate,
12
+ ViewerInfo,
13
+ ViewerJoinedUpdate,
14
+ ViewerLeftUpdate,
15
+ )
16
+ from ..state import HarborEventState, HarborViewer
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+ DEFAULT_CAMERA_EVENT_KEYS = ("motion_detection", "cry_detection", "noise_detection")
21
+ DEFAULT_EVENT_ACTIVE_SECONDS = 5.0
22
+
23
+
24
+ class HarborCamera(HarborDevice):
25
+ """Represents a Harbor camera device."""
26
+
27
+ def __init__(self, config: HarborCameraConfig) -> None:
28
+ """Initialize the camera device."""
29
+ super().__init__(config.serial, "camera")
30
+ self.config = config
31
+ self._event_reset_handles: dict[str, asyncio.TimerHandle] = {}
32
+
33
+ for event_key in DEFAULT_CAMERA_EVENT_KEYS:
34
+ self._ensure_camera_event(event_key)
35
+
36
+ def get_topics(self) -> list[str]:
37
+ """Return topics that should be subscribed for this device."""
38
+
39
+ return [f"cameras/{self.serial}/events/#"]
40
+
41
+ def _apply_event(self, event: HarborEvent) -> None:
42
+ """Apply a Harbor event to camera state, including camera-only events."""
43
+ super()._apply_event(event)
44
+ match event:
45
+ case LocalLivekitHeartbeatUpdate(payload=payload, viewers=viewers):
46
+ self._apply_local_livekit_heartbeat(payload, viewers)
47
+ case ViewerJoinedUpdate(viewer=viewer):
48
+ self._apply_viewer_joined(viewer)
49
+ case ViewerLeftUpdate(viewer_id=viewer_id):
50
+ self._apply_viewer_left(viewer_id)
51
+ case CameraEventUpdate():
52
+ self._apply_camera_event(event)
53
+
54
+ def _apply_local_livekit_heartbeat(
55
+ self,
56
+ payload,
57
+ viewers: tuple[ViewerInfo, ...],
58
+ ) -> None:
59
+ """Apply a local LiveKit heartbeat payload."""
60
+ self._set_state_value("bitrate", payload.bitrate)
61
+ self._set_state_value("wifi_strength", payload.network_bars)
62
+ self._set_state_value("camera_present", payload.camera_present)
63
+ self._set_state_value("speaker_state", payload.speaker_state)
64
+ self._set_state_value("stream_quality", payload.stream_quality)
65
+ self._set_state_value("app_start_time", payload.app_start_time)
66
+ self._set_state_value("stream_start_time", payload.stream_start_time)
67
+
68
+ self.state.viewers = {
69
+ viewer.viewer_id: HarborViewer(
70
+ viewer_id=viewer.viewer_id,
71
+ identity=viewer.identity,
72
+ client=viewer.client,
73
+ is_local=viewer.is_local,
74
+ role=viewer.role,
75
+ )
76
+ for viewer in viewers
77
+ }
78
+ self.state.values["num_viewers"] = len(self.state.viewers)
79
+
80
+ def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
81
+ """Apply a viewer joined update."""
82
+ if viewer is None:
83
+ return
84
+
85
+ self.state.viewers[viewer.viewer_id] = HarborViewer(
86
+ viewer_id=viewer.viewer_id,
87
+ identity=viewer.identity,
88
+ client=viewer.client,
89
+ is_local=viewer.is_local,
90
+ role=viewer.role,
91
+ )
92
+ self.state.values["num_viewers"] = len(self.state.viewers)
93
+
94
+ def _apply_viewer_left(self, viewer_id: str | None) -> None:
95
+ """Apply a viewer left update."""
96
+ if viewer_id is None:
97
+ return
98
+
99
+ self.state.viewers.pop(viewer_id, None)
100
+ self.state.values["num_viewers"] = len(self.state.viewers)
101
+
102
+ def _apply_camera_event(self, event: CameraEventUpdate) -> None:
103
+ """Apply a transient camera event update."""
104
+ event_state = self._ensure_camera_event(event.event_key, topic=event.topic)
105
+ event_state.topic = event.topic
106
+ event_state.last_seen = event.timestamp
107
+ event_state.last_payload = event.raw_payload
108
+
109
+ if handle := self._event_reset_handles.pop(event.event_key, None):
110
+ handle.cancel()
111
+
112
+ if event.explicit_state is False:
113
+ event_state.is_on = False
114
+ return
115
+
116
+ event_state.is_on = True
117
+ active_seconds = event.active_seconds
118
+ if active_seconds <= 0:
119
+ active_seconds = DEFAULT_EVENT_ACTIVE_SECONDS
120
+
121
+ loop = asyncio.get_running_loop()
122
+ self._event_reset_handles[event.event_key] = loop.call_later(
123
+ active_seconds,
124
+ lambda: asyncio.create_task(self._async_reset_event(event.event_key)),
125
+ )
126
+ _LOGGER.debug(
127
+ "Camera %s received event on topic %s: %s",
128
+ self.serial,
129
+ event.topic,
130
+ event.raw_payload,
131
+ )
132
+
133
+ def shutdown(self) -> None:
134
+ """Release camera resources."""
135
+ for handle in self._event_reset_handles.values():
136
+ handle.cancel()
137
+ self._event_reset_handles.clear()
138
+
139
+ async def _async_reset_event(self, event_key: str) -> None:
140
+ """Reset a transient event to off after its active period."""
141
+ self._event_reset_handles.pop(event_key, None)
142
+ if event_state := self.state.events.get(event_key):
143
+ event_state.is_on = False
144
+ await self._emit_update()
145
+
146
+ def _ensure_camera_event(
147
+ self,
148
+ event_key: str,
149
+ *,
150
+ topic: str | None = None,
151
+ ) -> HarborEventState:
152
+ """Ensure an event state exists for the camera."""
153
+ if existing := self.state.events.get(event_key):
154
+ if topic is not None:
155
+ existing.topic = topic
156
+ return existing
157
+
158
+ event_state = HarborEventState(
159
+ key=event_key,
160
+ topic=topic or event_key.replace("_", "-"),
161
+ friendly_name=_event_name_from_key(event_key),
162
+ )
163
+ self.state.events[event_key] = event_state
164
+ return event_state
165
+
166
+
167
+ def _event_name_from_key(event_key: str) -> str:
168
+ """Return a user-facing event name for an event key."""
169
+ if event_key == "cry_detection":
170
+ return "Cry detected"
171
+ if event_key == "motion_detection":
172
+ return "Motion detected"
173
+ if event_key == "noise_detection":
174
+ return "Noise detected"
175
+
176
+ words = event_key.replace("_", " ").strip()
177
+ return words[:1].upper() + words[1:] if words else event_key
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from ..device import HarborDevice
4
+
5
+
6
+ class HarborMonitor(HarborDevice):
7
+ """Represents a Harbor monitor device."""
8
+
9
+ def __init__(self, serial: str) -> None:
10
+ """Initialize the monitor device."""
11
+ super().__init__(serial, "monitor")
12
+
13
+ def get_topics(self) -> list[str]:
14
+ """Return topics that should be subscribed for this device."""
15
+
16
+ return [f"monitors/{self.serial}/events/#"]