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/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