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/events.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any, Literal, TypeVar, cast, overload
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
from .data.mqtt_models import (
|
|
16
|
+
HeartbeatEvent,
|
|
17
|
+
LocalLivekitHeartbeatEvent,
|
|
18
|
+
MotionDetectedEvent,
|
|
19
|
+
SettingsEvent,
|
|
20
|
+
ViewerJoinedEvent,
|
|
21
|
+
ViewerLeftEvent,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
HarborSourceType = Literal["camera", "monitor"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventType(StrEnum):
|
|
30
|
+
"""Normalized Harbor event types."""
|
|
31
|
+
|
|
32
|
+
HEARTBEAT = "heartbeat"
|
|
33
|
+
LOCAL_LIVEKIT_HEARTBEAT = "local_livekit_heartbeat"
|
|
34
|
+
VIEWER_JOINED = "viewer_joined"
|
|
35
|
+
VIEWER_LEFT = "viewer_left"
|
|
36
|
+
SETTINGS = "settings"
|
|
37
|
+
MOTION_DETECTION = "motion_detection"
|
|
38
|
+
CAMERA_EVENT = "camera_event"
|
|
39
|
+
RAW = "raw"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
43
|
+
class ViewerInfo:
|
|
44
|
+
"""Normalized viewer data derived from Harbor payloads."""
|
|
45
|
+
|
|
46
|
+
viewer_id: str
|
|
47
|
+
identity: str | None = None
|
|
48
|
+
client: str | None = None
|
|
49
|
+
is_local: bool | None = None
|
|
50
|
+
role: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
54
|
+
class HarborEvent:
|
|
55
|
+
"""Base Harbor event emitted to subscribers."""
|
|
56
|
+
|
|
57
|
+
source_sn: str
|
|
58
|
+
source_type: HarborSourceType
|
|
59
|
+
topic: str
|
|
60
|
+
event_key: str
|
|
61
|
+
timestamp: datetime
|
|
62
|
+
raw_payload: Any
|
|
63
|
+
app_version: str | None = None
|
|
64
|
+
os_version: str | None = None
|
|
65
|
+
display_name: str | None = None
|
|
66
|
+
event_type: EventType = field(init=False, default=EventType.RAW)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
70
|
+
class RawEventUpdate(HarborEvent):
|
|
71
|
+
"""A topic that could be parsed but did not match a typed payload."""
|
|
72
|
+
|
|
73
|
+
payload: Any
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
77
|
+
class HeartbeatUpdate(HarborEvent):
|
|
78
|
+
"""A typed heartbeat update."""
|
|
79
|
+
|
|
80
|
+
payload: HeartbeatEvent
|
|
81
|
+
event_type: EventType = field(init=False, default=EventType.HEARTBEAT)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
85
|
+
class LocalLivekitHeartbeatUpdate(HarborEvent):
|
|
86
|
+
"""A typed local LiveKit heartbeat update."""
|
|
87
|
+
|
|
88
|
+
payload: LocalLivekitHeartbeatEvent
|
|
89
|
+
viewers: tuple[ViewerInfo, ...]
|
|
90
|
+
event_type: EventType = field(init=False, default=EventType.LOCAL_LIVEKIT_HEARTBEAT)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
94
|
+
class ViewerJoinedUpdate(HarborEvent):
|
|
95
|
+
"""A typed viewer joined update."""
|
|
96
|
+
|
|
97
|
+
payload: ViewerJoinedEvent
|
|
98
|
+
viewer: ViewerInfo | None
|
|
99
|
+
event_type: EventType = field(init=False, default=EventType.VIEWER_JOINED)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
103
|
+
class ViewerLeftUpdate(HarborEvent):
|
|
104
|
+
"""A typed viewer left update."""
|
|
105
|
+
|
|
106
|
+
payload: ViewerLeftEvent
|
|
107
|
+
viewer_id: str | None
|
|
108
|
+
event_type: EventType = field(init=False, default=EventType.VIEWER_LEFT)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
112
|
+
class SettingsUpdate(HarborEvent):
|
|
113
|
+
"""A typed settings update."""
|
|
114
|
+
|
|
115
|
+
payload: SettingsEvent
|
|
116
|
+
event_type: EventType = field(init=False, default=EventType.SETTINGS)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
120
|
+
class CameraEventUpdate(HarborEvent):
|
|
121
|
+
"""A camera event that should be treated like a transient trigger."""
|
|
122
|
+
|
|
123
|
+
payload: Any
|
|
124
|
+
active_seconds: float
|
|
125
|
+
explicit_state: bool | None
|
|
126
|
+
event_type: EventType = field(init=False, default=EventType.CAMERA_EVENT)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
130
|
+
class MotionDetectedUpdate(CameraEventUpdate):
|
|
131
|
+
"""A typed motion detection update."""
|
|
132
|
+
|
|
133
|
+
payload: MotionDetectedEvent
|
|
134
|
+
event_type: EventType = field(init=False, default=EventType.MOTION_DETECTION)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
EVENT_MESSAGE_MAP: dict[type[BaseModel], type[HarborEvent]] = {
|
|
138
|
+
HeartbeatEvent: HeartbeatUpdate,
|
|
139
|
+
LocalLivekitHeartbeatEvent: LocalLivekitHeartbeatUpdate,
|
|
140
|
+
ViewerJoinedEvent: ViewerJoinedUpdate,
|
|
141
|
+
ViewerLeftEvent: ViewerLeftUpdate,
|
|
142
|
+
SettingsEvent: SettingsUpdate,
|
|
143
|
+
MotionDetectedEvent: MotionDetectedUpdate,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
CallbackType = Callable[[HarborEvent], Any]
|
|
148
|
+
EventT = TypeVar("EventT", bound=HarborEvent)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def event_key_from_topic(event_name: str) -> str:
|
|
152
|
+
"""Normalize an event topic segment into a stable key."""
|
|
153
|
+
|
|
154
|
+
event_key = event_name.strip().replace("-", "_")
|
|
155
|
+
if event_key in {"motion_detected", "cry_detected", "noise_detected"}:
|
|
156
|
+
return event_key.removesuffix("_detected") + "_detection"
|
|
157
|
+
return event_key
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_payload(payload: Any) -> Any:
|
|
161
|
+
"""Decode JSON payloads when they arrive as strings."""
|
|
162
|
+
|
|
163
|
+
if isinstance(payload, str):
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(payload)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return payload
|
|
168
|
+
return payload
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_topic(
|
|
172
|
+
topic: str,
|
|
173
|
+
) -> tuple[HarborSourceType | None, str | None, str | None]:
|
|
174
|
+
"""Parse a Harbor MQTT topic into source type, serial, and event key."""
|
|
175
|
+
|
|
176
|
+
parts = topic.split("/")
|
|
177
|
+
if len(parts) < 4:
|
|
178
|
+
return None, None, None
|
|
179
|
+
|
|
180
|
+
root, serial, event_root, *rest = parts
|
|
181
|
+
if event_root != "events" or not rest:
|
|
182
|
+
return None, None, None
|
|
183
|
+
|
|
184
|
+
if root == "cameras":
|
|
185
|
+
return "camera", serial, event_key_from_topic(rest[-1])
|
|
186
|
+
|
|
187
|
+
if root == "monitors":
|
|
188
|
+
return "monitor", serial, event_key_from_topic(rest[-1])
|
|
189
|
+
|
|
190
|
+
return None, None, None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def extract_event_duration_seconds(payload: Any) -> float:
|
|
194
|
+
"""Extract an event duration from a payload."""
|
|
195
|
+
|
|
196
|
+
default = 5.0
|
|
197
|
+
if not isinstance(payload, Mapping):
|
|
198
|
+
return default
|
|
199
|
+
|
|
200
|
+
duration = payload.get("duration")
|
|
201
|
+
if duration is None or isinstance(duration, bool):
|
|
202
|
+
return default
|
|
203
|
+
|
|
204
|
+
if isinstance(duration, int | float):
|
|
205
|
+
return float(duration) if duration > 0 else default
|
|
206
|
+
|
|
207
|
+
if not isinstance(duration, str):
|
|
208
|
+
return default
|
|
209
|
+
|
|
210
|
+
duration = duration.strip()
|
|
211
|
+
if not duration:
|
|
212
|
+
return default
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
parsed = float(duration)
|
|
216
|
+
except ValueError:
|
|
217
|
+
parts = duration.split(":")
|
|
218
|
+
if len(parts) not in (2, 3):
|
|
219
|
+
return default
|
|
220
|
+
try:
|
|
221
|
+
numbers = [float(part) for part in parts]
|
|
222
|
+
except ValueError:
|
|
223
|
+
return default
|
|
224
|
+
if len(numbers) == 2:
|
|
225
|
+
minutes, seconds = numbers
|
|
226
|
+
total = minutes * 60 + seconds
|
|
227
|
+
else:
|
|
228
|
+
hours, minutes, seconds = numbers
|
|
229
|
+
total = hours * 3600 + minutes * 60 + seconds
|
|
230
|
+
return total if total > 0 else default
|
|
231
|
+
|
|
232
|
+
return parsed if parsed > 0 else default
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def extract_explicit_event_state(payload: Any) -> bool | None:
|
|
236
|
+
"""Extract an explicit on/off state from a payload when available."""
|
|
237
|
+
|
|
238
|
+
if not isinstance(payload, Mapping):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
for key in (
|
|
242
|
+
"active",
|
|
243
|
+
"detected",
|
|
244
|
+
"is_active",
|
|
245
|
+
"is_detected",
|
|
246
|
+
"motion",
|
|
247
|
+
"motion_detected",
|
|
248
|
+
"cry",
|
|
249
|
+
"cry_detected",
|
|
250
|
+
"noise",
|
|
251
|
+
"noise_detected",
|
|
252
|
+
):
|
|
253
|
+
value = payload.get(key)
|
|
254
|
+
if isinstance(value, bool):
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
state_value = payload.get("state")
|
|
258
|
+
if isinstance(state_value, str):
|
|
259
|
+
normalized = state_value.strip().lower()
|
|
260
|
+
if normalized in {"active", "detected", "on", "true"}:
|
|
261
|
+
return True
|
|
262
|
+
if normalized in {"inactive", "off", "false", "idle"}:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_message(
|
|
269
|
+
topic: str,
|
|
270
|
+
payload: Any,
|
|
271
|
+
*,
|
|
272
|
+
timestamp: datetime | None = None,
|
|
273
|
+
) -> HarborEvent | None:
|
|
274
|
+
"""Parse a raw Harbor MQTT message into a typed Harbor event."""
|
|
275
|
+
|
|
276
|
+
source_type, serial, event_key = parse_topic(topic)
|
|
277
|
+
if source_type is None or serial is None or event_key is None:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
raw_payload = parse_payload(payload)
|
|
281
|
+
event_timestamp = timestamp or datetime.now(UTC)
|
|
282
|
+
app_version, os_version, display_name = _extract_metadata(raw_payload)
|
|
283
|
+
base_kwargs = {
|
|
284
|
+
"source_sn": serial,
|
|
285
|
+
"source_type": source_type,
|
|
286
|
+
"topic": topic,
|
|
287
|
+
"event_key": event_key,
|
|
288
|
+
"timestamp": event_timestamp,
|
|
289
|
+
"raw_payload": raw_payload,
|
|
290
|
+
"app_version": app_version,
|
|
291
|
+
"os_version": os_version,
|
|
292
|
+
"display_name": display_name,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if event_key == "heartbeat":
|
|
296
|
+
if typed_payload := _validate_payload(HeartbeatEvent, raw_payload, topic):
|
|
297
|
+
return HeartbeatUpdate(payload=typed_payload, **base_kwargs)
|
|
298
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
299
|
+
|
|
300
|
+
if event_key == "local_livekit_heartbeat":
|
|
301
|
+
if typed_payload := _validate_payload(LocalLivekitHeartbeatEvent, raw_payload, topic):
|
|
302
|
+
return LocalLivekitHeartbeatUpdate(
|
|
303
|
+
payload=typed_payload,
|
|
304
|
+
viewers=_extract_viewers_from_local_livekit(typed_payload),
|
|
305
|
+
**base_kwargs,
|
|
306
|
+
)
|
|
307
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
308
|
+
|
|
309
|
+
if event_key == "viewer_joined":
|
|
310
|
+
if typed_payload := _validate_payload(ViewerJoinedEvent, raw_payload, topic):
|
|
311
|
+
return ViewerJoinedUpdate(
|
|
312
|
+
payload=typed_payload,
|
|
313
|
+
viewer=_extract_viewer_info_from_payload(raw_payload)
|
|
314
|
+
or _extract_viewer_info(
|
|
315
|
+
typed_payload.viewer_id,
|
|
316
|
+
typed_payload.identity,
|
|
317
|
+
typed_payload.client,
|
|
318
|
+
typed_payload.is_local,
|
|
319
|
+
typed_payload.role,
|
|
320
|
+
),
|
|
321
|
+
**base_kwargs,
|
|
322
|
+
)
|
|
323
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
324
|
+
|
|
325
|
+
if event_key == "viewer_left":
|
|
326
|
+
if typed_payload := _validate_payload(ViewerLeftEvent, raw_payload, topic):
|
|
327
|
+
return ViewerLeftUpdate(
|
|
328
|
+
payload=typed_payload,
|
|
329
|
+
viewer_id=_extract_viewer_id_from_payload(raw_payload)
|
|
330
|
+
or _coalesce_string(
|
|
331
|
+
typed_payload.viewer_id,
|
|
332
|
+
typed_payload.identity,
|
|
333
|
+
typed_payload.client,
|
|
334
|
+
),
|
|
335
|
+
**base_kwargs,
|
|
336
|
+
)
|
|
337
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
338
|
+
|
|
339
|
+
if event_key == "settings":
|
|
340
|
+
if typed_payload := _validate_payload(SettingsEvent, raw_payload, topic):
|
|
341
|
+
return SettingsUpdate(payload=typed_payload, **base_kwargs)
|
|
342
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
343
|
+
|
|
344
|
+
if source_type == "camera":
|
|
345
|
+
active_seconds = extract_event_duration_seconds(raw_payload)
|
|
346
|
+
explicit_state = extract_explicit_event_state(raw_payload)
|
|
347
|
+
|
|
348
|
+
if event_key == "motion_detection":
|
|
349
|
+
if typed_payload := _validate_payload(MotionDetectedEvent, raw_payload, topic):
|
|
350
|
+
return MotionDetectedUpdate(
|
|
351
|
+
payload=typed_payload,
|
|
352
|
+
active_seconds=active_seconds,
|
|
353
|
+
explicit_state=explicit_state,
|
|
354
|
+
**base_kwargs,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return CameraEventUpdate(
|
|
358
|
+
payload=raw_payload,
|
|
359
|
+
active_seconds=active_seconds,
|
|
360
|
+
explicit_state=explicit_state,
|
|
361
|
+
**base_kwargs,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return RawEventUpdate(payload=raw_payload, **base_kwargs)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class SubscriptionManager:
|
|
368
|
+
"""Manage event subscriptions for typed Harbor events."""
|
|
369
|
+
|
|
370
|
+
def __init__(self) -> None:
|
|
371
|
+
"""Initialize the subscription manager."""
|
|
372
|
+
|
|
373
|
+
self._subscribers: dict[type[HarborEvent], list[CallbackType]] = defaultdict(list)
|
|
374
|
+
self._all_subscribers: list[CallbackType] = []
|
|
375
|
+
|
|
376
|
+
@overload
|
|
377
|
+
def subscribe(
|
|
378
|
+
self,
|
|
379
|
+
event_type: type[EventT],
|
|
380
|
+
callback: Callable[[EventT], Any],
|
|
381
|
+
) -> Callable[[], None]: ...
|
|
382
|
+
|
|
383
|
+
@overload
|
|
384
|
+
def subscribe(
|
|
385
|
+
self,
|
|
386
|
+
event_type: type[BaseModel],
|
|
387
|
+
callback: CallbackType,
|
|
388
|
+
) -> Callable[[], None]: ...
|
|
389
|
+
|
|
390
|
+
@overload
|
|
391
|
+
def subscribe(
|
|
392
|
+
self,
|
|
393
|
+
event_type: None,
|
|
394
|
+
callback: CallbackType,
|
|
395
|
+
) -> Callable[[], None]: ...
|
|
396
|
+
|
|
397
|
+
def subscribe(
|
|
398
|
+
self,
|
|
399
|
+
event_type: type[HarborEvent] | type[BaseModel] | None,
|
|
400
|
+
callback: Callable[[Any], Any],
|
|
401
|
+
) -> Callable[[], None]:
|
|
402
|
+
"""Subscribe to a Harbor event type or to all events."""
|
|
403
|
+
|
|
404
|
+
typed_callback = cast(CallbackType, callback)
|
|
405
|
+
if event_type is None:
|
|
406
|
+
self._all_subscribers.append(typed_callback)
|
|
407
|
+
|
|
408
|
+
def unsubscribe_all() -> None:
|
|
409
|
+
if typed_callback in self._all_subscribers:
|
|
410
|
+
self._all_subscribers.remove(typed_callback)
|
|
411
|
+
|
|
412
|
+
return unsubscribe_all
|
|
413
|
+
|
|
414
|
+
resolved_type = _resolve_event_type(event_type)
|
|
415
|
+
self._subscribers[resolved_type].append(typed_callback)
|
|
416
|
+
|
|
417
|
+
def unsubscribe_event() -> None:
|
|
418
|
+
if typed_callback in self._subscribers[resolved_type]:
|
|
419
|
+
self._subscribers[resolved_type].remove(typed_callback)
|
|
420
|
+
|
|
421
|
+
return unsubscribe_event
|
|
422
|
+
|
|
423
|
+
async def emit(self, event: HarborEvent) -> None:
|
|
424
|
+
"""Emit an event to all matching subscribers."""
|
|
425
|
+
|
|
426
|
+
seen_callbacks: set[int] = set()
|
|
427
|
+
callbacks: list[CallbackType] = []
|
|
428
|
+
|
|
429
|
+
for base_type in type(event).__mro__:
|
|
430
|
+
if not isinstance(base_type, type) or not issubclass(base_type, HarborEvent):
|
|
431
|
+
continue
|
|
432
|
+
for callback in self._subscribers.get(cast(type[HarborEvent], base_type), []):
|
|
433
|
+
callback_id = id(callback)
|
|
434
|
+
if callback_id in seen_callbacks:
|
|
435
|
+
continue
|
|
436
|
+
seen_callbacks.add(callback_id)
|
|
437
|
+
callbacks.append(callback)
|
|
438
|
+
|
|
439
|
+
for callback in self._all_subscribers:
|
|
440
|
+
callback_id = id(callback)
|
|
441
|
+
if callback_id in seen_callbacks:
|
|
442
|
+
continue
|
|
443
|
+
seen_callbacks.add(callback_id)
|
|
444
|
+
callbacks.append(callback)
|
|
445
|
+
|
|
446
|
+
for callback in callbacks:
|
|
447
|
+
try:
|
|
448
|
+
result = callback(event)
|
|
449
|
+
if asyncio.iscoroutine(result):
|
|
450
|
+
await result
|
|
451
|
+
except Exception:
|
|
452
|
+
_LOGGER.exception(
|
|
453
|
+
"Error in event callback %s for %s",
|
|
454
|
+
getattr(callback, "__qualname__", repr(callback)),
|
|
455
|
+
event.event_key,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class HarborEventBus:
|
|
460
|
+
"""Parse raw MQTT messages and emit typed Harbor events."""
|
|
461
|
+
|
|
462
|
+
def __init__(self) -> None:
|
|
463
|
+
"""Initialize the Harbor event bus."""
|
|
464
|
+
|
|
465
|
+
self._subscriptions = SubscriptionManager()
|
|
466
|
+
|
|
467
|
+
@overload
|
|
468
|
+
def subscribe(
|
|
469
|
+
self,
|
|
470
|
+
event_type: type[EventT],
|
|
471
|
+
callback: Callable[[EventT], Any],
|
|
472
|
+
) -> Callable[[], None]: ...
|
|
473
|
+
|
|
474
|
+
@overload
|
|
475
|
+
def subscribe(
|
|
476
|
+
self,
|
|
477
|
+
event_type: type[BaseModel],
|
|
478
|
+
callback: CallbackType,
|
|
479
|
+
) -> Callable[[], None]: ...
|
|
480
|
+
|
|
481
|
+
@overload
|
|
482
|
+
def subscribe(
|
|
483
|
+
self,
|
|
484
|
+
event_type: None,
|
|
485
|
+
callback: CallbackType,
|
|
486
|
+
) -> Callable[[], None]: ...
|
|
487
|
+
|
|
488
|
+
def subscribe(
|
|
489
|
+
self,
|
|
490
|
+
event_type: type[HarborEvent] | type[BaseModel] | None,
|
|
491
|
+
callback: Callable[[Any], Any],
|
|
492
|
+
) -> Callable[[], None]:
|
|
493
|
+
"""Subscribe to typed Harbor events."""
|
|
494
|
+
|
|
495
|
+
return self._subscriptions.subscribe(event_type, callback)
|
|
496
|
+
|
|
497
|
+
async def async_process_message(self, topic: str, payload: Any) -> HarborEvent | None:
|
|
498
|
+
"""Parse a raw MQTT message and emit a typed event when possible."""
|
|
499
|
+
|
|
500
|
+
if event := parse_message(topic, payload):
|
|
501
|
+
await self._subscriptions.emit(event)
|
|
502
|
+
return event
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _resolve_event_type(event_type: type[HarborEvent] | type[BaseModel]) -> type[HarborEvent]:
|
|
507
|
+
"""Resolve a subscription type into a Harbor event class."""
|
|
508
|
+
|
|
509
|
+
if issubclass(event_type, HarborEvent):
|
|
510
|
+
return event_type
|
|
511
|
+
|
|
512
|
+
if event_type in EVENT_MESSAGE_MAP:
|
|
513
|
+
return EVENT_MESSAGE_MAP[event_type]
|
|
514
|
+
|
|
515
|
+
raise ValueError(f"Unknown event type: {event_type}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
PayloadT = TypeVar("PayloadT", bound=BaseModel)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _validate_payload(payload_type: type[PayloadT], payload: Any, topic: str) -> PayloadT | None:
|
|
522
|
+
"""Validate a payload with a Harbor payload model."""
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
return payload_type.model_validate(payload)
|
|
526
|
+
except ValidationError:
|
|
527
|
+
_LOGGER.debug("Unable to validate payload for topic %s", topic, exc_info=True)
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _extract_metadata(payload: Any) -> tuple[str | None, str | None, str | None]:
|
|
532
|
+
"""Extract metadata shared across Harbor messages."""
|
|
533
|
+
|
|
534
|
+
if not isinstance(payload, Mapping):
|
|
535
|
+
return None, None, None
|
|
536
|
+
|
|
537
|
+
app_version = _coerce_string(payload.get("app_version"))
|
|
538
|
+
os_version = _coerce_string(payload.get("os_version"))
|
|
539
|
+
|
|
540
|
+
display_name = None
|
|
541
|
+
settings = payload.get("settings")
|
|
542
|
+
if isinstance(settings, Mapping):
|
|
543
|
+
display_name = _coerce_string(settings.get("preference_display_name"))
|
|
544
|
+
|
|
545
|
+
return app_version, os_version, display_name
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _extract_viewers_from_local_livekit(
|
|
549
|
+
payload: LocalLivekitHeartbeatEvent,
|
|
550
|
+
) -> tuple[ViewerInfo, ...]:
|
|
551
|
+
"""Normalize viewer data from a local LiveKit heartbeat."""
|
|
552
|
+
|
|
553
|
+
viewers: list[ViewerInfo] = []
|
|
554
|
+
|
|
555
|
+
for viewer_key, viewer_payload in payload.viewers_by_identity_full.items():
|
|
556
|
+
if not isinstance(viewer_payload, Mapping):
|
|
557
|
+
continue
|
|
558
|
+
viewer_id = _coalesce_string(
|
|
559
|
+
viewer_payload.get("identity"),
|
|
560
|
+
viewer_payload.get("viewer_id"),
|
|
561
|
+
viewer_payload.get("client"),
|
|
562
|
+
str(viewer_key),
|
|
563
|
+
)
|
|
564
|
+
if viewer_id is None:
|
|
565
|
+
continue
|
|
566
|
+
viewers.append(
|
|
567
|
+
ViewerInfo(
|
|
568
|
+
viewer_id=viewer_id,
|
|
569
|
+
identity=_coalesce_string(viewer_payload.get("identity"), viewer_id),
|
|
570
|
+
client=_coerce_string(viewer_payload.get("client")),
|
|
571
|
+
is_local=_coerce_bool(viewer_payload.get("is_local")),
|
|
572
|
+
role=_coerce_string(viewer_payload.get("role")),
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if viewers:
|
|
577
|
+
return tuple(viewers)
|
|
578
|
+
|
|
579
|
+
for viewer_key in payload.viewers_by_identity:
|
|
580
|
+
viewer_id = str(viewer_key)
|
|
581
|
+
viewers.append(ViewerInfo(viewer_id=viewer_id, identity=viewer_id))
|
|
582
|
+
|
|
583
|
+
return tuple(viewers)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _extract_viewer_info(
|
|
587
|
+
viewer_id: Any,
|
|
588
|
+
identity: Any,
|
|
589
|
+
client: Any,
|
|
590
|
+
is_local: Any,
|
|
591
|
+
role: Any,
|
|
592
|
+
) -> ViewerInfo | None:
|
|
593
|
+
"""Normalize a single viewer event payload."""
|
|
594
|
+
|
|
595
|
+
normalized_viewer_id = _coalesce_string(identity, viewer_id, client)
|
|
596
|
+
if normalized_viewer_id is None:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
return ViewerInfo(
|
|
600
|
+
viewer_id=normalized_viewer_id,
|
|
601
|
+
identity=_coalesce_string(identity, normalized_viewer_id),
|
|
602
|
+
client=_coerce_string(client),
|
|
603
|
+
is_local=_coerce_bool(is_local),
|
|
604
|
+
role=_coerce_string(role),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _extract_viewer_info_from_payload(payload: Any) -> ViewerInfo | None:
|
|
609
|
+
"""Normalize viewer data from top-level or nested viewer payloads."""
|
|
610
|
+
|
|
611
|
+
if not isinstance(payload, Mapping):
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
viewer_payload: Mapping[str, Any] = payload
|
|
615
|
+
for key in ("viewer", "participant", "viewer_info", "data"):
|
|
616
|
+
nested = payload.get(key)
|
|
617
|
+
if isinstance(nested, Mapping):
|
|
618
|
+
viewer_payload = nested
|
|
619
|
+
break
|
|
620
|
+
|
|
621
|
+
return _extract_viewer_info(
|
|
622
|
+
_coalesce_string(viewer_payload.get("viewer_id"), viewer_payload.get("id")),
|
|
623
|
+
viewer_payload.get("identity"),
|
|
624
|
+
viewer_payload.get("client"),
|
|
625
|
+
viewer_payload.get("is_local"),
|
|
626
|
+
viewer_payload.get("role"),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _extract_viewer_id_from_payload(payload: Any) -> str | None:
|
|
631
|
+
"""Extract a viewer id from top-level or nested viewer payloads."""
|
|
632
|
+
|
|
633
|
+
if viewer := _extract_viewer_info_from_payload(payload):
|
|
634
|
+
return viewer.viewer_id
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _coerce_bool(value: Any) -> bool | None:
|
|
639
|
+
"""Convert a value to bool when possible."""
|
|
640
|
+
|
|
641
|
+
if isinstance(value, bool):
|
|
642
|
+
return value
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _coerce_string(value: Any) -> str | None:
|
|
647
|
+
"""Convert a value to a non-empty string when possible."""
|
|
648
|
+
|
|
649
|
+
if not isinstance(value, str):
|
|
650
|
+
return None
|
|
651
|
+
stripped = value.strip()
|
|
652
|
+
return stripped or None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _coalesce_string(*values: Any) -> str | None:
|
|
656
|
+
"""Return the first non-empty string-like value."""
|
|
657
|
+
|
|
658
|
+
for value in values:
|
|
659
|
+
if value is None:
|
|
660
|
+
continue
|
|
661
|
+
if isinstance(value, str):
|
|
662
|
+
stripped = value.strip()
|
|
663
|
+
if stripped:
|
|
664
|
+
return stripped
|
|
665
|
+
continue
|
|
666
|
+
return str(value)
|
|
667
|
+
return None
|