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 +62 -0
- harbor/config.py +11 -0
- harbor/core.py +63 -0
- harbor/data/__init__.py +0 -0
- harbor/data/mqtt_models.py +123 -0
- harbor/device.py +179 -0
- harbor/devices/camera.py +177 -0
- harbor/devices/monitor.py +16 -0
- harbor/events.py +667 -0
- harbor/mqtt.py +201 -0
- harbor/py.typed +0 -0
- harbor/state.py +47 -0
- harbor/utils.py +39 -0
- harbor_python-1.0.0.dist-info/METADATA +21 -0
- harbor_python-1.0.0.dist-info/RECORD +17 -0
- harbor_python-1.0.0.dist-info/WHEEL +4 -0
- harbor_python-1.0.0.dist-info/licenses/LICENSE +201 -0
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
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)
|
harbor/data/__init__.py
ADDED
|
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
|
harbor/devices/camera.py
ADDED
|
@@ -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/#"]
|