harbor-python 1.1.0__tar.gz → 1.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harbor-python
3
- Version: 1.1.0
3
+ Version: 1.2.1
4
4
  Summary: A package to locally connect to a Harbor Sleep Camera
5
5
  Author: Andres Garcia, Lash-L
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,7 @@
1
1
  from .config import HarborCameraConfig
2
2
  from .core import Harbor
3
3
  from .data.mqtt_models import (
4
+ GetCameraSettingsRequest,
4
5
  HeartbeatEvent,
5
6
  LocalLivekitHeartbeatEvent,
6
7
  MotionDetectedEvent,
@@ -49,6 +50,7 @@ __all__ = [
49
50
  "MotionDetectedUpdate",
50
51
  "ViewerInfo",
51
52
  "parse_message",
53
+ "GetCameraSettingsRequest",
52
54
  "HeartbeatEvent",
53
55
  "LocalLivekitHeartbeatEvent",
54
56
  "SettingsEvent",
@@ -2,8 +2,9 @@ import logging
2
2
  from typing import Any
3
3
 
4
4
  from .config import HarborCameraConfig
5
+ from .data.mqtt_models import SettingsEvent
5
6
  from .device import HarborDevice
6
- from .mqtt import HarborMQTTClient
7
+ from .mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient
7
8
 
8
9
  _LOGGER = logging.getLogger(__name__)
9
10
 
@@ -32,12 +33,13 @@ class Harbor:
32
33
  if config.serial in self._clients:
33
34
  _LOGGER.warning("Camera connection already exists: %s", config.serial)
34
35
  return
35
- topics = list(self._topics_cache)
36
+ topics = list({*self._topics_cache, f"cameras/{config.serial}/responses/#"})
36
37
  client = HarborMQTTClient(
37
38
  config=config,
38
39
  topics=topics,
39
40
  message_handler=self.handle_message,
40
41
  client_id=f"harbor-client-{config.serial}",
42
+ initial_commands=DEFAULT_INITIAL_COMMANDS,
41
43
  )
42
44
  self._clients[config.serial] = client
43
45
  _LOGGER.info("Added MQTT client for camera: %s", config.serial)
@@ -54,6 +56,35 @@ class Harbor:
54
56
  for device in self._devices.values():
55
57
  device.shutdown()
56
58
 
59
+ async def publish_camera_command(
60
+ self,
61
+ serial: str,
62
+ command: str,
63
+ payload: Any,
64
+ ) -> None:
65
+ """Publish a command to a camera."""
66
+ await self._get_client(serial).publish_command(command, payload)
67
+
68
+ async def request_camera_command(
69
+ self,
70
+ serial: str,
71
+ command: str,
72
+ payload: dict[str, Any],
73
+ *,
74
+ timeout: float = 10.0,
75
+ ) -> Any:
76
+ """Publish a command to a camera and wait for the matching response."""
77
+ return await self._get_client(serial).request_command(command, payload, timeout=timeout)
78
+
79
+ async def get_camera_settings(
80
+ self,
81
+ serial: str,
82
+ *,
83
+ timeout: float = 10.0,
84
+ ) -> SettingsEvent:
85
+ """Request the camera settings payload."""
86
+ return await self._get_client(serial).get_settings(timeout=timeout)
87
+
57
88
  async def handle_message(self, topic: str, payload: Any) -> None:
58
89
  """
59
90
  Central message handler.
@@ -61,3 +92,9 @@ class Harbor:
61
92
  """
62
93
  for device in self._devices.values():
63
94
  await device.handle_message(topic, payload)
95
+
96
+ def _get_client(self, serial: str) -> HarborMQTTClient:
97
+ try:
98
+ return self._clients[serial]
99
+ except KeyError as exc:
100
+ raise KeyError(f"No camera connection exists for serial {serial!r}") from exc
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
8
8
  class HarborMQTTPayload(BaseModel):
9
9
  """Base model for Harbor MQTT payloads."""
10
10
 
11
- model_config = ConfigDict(extra="allow")
11
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
12
12
 
13
13
 
14
14
  class LocalLivekitHeartbeatEvent(HarborMQTTPayload):
@@ -82,14 +82,22 @@ class SettingsEvent(HarborMQTTPayload):
82
82
  """Payload for a settings event."""
83
83
 
84
84
  client: str | None = None
85
- is_updating: bool | None = None
85
+ is_updating: bool | None = Field(default=None, alias="isUpdating")
86
86
  seq: str | None = None
87
87
  settings: Settings | None = None
88
88
  state: SettingsState | None = None
89
- triggered_by: str | None = None
89
+ triggered_by: str | None = Field(default=None, alias="triggeredBy")
90
90
  updated: dict[str, Any] = Field(default_factory=dict)
91
91
 
92
92
 
93
+ class GetCameraSettingsRequest(HarborMQTTPayload):
94
+ """Payload for the get-settings camera command."""
95
+
96
+ seq: str
97
+ client: str
98
+ triggered_by: str = Field(alias="triggeredBy")
99
+
100
+
93
101
  class ViewerJoinedEvent(HarborMQTTPayload):
94
102
  """Payload for a viewer joined event."""
95
103
 
@@ -23,9 +23,10 @@ DEFAULT_EVENT_ACTIVE_SECONDS = 5.0
23
23
  # Known values for enumerated state fields. The device reports these in
24
24
  # mixed/upper case (e.g. "PLAYING", "GOOD"); they are normalized to the
25
25
  # lowercase values below before being stored in ``HarborDeviceState.values``.
26
- # A value outside these sets is still stored lowercased (and logged once),
27
- # so consumers that declare options up front should treat these sets as the
28
- # baseline, not a hard guarantee.
26
+ # A value outside these sets is mapped to ``UNKNOWN_ENUM_VALUE`` (and logged
27
+ # once), so a stored enum value is always a member of its set. Consumers can
28
+ # rely on that guarantee instead of clamping the value themselves.
29
+ UNKNOWN_ENUM_VALUE = "unknown"
29
30
  SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"})
30
31
  STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"})
31
32
 
@@ -46,7 +47,10 @@ class HarborCamera(HarborDevice):
46
47
  def get_topics(self) -> list[str]:
47
48
  """Return topics that should be subscribed for this device."""
48
49
 
49
- return [f"cameras/{self.serial}/events/#"]
50
+ return [
51
+ f"cameras/{self.serial}/events/#",
52
+ f"cameras/{self.serial}/responses/#",
53
+ ]
50
54
 
51
55
  def _apply_event(self, event: HarborEvent) -> None:
52
56
  """Apply a Harbor event to camera state, including camera-only events."""
@@ -99,21 +103,28 @@ class HarborCamera(HarborDevice):
99
103
  value: str | None,
100
104
  known_values: frozenset[str],
101
105
  ) -> str | None:
102
- """Normalize an enumerated device value to a stable lowercase string."""
106
+ """Normalize an enumerated device value to a known lowercase member.
107
+
108
+ Returns ``None`` only when the device omitted the field. A value that
109
+ does not match ``known_values`` is mapped to ``UNKNOWN_ENUM_VALUE`` so
110
+ callers can rely on the result always being a member of the set.
111
+ """
103
112
  if value is None:
104
113
  return None
105
114
  normalized = value.strip().lower()
106
115
  if not normalized:
107
116
  return None
108
- if normalized not in known_values and (field_name, normalized) not in self._unexpected_enum_values:
109
- self._unexpected_enum_values.add((field_name, normalized))
110
- _LOGGER.warning(
111
- "Camera %s reported unexpected %s value %r (known values: %s)",
112
- self.serial,
113
- field_name,
114
- normalized,
115
- sorted(known_values),
116
- )
117
+ if normalized not in known_values:
118
+ if (field_name, normalized) not in self._unexpected_enum_values:
119
+ self._unexpected_enum_values.add((field_name, normalized))
120
+ _LOGGER.warning(
121
+ "Camera %s reported unexpected %s value %r (known values: %s)",
122
+ self.serial,
123
+ field_name,
124
+ normalized,
125
+ sorted(known_values),
126
+ )
127
+ return UNKNOWN_ENUM_VALUE
117
128
  return normalized
118
129
 
119
130
  def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
@@ -178,7 +178,7 @@ def parse_topic(
178
178
  return None, None, None
179
179
 
180
180
  root, serial, event_root, *rest = parts
181
- if event_root != "events" or not rest:
181
+ if event_root not in {"events", "responses"} or not rest:
182
182
  return None, None, None
183
183
 
184
184
  if root == "cameras":
@@ -336,7 +336,7 @@ def parse_message(
336
336
  )
337
337
  return RawEventUpdate(payload=raw_payload, **base_kwargs)
338
338
 
339
- if event_key == "settings":
339
+ if event_key in {"settings", "get_settings"}:
340
340
  if typed_payload := _validate_payload(SettingsEvent, raw_payload, topic):
341
341
  return SettingsUpdate(payload=typed_payload, **base_kwargs)
342
342
  return RawEventUpdate(payload=raw_payload, **base_kwargs)
@@ -1,18 +1,29 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import json
3
5
  import logging
4
6
  import sys
5
7
  from collections.abc import Awaitable, Callable
6
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
+ from uuid import uuid4
7
10
 
8
11
  from aiomqtt import Client, MqttError
9
12
 
10
13
  from .config import HarborCameraConfig
14
+ from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent
11
15
  from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context
12
16
 
17
+ if TYPE_CHECKING:
18
+ from .events import HarborEvent
19
+
13
20
  _LOGGER = logging.getLogger(__name__)
14
21
 
15
22
  DEFAULT_CONNECTION_GRACE_PERIOD = 90.0
23
+ DEFAULT_COMMAND_QOS = 2
24
+ DEFAULT_REQUEST_TIMEOUT = 10.0
25
+ GET_SETTINGS_COMMAND = "get-settings"
26
+ DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,)
16
27
 
17
28
 
18
29
  class HarborMQTTClient:
@@ -20,11 +31,12 @@ class HarborMQTTClient:
20
31
  self,
21
32
  config: HarborCameraConfig,
22
33
  topics: list[str],
23
- message_handler: Callable[[str, Any], Awaitable[None]],
34
+ message_handler: Callable[[str, Any], Awaitable[HarborEvent | None]],
24
35
  client_id: str | None = None,
25
36
  ssl_context_cache: dict | None = None,
26
37
  on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
27
38
  connection_grace_period: float = DEFAULT_CONNECTION_GRACE_PERIOD,
39
+ initial_commands: list[str] | tuple[str, ...] | None = None,
28
40
  ) -> None:
