harbor-python 1.0.0__tar.gz → 1.2.0__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.0.0
3
+ Version: 1.2.0
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,
@@ -9,7 +10,7 @@ from .data.mqtt_models import (
9
10
  ViewerLeftEvent,
10
11
  )
11
12
  from .device import HarborDevice
12
- from .devices.camera import HarborCamera
13
+ from .devices.camera import SPEAKER_STATES, STREAM_QUALITIES, HarborCamera
13
14
  from .devices.monitor import HarborMonitor
14
15
  from .events import (
15
16
  CameraEventUpdate,
@@ -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",
@@ -59,4 +61,6 @@ __all__ = [
59
61
  "HarborViewer",
60
62
  "HarborEventState",
61
63
  "HarborDeviceState",
64
+ "SPEAKER_STATES",
65
+ "STREAM_QUALITIES",
62
66
  ]
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class HarborCameraConfig:
6
+ """Connection configuration for a Harbor camera.
7
+
8
+ Client certificate material can be provided either as in-memory PEM
9
+ strings (``cert_pem``/``key_pem``) or as file paths
10
+ (``cert_path``/``key_path``). PEM strings are preferred for consumers
11
+ that should not manage certificate files themselves: while building the
12
+ SSL context the library briefly stages the PEM data in a private
13
+ temporary directory (0600 files) that is deleted before the build
14
+ returns, so the caller never handles files. When both are provided, the
15
+ PEM strings win.
16
+ """
17
+
18
+ serial: str
19
+ cert_path: str | None = None
20
+ key_path: str | None = None
21
+ cert_dir: str | None = None
22
+
23
+ ip_address: str | None = None
24
+
25
+ cert_pem: str | None = None
26
+ key_pem: str | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ if (self.cert_pem is None) != (self.key_pem is None):
30
+ raise ValueError("cert_pem and key_pem must be provided together")
31
+ if (self.cert_path is None) != (self.key_path is None):
32
+ raise ValueError("cert_path and key_path must be provided together")
33
+ if self.cert_pem is None and self.cert_path is None:
34
+ raise ValueError("Certificate material is required: provide cert_pem/key_pem or cert_path/key_path")
@@ -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
 
@@ -20,6 +20,16 @@ _LOGGER = logging.getLogger(__name__)
20
20
  DEFAULT_CAMERA_EVENT_KEYS = ("motion_detection", "cry_detection", "noise_detection")
21
21
  DEFAULT_EVENT_ACTIVE_SECONDS = 5.0
22
22
 
23
+ # Known values for enumerated state fields. The device reports these in
24
+ # mixed/upper case (e.g. "PLAYING", "GOOD"); they are normalized to the
25
+ # lowercase values below before being stored in ``HarborDeviceState.values``.
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"
30
+ SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"})
31
+ STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"})
32
+
23
33
 
24
34
  class HarborCamera(HarborDevice):
25
35
  """Represents a Harbor camera device."""
@@ -29,6 +39,7 @@ class HarborCamera(HarborDevice):
29
39
  super().__init__(config.serial, "camera")
30
40
  self.config = config
31
41
  self._event_reset_handles: dict[str, asyncio.TimerHandle] = {}
42
+ self._unexpected_enum_values: set[tuple[str, str]] = set()
32
43
 
33
44
  for event_key in DEFAULT_CAMERA_EVENT_KEYS:
34
45
  self._ensure_camera_event(event_key)
@@ -36,7 +47,10 @@ class HarborCamera(HarborDevice):
36
47
  def get_topics(self) -> list[str]:
37
48
  """Return topics that should be subscribed for this device."""
38
49
 
39
- return [f"cameras/{self.serial}/events/#"]
50
+ return [
51
+ f"cameras/{self.serial}/events/#",
52
+ f"cameras/{self.serial}/responses/#",
53
+ ]
40
54
 
41
55
  def _apply_event(self, event: HarborEvent) -> None:
42
56
  """Apply a Harbor event to camera state, including camera-only events."""
@@ -60,8 +74,14 @@ class HarborCamera(HarborDevice):
60
74
  self._set_state_value("bitrate", payload.bitrate)
61
75
  self._set_state_value("wifi_strength", payload.network_bars)
62
76
  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)