29
41
  """Initialize the MQTT client.
30
42
 
@@ -42,9 +54,12 @@ class HarborMQTTClient:
42
54
  self.ssl_context_cache = ssl_context_cache or {}
43
55
  self.on_connection_change = on_connection_change
44
56
  self.connection_grace_period = connection_grace_period
57
+ self.initial_commands = tuple(initial_commands or ())
45
58
  self.connected: bool = False
46
59
  self._stop_event = asyncio.Event()
47
60
  self._task: asyncio.Task | None = None
61
+ self._client: Client | None = None
62
+ self._pending_responses: dict[str, asyncio.Future[Any]] = {}
48
63
  self._reported_connected: bool | None = None
49
64
  self._disconnect_grace_task: asyncio.Task | None = None
50
65
 
@@ -55,6 +70,23 @@ class HarborMQTTClient:
55
70
  payload = payload_raw
56
71
 
57
72
  await self.message_handler(topic, payload)
73
+ self._resolve_pending_response(topic, payload)
74
+
75
+ def _resolve_pending_response(self, topic: str, payload: Any) -> None:
76
+ """Resolve a pending request when a camera response echoes its seq."""
77
+ if not topic.startswith(f"cameras/{self.config.serial}/responses/"):
78
+ return
79
+ if not isinstance(payload, dict):
80
+ return
81
+
82
+ seq = payload.get("seq")
83
+ if not isinstance(seq, str):
84
+ return
85
+
86
+ future = self._pending_responses.pop(seq, None)
87
+ if future is None or future.done():
88
+ return
89
+ future.set_result(payload)
58
90
 
59
91
  async def _set_connected(self, connected: bool) -> None:
60
92
  """Update the raw connection flag and debounce listener notifications."""
@@ -108,6 +140,12 @@ class HarborMQTTClient:
108
140
  def _invalidate_ssl_cache(self) -> None:
109
141
  self.ssl_context_cache.pop(get_ssl_cache_key(self.config), None)
110
142
 
143
+ def _fail_pending_responses(self, exc: Exception) -> None:
144
+ for future in self._pending_responses.values():
145
+ if not future.done():
146
+ future.set_exception(exc)
147
+ self._pending_responses.clear()
148
+
111
149
  async def run(self) -> None:
112
150
  reconnect_delay = 2
113
151
 
@@ -143,6 +181,7 @@ class HarborMQTTClient:
143
181
  timeout=10,
144
182
  identifier=self.client_id,
145
183
  ) as client:
184
+ self._client = client
146
185
  _LOGGER.info(
147
186
  "Harbor: MQTT connected to %s:%s for camera %s",
148
187
  host,
@@ -159,6 +198,8 @@ class HarborMQTTClient:
159
198
  self.config.serial,
160
199
  )
161
200
 
201
+ await self._publish_initial_commands()
202
+
162
203
  async for message in client.messages:
163
204
  if self._stop_event.is_set():
164
205
  break
@@ -177,6 +218,8 @@ class HarborMQTTClient:
177
218
 
178
219
  reconnect_delay = 2
179
220
  finally:
221
+ self._client = None
222
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client disconnected"))
180
223
  await self._set_connected(False)
181
224
 
182
225
  except TimeoutError as e:
@@ -247,9 +290,131 @@ class HarborMQTTClient:
247
290
  _LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
248
291
  # An intentional stop is a stable disconnect: skip the grace period.
249
292
  self._cancel_disconnect_grace()
293
+ self._client = None
294
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client stopped"))
250
295
  if self._reported_connected:
251
296
  await self._notify_connection_change(False)
252
297
 
298
+ async def publish(
299
+ self,
300
+ topic: str,
301
+ payload: Any,
302
+ *,
303
+ qos: int = DEFAULT_COMMAND_QOS,
304
+ retain: bool = False,
305
+ ) -> None:
306
+ """Publish a JSON-compatible payload to a Harbor MQTT topic."""
307
+ client = self._client
308
+ if client is None or not self.connected:
309
+ raise ConnectionError(f"Harbor MQTT client is not connected for camera {self.config.serial}")
310
+
311
+ if isinstance(payload, str):
312
+ payload_raw = payload
313
+ else:
314
+ payload_raw = json.dumps(payload, separators=(",", ":"))
315
+
316
+ _LOGGER.debug(
317
+ "Harbor: MQTT publishing to topic '%s' for camera %s: %s",
318
+ topic,
319
+ self.config.serial,
320
+ payload_raw,
321
+ )
322
+ await client.publish(topic, payload_raw, qos=qos, retain=retain)
323
+
324
+ async def publish_command(
325
+ self,
326
+ command: str,
327
+ payload: Any,
328
+ *,
329
+ qos: int = DEFAULT_COMMAND_QOS,
330
+ ) -> None:
331
+ """Publish a camera command using the app's command topic layout."""
332
+ await self.publish(f"cameras/{self.config.serial}/{command}", payload, qos=qos)
333
+
334
+ async def _publish_initial_commands(self) -> None:
335
+ """Publish configured one-shot state population commands after connect."""
336
+ for command in self.initial_commands:
337
+ try:
338
+ if command == GET_SETTINGS_COMMAND:
339
+ await self.publish_command(command, self._build_get_settings_payload())
340
+ else:
341
+ _LOGGER.warning(
342
+ "Harbor: skipping unsupported initial command %s for camera %s",
343
+ command,
344
+ self.config.serial,
345
+ )
346
+ except Exception:
347
+ _LOGGER.exception(
348
+ "Harbor: failed to publish initial command %s for camera %s",
349
+ command,
350
+ self.config.serial,
351
+ )
352
+
353
+ def _build_get_settings_payload(
354
+ self,
355
+ *,
356
+ client: str | None = None,
357
+ triggered_by: str | None = None,
358
+ seq: str | None = None,
359
+ ) -> dict[str, Any]:
360
+ request = GetCameraSettingsRequest(
361
+ seq=seq or _generate_seq(),
362
+ client=client or self.client_id or f"harbor-client-{self.config.serial}",
363
+ triggered_by=triggered_by or "harbor-python",
364
+ )
365
+ return request.model_dump(by_alias=True)
366
+
367
+ async def request_command(
368
+ self,
369
+ command: str,
370
+ payload: dict[str, Any],
371
+ *,
372
+ seq: str | None = None,
373
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
374
+ qos: int = DEFAULT_COMMAND_QOS,
375
+ ) -> Any:
376
+ """Publish a command and wait for a response carrying the same seq."""
377
+ request_seq = seq or _generate_seq()
378
+ payload = {**payload, "seq": request_seq}
379
+ loop = asyncio.get_running_loop()
380
+ future: asyncio.Future[Any] = loop.create_future()
381
+ self._pending_responses[request_seq] = future
382
+
383
+ try:
384
+ await self.publish_command(command, payload, qos=qos)
385
+ return await asyncio.wait_for(future, timeout=timeout)
386
+ except Exception:
387
+ pending = self._pending_responses.pop(request_seq, None)
388
+ if pending is not None and not pending.done():
389
+ pending.cancel()
390
+ raise
391
+
392
+ async def get_settings(
393
+ self,
394
+ *,
395
+ client: str | None = None,
396
+ triggered_by: str | None = None,
397
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
398
+ ) -> SettingsEvent:
399
+ """Request camera settings via the get-settings command."""
400
+ seq = _generate_seq()
401
+ response = await self.request_command(
402
+ GET_SETTINGS_COMMAND,
403
+ self._build_get_settings_payload(
404
+ seq=seq,
405
+ client=client,
406
+ triggered_by=triggered_by,
407
+ ),
408
+ seq=seq,
409
+ timeout=timeout,
410
+ )
411
+ return SettingsEvent.model_validate(response)
412
+
253
413
  def __del__(self) -> None:
254
414
  if self._stop_event and not self._stop_event.is_set():
255
415
  self._stop_event.set()
416
+
417
+
418
+ def _generate_seq() -> str:
419
+ """Generate a request sequence string echoed by Harbor responses."""
420
+ return uuid4().hex
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harbor-python"
3
- version = "1.1.0"
3
+ version = "1.2.1"
4
4
  description = "A package to locally connect to a Harbor Sleep Camera"
5
5
  authors = [{name="Andres Garcia"},{name = "Lash-L"}]
6
6
  readme = "README.md"
File without changes
File without changes
File without changes