77
+ self._set_state_value(
78
+ "speaker_state",
79
+ self._normalize_enum_value("speaker_state", payload.speaker_state, SPEAKER_STATES),
80
+ )
81
+ self._set_state_value(
82
+ "stream_quality",
83
+ self._normalize_enum_value("stream_quality", payload.stream_quality, STREAM_QUALITIES),
84
+ )
65
85
  self._set_state_value("app_start_time", payload.app_start_time)
66
86
  self._set_state_value("stream_start_time", payload.stream_start_time)
67
87
 
@@ -77,6 +97,36 @@ class HarborCamera(HarborDevice):
77
97
  }
78
98
  self.state.values["num_viewers"] = len(self.state.viewers)
79
99
 
100
+ def _normalize_enum_value(
101
+ self,
102
+ field_name: str,
103
+ value: str | None,
104
+ known_values: frozenset[str],
105
+ ) -> str | None:
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
+ """
112
+ if value is None:
113
+ return None
114
+ normalized = value.strip().lower()
115
+ if not normalized:
116
+ return None
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
128
+ return normalized
129
+
80
130
  def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
81
131
  """Apply a viewer joined update."""
82
132
  if viewer is 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)
@@ -0,0 +1,415 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import sys
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from aiomqtt import Client, MqttError
10
+
11
+ from .config import HarborCameraConfig
12
+ from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent
13
+ from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ DEFAULT_CONNECTION_GRACE_PERIOD = 90.0
18
+ DEFAULT_COMMAND_QOS = 2
19
+ DEFAULT_REQUEST_TIMEOUT = 10.0
20
+ GET_SETTINGS_COMMAND = "get-settings"
21
+ DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,)
22
+
23
+
24
+ class HarborMQTTClient:
25
+ def __init__(
26
+ self,
27
+ config: HarborCameraConfig,
28
+ topics: list[str],
29
+ message_handler: Callable[[str, Any], Awaitable[None]],
30
+ client_id: str | None = None,
31
+ ssl_context_cache: dict | None = None,
32
+ on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
33
+ connection_grace_period: float = DEFAULT_CONNECTION_GRACE_PERIOD,
34
+ initial_commands: list[str] | tuple[str, ...] | None = None,
35
+ ) -> None:
36
+ """Initialize the MQTT client.
37
+
38
+ ``on_connection_change`` fires only on stable connection-state
39
+ transitions: a disconnect is reported only if the client stays
40
+ disconnected for ``connection_grace_period`` seconds, so routine
41
+ TCP flapping never reaches the listener. Set the grace period to 0
42
+ to report every raw transition. ``connected`` always reflects the
43
+ raw transport state.
44
+ """
45
+ self.config = config
46
+ self.topics = topics
47
+ self.message_handler = message_handler
48
+ self.client_id = client_id
49
+ self.ssl_context_cache = ssl_context_cache or {}
50
+ self.on_connection_change = on_connection_change
51
+ self.connection_grace_period = connection_grace_period
52
+ self.initial_commands = tuple(initial_commands or ())
53
+ self.connected: bool = False
54
+ self._stop_event = asyncio.Event()
55
+ self._task: asyncio.Task | None = None
56
+ self._client: Client | None = None
57
+ self._pending_responses: dict[str, asyncio.Future[Any]] = {}
58
+ self._reported_connected: bool | None = None
59
+ self._disconnect_grace_task: asyncio.Task | None = None
60
+
61
+ async def _handle_message(self, topic: str, payload_raw: str) -> None:
62
+ try:
63
+ payload = json.loads(payload_raw)
64
+ except Exception:
65
+ payload = payload_raw
66
+
67
+ await self.message_handler(topic, payload)
68
+ self._resolve_pending_response(topic, payload)
69
+
70
+ def _resolve_pending_response(self, topic: str, payload: Any) -> None:
71
+ """Resolve a pending request when a camera response echoes its seq."""
72
+ if not topic.startswith(f"cameras/{self.config.serial}/responses/"):
73
+ return
74
+ if not isinstance(payload, dict):
75
+ return
76
+
77
+ seq = payload.get("seq")
78
+ if not isinstance(seq, str):
79
+ return
80
+
81
+ future = self._pending_responses.pop(seq, None)
82
+ if future is None or future.done():
83
+ return
84
+ future.set_result(payload)
85
+
86
+ async def _set_connected(self, connected: bool) -> None:
87
+ """Update the raw connection flag and debounce listener notifications."""
88
+ if self.connected == connected:
89
+ return
90
+ self.connected = connected
91
+
92
+ if connected:
93
+ self._cancel_disconnect_grace()
94
+ if self._reported_connected is not True:
95
+ await self._notify_connection_change(True)
96
+ return
97
+
98
+ if self._reported_connected is not True:
99
+ return
100
+ if self.connection_grace_period <= 0:
101
+ await self._notify_connection_change(False)
102
+ return
103
+ if self._disconnect_grace_task is None or self._disconnect_grace_task.done():
104
+ self._disconnect_grace_task = asyncio.create_task(self._disconnect_after_grace())
105
+
106
+ async def _disconnect_after_grace(self) -> None:
107
+ """Report a disconnect only if it survives the grace period."""
108
+ await asyncio.sleep(self.connection_grace_period)
109
+ self._disconnect_grace_task = None
110
+ if not self.connected:
111
+ _LOGGER.info(
112
+ "Harbor: camera %s still disconnected after %s second grace period",
113
+ self.config.serial,
114
+ self.connection_grace_period,
115
+ )
116
+ await self._notify_connection_change(False)
117
+
118
+ def _cancel_disconnect_grace(self) -> None:
119
+ if self._disconnect_grace_task is not None:
120
+ self._disconnect_grace_task.cancel()
121
+ self._disconnect_grace_task = None
122
+
123
+ async def _notify_connection_change(self, connected: bool) -> None:
124
+ self._reported_connected = connected
125
+ if self.on_connection_change is None:
126
+ return
127
+ try:
128
+ await self.on_connection_change(connected)
129
+ except Exception:
130
+ _LOGGER.exception(
131
+ "Harbor: connection-change listener raised for camera %s",
132
+ self.config.serial,
133
+ )
134
+
135
+ def _invalidate_ssl_cache(self) -> None:
136
+ self.ssl_context_cache.pop(get_ssl_cache_key(self.config), None)
137
+
138
+ def _fail_pending_responses(self, exc: Exception) -> None:
139
+ for future in self._pending_responses.values():
140
+ if not future.done():
141
+ future.set_exception(exc)
142
+ self._pending_responses.clear()
143
+
144
+ async def run(self) -> None:
145
+ reconnect_delay = 2
146
+
147
+ _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
148
+
149
+ try:
150
+ while not self._stop_event.is_set():
151
+ # Fetch the SSL context each attempt so invalidations in the
152
+ # error handlers below take effect on the next reconnect;
153
+ # unchanged material is a cheap cache hit.
154
+ try:
155
+ loop = asyncio.get_running_loop()
156
+ ssl_ctx = await loop.run_in_executor(None, get_ssl_context, self.config, self.ssl_context_cache)
157
+ except Exception as e:
158
+ _LOGGER.error("Harbor: Failed to create SSL context for camera %s: %s", self.config.serial, e)
159
+ # Ensure we clear any partial state
160
+ self._invalidate_ssl_cache()
161
+ return
162
+
163
+ try:
164
+ host = get_camera_host(self.config)
165
+ _LOGGER.info(
166
+ "Harbor: MQTT attempting connection to %s:%s for camera %s",
167
+ host,
168
+ 8884,
169
+ self.config.serial,
170
+ )
171
+
172
+ async with Client(
173
+ hostname=host,
174
+ port=8884,
175
+ tls_context=ssl_ctx,
176
+ timeout=10,
177
+ identifier=self.client_id,
178
+ ) as client:
179
+ self._client = client
180
+ _LOGGER.info(
181
+ "Harbor: MQTT connected to %s:%s for camera %s",
182
+ host,
183
+ 8884,
184
+ self.config.serial,
185
+ )
186
+ await self._set_connected(True)
187
+ try:
188
+ if self.topics:
189
+ await client.subscribe([(t, 0) for t in self.topics])
190
+ _LOGGER.info(
191
+ "Harbor: MQTT subscribed to topics: %s for camera %s",
192
+ self.topics,
193
+ self.config.serial,
194
+ )
195
+
196
+ await self._publish_initial_commands()
197
+
198
+ async for message in client.messages:
199
+ if self._stop_event.is_set():
200
+ break
201
+
202
+ payload_raw = message.payload.decode("utf-8", errors="replace")
203
+ topic = str(message.topic)
204
+
205
+ _LOGGER.debug(
206
+ "Harbor: MQTT message received on topic '%s' from camera %s: %s",
207
+ topic,
208
+ self.config.serial,
209
+ payload_raw,
210
+ )
211
+
212
+ await self._handle_message(topic, payload_raw)
213
+
214
+ reconnect_delay = 2
215
+ finally:
216
+ self._client = None
217
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client disconnected"))
218
+ await self._set_connected(False)
219
+
220
+ except TimeoutError as e:
221
+ _LOGGER.warning("Harbor: MQTT connection timeout for %s: %s (reconnecting)", self.config.serial, e)
222
+ # Clear SSL context on timeout as it might be a stale session
223
+ self._invalidate_ssl_cache()
224
+
225
+ except MqttError as e:
226
+ _LOGGER.warning("Harbor: MQTT error for %s: %s (reconnecting)", self.config.serial, e)
227
+ _LOGGER.info("Harbor: MQTT disconnected from camera %s", self.config.serial)
228
+ # Clear SSL context on MQTT error
229
+ self._invalidate_ssl_cache()
230
+
231
+ except OSError as e:
232
+ _LOGGER.warning("Harbor: MQTT OS error for %s: %s (reconnecting)", self.config.serial, e)
233
+ # Critical to clear context here for WinError 10065 cleanup
234
+ self._invalidate_ssl_cache()
235
+ except asyncio.CancelledError:
236
+ raise
237
+ except Exception as e:
238
+ _LOGGER.error("Harbor: MQTT unexpected error for %s: %s (reconnecting)", self.config.serial, e)
239
+ # Clear context on unexpected errors too
240
+ self._invalidate_ssl_cache()
241
+ import traceback
242
+
243
+ _LOGGER.error(traceback.format_exc())
244
+
245
+ try:
246
+ _LOGGER.info(
247
+ "Harbor: MQTT waiting %s seconds before reconnecting to camera %s",
248
+ reconnect_delay,
249
+ self.config.serial,
250
+ )
251
+ await asyncio.wait_for(self._stop_event.wait(), timeout=reconnect_delay)
252
+ except TimeoutError:
253
+ pass
254
+ reconnect_delay = min(reconnect_delay * 2, 30)
255
+
256
+ except asyncio.CancelledError:
257
+ _LOGGER.info("Harbor: MQTT task cancelled for camera %s", self.config.serial)
258
+ raise
259
+
260
+ async def start(self) -> None:
261
+ if sys.platform == "win32" and isinstance(asyncio.get_running_loop(), asyncio.ProactorEventLoop):
262
+ _LOGGER.warning(
263
+ "Harbor: You are running on Windows with the default ProactorEventLoop. "
264
+ "This is known to cause issues with aiomqtt. "
265
+ "Please use WindowsSelectorEventLoopPolicy instead: "
266
+ "asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())"
267
+ )
268
+
269
+ if self._task and not self._task.done():
270
+ _LOGGER.info("Harbor: MQTT client already running for camera %s", self.config.serial)
271
+ return
272
+ _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
273
+ self._stop_event.clear()
274
+ self._task = asyncio.create_task(self.run())
275
+
276
+ async def stop(self) -> None:
277
+ _LOGGER.info("Harbor: MQTT client stopping for camera %s", self.config.serial)
278
+ self._stop_event.set()
279
+ if self._task:
280
+ self._task.cancel()
281
+ try:
282
+ await self._task
283
+ except asyncio.CancelledError:
284
+ pass
285
+ _LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
286
+ # An intentional stop is a stable disconnect: skip the grace period.
287
+ self._cancel_disconnect_grace()
288
+ self._client = None
289
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client stopped"))
290
+ if self._reported_connected:
291
+ await self._notify_connection_change(False)
292
+
293
+ async def publish(
294
+ self,
295
+ topic: str,
296
+ payload: Any,
297
+ *,
298
+ qos: int = DEFAULT_COMMAND_QOS,
299
+ retain: bool = False,
300
+ ) -> None:
301
+ """Publish a JSON-compatible payload to a Harbor MQTT topic."""
302
+ client = self._client
303
+ if client is None or not self.connected:
304
+ raise ConnectionError(f"Harbor MQTT client is not connected for camera {self.config.serial}")
305
+
306
+ if isinstance(payload, str):
307
+ payload_raw = payload
308
+ else:
309
+ payload_raw = json.dumps(payload, separators=(",", ":"))
310
+
311
+ _LOGGER.debug(
312
+ "Harbor: MQTT publishing to topic '%s' for camera %s: %s",
313
+ topic,
314
+ self.config.serial,
315
+ payload_raw,
316
+ )
317
+ await client.publish(topic, payload_raw, qos=qos, retain=retain)
318
+
319
+ async def publish_command(
320
+ self,
321
+ command: str,
322
+ payload: Any,
323
+ *,
324
+ qos: int = DEFAULT_COMMAND_QOS,
325
+ ) -> None:
326
+ """Publish a camera command using the app's command topic layout."""
327
+ await self.publish(f"cameras/{self.config.serial}/{command}", payload, qos=qos)
328
+
329
+ async def _publish_initial_commands(self) -> None:
330
+ """Publish configured one-shot state population commands after connect."""
331
+ for command in self.initial_commands:
332
+ try:
333
+ if command == GET_SETTINGS_COMMAND:
334
+ await self.publish_command(command, self._build_get_settings_payload())
335
+ else:
336
+ _LOGGER.warning(
337
+ "Harbor: skipping unsupported initial command %s for camera %s",
338
+ command,
339
+ self.config.serial,
340
+ )
341
+ except Exception:
342
+ _LOGGER.exception(
343
+ "Harbor: failed to publish initial command %s for camera %s",
344
+ command,
345
+ self.config.serial,
346
+ )
347
+
348
+ def _build_get_settings_payload(
349
+ self,
350
+ *,
351
+ client: str | None = None,
352
+ triggered_by: str | None = None,
353
+ seq: str | None = None,
354
+ ) -> dict[str, Any]:
355
+ request = GetCameraSettingsRequest(
356
+ seq=seq or _generate_seq(),
357
+ client=client or self.client_id or f"harbor-client-{self.config.serial}",
358
+ triggered_by=triggered_by or "harbor-python",
359
+ )
360
+ return request.model_dump(by_alias=True)
361
+
362
+ async def request_command(
363
+ self,
364
+ command: str,
365
+ payload: dict[str, Any],
366
+ *,
367
+ seq: str | None = None,
368
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
369
+ qos: int = DEFAULT_COMMAND_QOS,
370
+ ) -> Any:
371
+ """Publish a command and wait for a response carrying the same seq."""
372
+ request_seq = seq or _generate_seq()
373
+ payload = {**payload, "seq": request_seq}
374
+ loop = asyncio.get_running_loop()
375
+ future: asyncio.Future[Any] = loop.create_future()
376
+ self._pending_responses[request_seq] = future
377
+
378
+ try:
379
+ await self.publish_command(command, payload, qos=qos)
380
+ return await asyncio.wait_for(future, timeout=timeout)
381
+ except Exception:
382
+ pending = self._pending_responses.pop(request_seq, None)
383
+ if pending is not None and not pending.done():
384
+ pending.cancel()
385
+ raise
386
+
387
+ async def get_settings(
388
+ self,
389
+ *,
390
+ client: str | None = None,
391
+ triggered_by: str | None = None,
392
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
393
+ ) -> SettingsEvent:
394
+ """Request camera settings via the get-settings command."""
395
+ seq = _generate_seq()
396
+ response = await self.request_command(
397
+ GET_SETTINGS_COMMAND,
398
+ self._build_get_settings_payload(
399
+ seq=seq,
400
+ client=client,
401
+ triggered_by=triggered_by,
402
+ ),
403
+ seq=seq,
404
+ timeout=timeout,
405
+ )
406
+ return SettingsEvent.model_validate(response)
407
+
408
+ def __del__(self) -> None:
409
+ if self._stop_event and not self._stop_event.is_set():
410
+ self._stop_event.set()
411
+
412
+
413
+ def _generate_seq() -> str:
414
+ """Generate a request sequence string echoed by Harbor responses."""
415
+ return uuid4().hex
@@ -0,0 +1,82 @@
1
+ import hashlib
2
+ import logging
3
+ import os
4
+ import ssl
5
+ import tempfile
6
+
7
+ from .config import HarborCameraConfig
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ def get_camera_host(camera_config: HarborCameraConfig) -> str:
13
+ if camera_config.ip_address:
14
+ return camera_config.ip_address
15
+ return f"harborc-{camera_config.serial}.local"
16
+
17
+
18
+ def get_ssl_cache_key(camera_config: HarborCameraConfig) -> str:
19
+ """Return the cache key for the SSL context built from this config.
20
+
21
+ Keyed off the certificate material itself so a config carrying new
22
+ credentials never reuses a stale context.
23
+ """
24
+ if camera_config.cert_pem is not None and camera_config.key_pem is not None:
25
+ digest = hashlib.sha256()
26
+ digest.update(camera_config.cert_pem.encode())
27
+ digest.update(b"\x00")
28
+ digest.update(camera_config.key_pem.encode())
29
+ return f"pem:{digest.hexdigest()}"
30
+ return f"path:{camera_config.cert_path}:{camera_config.key_path}"
31
+
32
+
33
+ def _write_private_file(path: str, data: str) -> None:
34
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
35
+ # PEM must stay byte-exact: no platform newline translation or locale encoding.
36
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
37
+ handle.write(data)
38
+
39
+
40
+ def build_ssl_context(camera_config: HarborCameraConfig) -> ssl.SSLContext:
41
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
42
+
43
+ if camera_config.cert_pem is not None and camera_config.key_pem is not None:
44
+ _LOGGER.info("Harbor: Building SSL context from in-memory PEM data for camera %s", camera_config.serial)
45
+ # ssl.SSLContext.load_cert_chain only accepts file paths, so stage the
46
+ # PEM data in a short-lived private temp dir that is removed before
47
+ # this function returns.
48
+ with tempfile.TemporaryDirectory(prefix="harbor-tls-") as tmp_dir:
49
+ cert_file = os.path.join(tmp_dir, "cert.pem")
50
+ key_file = os.path.join(tmp_dir, "key.pem")
51
+ _write_private_file(cert_file, camera_config.cert_pem)
52
+ _write_private_file(key_file, camera_config.key_pem)
53
+ ctx.load_cert_chain(certfile=cert_file, keyfile=key_file)
54
+ else:
55
+ _LOGGER.info(
56
+ "Harbor: Building SSL context with cert_path=%s, key_path=%s",
57
+ camera_config.cert_path,
58
+ camera_config.key_path,
59
+ )
60
+ if camera_config.cert_path is None or camera_config.key_path is None:
61
+ raise ValueError("HarborCameraConfig has no certificate material")
62
+ ctx.load_cert_chain(certfile=camera_config.cert_path, keyfile=camera_config.key_path)
63
+
64
+ ctx.check_hostname = False
65
+ ctx.verify_mode = ssl.CERT_NONE
66
+ return ctx
67
+
68
+
69
+ def get_ssl_context(camera_config: HarborCameraConfig, cache: dict | None = None) -> ssl.SSLContext:
70
+ if cache is None:
71
+ _LOGGER.info("Harbor: Creating new SSL context (no cache)")
72
+ return build_ssl_context(camera_config)
73
+
74
+ key = get_ssl_cache_key(camera_config)
75
+ if key in cache:
76
+ _LOGGER.debug("Harbor: Returning cached SSL context for camera %s", camera_config.serial)
77
+ return cache[key]
78
+
79
+ _LOGGER.info("Harbor: Creating new SSL context for camera %s", camera_config.serial)
80
+ ctx = build_ssl_context(camera_config)
81
+ cache[key] = ctx
82
+ return ctx
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harbor-python"
3
- version = "1.0.0"
3
+ version = "1.2.0"
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"
@@ -1,11 +0,0 @@
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
@@ -1,201 +0,0 @@
1
- import asyncio
2
- import json
3
- import logging
4
- import sys
5
- from collections.abc import Awaitable, Callable
6
- from typing import Any
7
-
8
- from aiomqtt import Client, MqttError
9
-
10
- from .config import HarborCameraConfig
11
- from .utils import get_camera_host, get_ssl_context
12
-
13
- _LOGGER = logging.getLogger(__name__)
14
-
15
-
16
- class HarborMQTTClient:
17
- def __init__(
18
- self,
19
- config: HarborCameraConfig,
20
- topics: list[str],
21
- message_handler: Callable[[str, Any], Awaitable[None]],
22
- client_id: str | None = None,
23
- ssl_context_cache: dict | None = None,
24
- on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
25
- ) -> None:
26
- self.config = config
27
- self.topics = topics
28
- self.message_handler = message_handler
29
- self.client_id = client_id
30
- self.ssl_context_cache = ssl_context_cache or {}
31
- self.on_connection_change = on_connection_change
32
- self.connected: bool = False
33
- self._stop_event = asyncio.Event()
34
- self._task: asyncio.Task | None = None
35
-
36
- async def _handle_message(self, topic: str, payload_raw: str) -> None:
37
- try:
38
- payload = json.loads(payload_raw)
39
- except Exception:
40
- payload = payload_raw
41
-
42
- await self.message_handler(topic, payload)
43
-
44
- async def _set_connected(self, connected: bool) -> None:
45
- """Update the connection flag and notify the listener if it changed."""
46
- if self.connected == connected:
47
- return
48
- self.connected = connected
49
- if self.on_connection_change is None:
50
- return
51
- try:
52
- await self.on_connection_change(connected)
53
- except Exception:
54
- _LOGGER.exception(
55
- "Harbor: connection-change listener raised for camera %s",
56
- self.config.serial,
57
- )
58
-
59
- async def run(self) -> None:
60
- try:
61
- loop = asyncio.get_running_loop()
62
- ssl_ctx = await loop.run_in_executor(None, get_ssl_context, self.config, self.ssl_context_cache)
63
- except Exception as e:
64
- _LOGGER.error("Harbor: Failed to create SSL context for camera %s: %s", self.config.serial, e)
65
- # Ensure we clear any partial state
66
- if self.config.serial in self.ssl_context_cache:
67
- del self.ssl_context_cache[self.config.serial]
68
- return
69
-
70
- reconnect_delay = 2
71
-
72
- _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
73
-
74
- try:
75
- while not self._stop_event.is_set():
76
- try:
77
- host = get_camera_host(self.config)
78
- _LOGGER.info(
79
- "Harbor: MQTT attempting connection to %s:%s for camera %s",
80
- host,
81
- 8884,
82
- self.config.serial,
83
- )
84
-
85
- async with Client(
86
- hostname=host,
87
- port=8884,
88
- tls_context=ssl_ctx,
89
- timeout=10,
90
- identifier=self.client_id,
91
- ) as client:
92
- _LOGGER.info(
93
- "Harbor: MQTT connected to %s:%s for camera %s",
94
- host,
95
- 8884,
96
- self.config.serial,
97
- )
98
- await self._set_connected(True)
99
- try:
100
- if self.topics:
101
- await client.subscribe([(t, 0) for t in self.topics])
102
- _LOGGER.info(
103
- "Harbor: MQTT subscribed to topics: %s for camera %s",
104
- self.topics,
105
- self.config.serial,
106
- )
107
-
108
- async for message in client.messages:
109
- if self._stop_event.is_set():
110
- break
111
-
112
- payload_raw = message.payload.decode("utf-8", errors="replace")
113
- topic = str(message.topic)
114
-
115
- _LOGGER.debug(
116
- "Harbor: MQTT message received on topic '%s' from camera %s: %s",
117
- topic,
118
- self.config.serial,
119
- payload_raw,
120
- )
121
-
122
- await self._handle_message(topic, payload_raw)
123
-
124
- reconnect_delay = 2
125
- finally:
126
- await self._set_connected(False)
127
-
128
- except TimeoutError as e:
129
- _LOGGER.warning("Harbor: MQTT connection timeout for %s: %s (reconnecting)", self.config.serial, e)
130
- # Clear SSL context on timeout as it might be a stale session
131
- if self.config.serial in self.ssl_context_cache:
132
- del self.ssl_context_cache[self.config.serial]
133
-
134
- except MqttError as e:
135
- _LOGGER.warning("Harbor: MQTT error for %s: %s (reconnecting)", self.config.serial, e)
136
- _LOGGER.info("Harbor: MQTT disconnected from camera %s", self.config.serial)
137
- # Clear SSL context on MQTT error
138
- if self.config.serial in self.ssl_context_cache:
139
- del self.ssl_context_cache[self.config.serial]
140
-
141
- except OSError as e:
142
- _LOGGER.warning("Harbor: MQTT OS error for %s: %s (reconnecting)", self.config.serial, e)
143
- # Critical to clear context here for WinError 10065 cleanup
144
- if self.config.serial in self.ssl_context_cache:
145
- del self.ssl_context_cache[self.config.serial]
146
- except asyncio.CancelledError:
147
- raise
148
- except Exception as e:
149
- _LOGGER.error("Harbor: MQTT unexpected error for %s: %s (reconnecting)", self.config.serial, e)
150
- # Clear context on unexpected errors too
151
- if self.config.serial in self.ssl_context_cache:
152
- del self.ssl_context_cache[self.config.serial]
153
- import traceback
154
-
155
- _LOGGER.error(traceback.format_exc())
156
-
157
- try:
158
- _LOGGER.info(
159
- "Harbor: MQTT waiting %s seconds before reconnecting to camera %s",
160
- reconnect_delay,
161
- self.config.serial,
162
- )
163
- await asyncio.wait_for(self._stop_event.wait(), timeout=reconnect_delay)
164
- except TimeoutError:
165
- pass
166
- reconnect_delay = min(reconnect_delay * 2, 30)
167
-
168
- except asyncio.CancelledError:
169
- _LOGGER.info("Harbor: MQTT task cancelled for camera %s", self.config.serial)
170
- raise
171
-
172
- async def start(self) -> None:
173
- if sys.platform == "win32" and isinstance(asyncio.get_running_loop(), asyncio.ProactorEventLoop):
174
- _LOGGER.warning(
175
- "Harbor: You are running on Windows with the default ProactorEventLoop. "
176
- "This is known to cause issues with aiomqtt. "
177
- "Please use WindowsSelectorEventLoopPolicy instead: "
178
- "asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())"
179
- )
180
-
181
- if self._task and not self._task.done():
182
- _LOGGER.info("Harbor: MQTT client already running for camera %s", self.config.serial)
183
- return
184
- _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
185
- self._stop_event.clear()
186
- self._task = asyncio.create_task(self.run())
187
-
188
- async def stop(self) -> None:
189
- _LOGGER.info("Harbor: MQTT client stopping for camera %s", self.config.serial)
190
- self._stop_event.set()
191
- if self._task:
192
- self._task.cancel()
193
- try:
194
- await self._task
195
- except asyncio.CancelledError:
196
- pass
197
- _LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
198
-
199
- def __del__(self) -> None:
200
- if self._stop_event and not self._stop_event.is_set():
201
- self._stop_event.set()
@@ -1,39 +0,0 @@
1
- import logging
2
- import ssl
3
-
4
- from .config import HarborCameraConfig
5
-
6
- _LOGGER = logging.getLogger(__name__)
7
-
8
-
9
- def get_camera_host(camera_config: HarborCameraConfig) -> str:
10
- if camera_config.ip_address:
11
- return camera_config.ip_address
12
- return f"harborc-{camera_config.serial}.local"
13
-
14
-
15
- def build_ssl_context(camera_config: HarborCameraConfig) -> ssl.SSLContext:
16
- _LOGGER.info(
17
- "Harbor: Building SSL context with cert_path=%s, key_path=%s", camera_config.cert_path, camera_config.key_path
18
- )
19
- ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
20
- ctx.load_cert_chain(certfile=camera_config.cert_path, keyfile=camera_config.key_path)
21
- ctx.check_hostname = False
22
- ctx.verify_mode = ssl.CERT_NONE
23
- return ctx
24
-
25
-
26
- def get_ssl_context(camera_config: HarborCameraConfig, cache: dict | None = None) -> ssl.SSLContext:
27
- if cache is None:
28
- _LOGGER.info("Harbor: Creating new SSL context (no cache)")
29
- return build_ssl_context(camera_config)
30
-
31
- key = camera_config.serial
32
- if key in cache:
33
- _LOGGER.debug("Harbor: Returning cached SSL context for camera %s", camera_config.serial)
34
- return cache[key]
35
-
36
- _LOGGER.info("Harbor: Creating new SSL context for camera %s", camera_config.serial)
37
- ctx = build_ssl_context(camera_config)
38
- cache[key] = ctx
39
- return ctx
File without changes
File without changes
File without changes