harbor-python 1.0.0__py3-none-any.whl → 1.2.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 CHANGED
@@ -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
  ]
harbor/config.py CHANGED
@@ -3,9 +3,32 @@ from dataclasses import dataclass
3
3
 
4
4
  @dataclass(frozen=True)
5
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
+
6
18
  serial: str
7
- cert_path: str
8
- key_path: str
9
- cert_dir: str
19
+ cert_path: str | None = None
20
+ key_path: str | None = None
21
+ cert_dir: str | None = None
10
22
 
11
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")
harbor/core.py CHANGED
@@ -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
 
harbor/devices/camera.py CHANGED
@@ -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:
harbor/events.py CHANGED
@@ -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)
harbor/mqtt.py CHANGED
@@ -4,14 +4,22 @@ import logging
4
4
  import sys
5
5
  from collections.abc import Awaitable, Callable
6
6
  from typing import Any
7
+ from uuid import uuid4
7
8
 
8
9
  from aiomqtt import Client, MqttError
9
10
 
10
11
  from .config import HarborCameraConfig
11
- from .utils import get_camera_host, get_ssl_context
12
+ from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent
13
+ from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context
12
14
 
13
15
  _LOGGER = logging.getLogger(__name__)
14
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
+
15
23
 
16
24
  class HarborMQTTClient:
17
25
  def __init__(
@@ -22,16 +30,33 @@ class HarborMQTTClient:
22
30
  client_id: str | None = None,
23
31
  ssl_context_cache: dict | None = None,
24
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,
25
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
+ """
26
45
  self.config = config
27
46
  self.topics = topics
28
47
  self.message_handler = message_handler
29
48
  self.client_id = client_id
30
49
  self.ssl_context_cache = ssl_context_cache or {}
31
50
  self.on_connection_change = on_connection_change
51
+ self.connection_grace_period = connection_grace_period
52
+ self.initial_commands = tuple(initial_commands or ())
32
53
  self.connected: bool = False
33
54
  self._stop_event = asyncio.Event()
34
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
35
60
 
36
61
  async def _handle_message(self, topic: str, payload_raw: str) -> None:
37
62
  try:
@@ -40,12 +65,63 @@ class HarborMQTTClient:
40
65
  payload = payload_raw
41
66
 
42
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)
43
85
 
44
86
  async def _set_connected(self, connected: bool) -> None:
45
- """Update the connection flag and notify the listener if it changed."""
87
+ """Update the raw connection flag and debounce listener notifications."""
46
88
  if self.connected == connected:
47
89
  return
48
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
49
125
  if self.on_connection_change is None:
50
126
  return
51
127
  try:
@@ -56,23 +132,34 @@ class HarborMQTTClient:
56
132
  self.config.serial,
57
133
  )
58
134
 
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
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()
69
143
 
144
+ async def run(self) -> None:
70
145
  reconnect_delay = 2
71
146
 
72
147
  _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
73
148
 
74
149
  try:
75
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
+
76
163
  try:
77
164
  host = get_camera_host(self.config)
78
165
  _LOGGER.info(
@@ -89,6 +176,7 @@ class HarborMQTTClient:
89
176
  timeout=10,
90
177
  identifier=self.client_id,
91
178
  ) as client:
179
+ self._client = client
92
180
  _LOGGER.info(
93
181
  "Harbor: MQTT connected to %s:%s for camera %s",
94
182
  host,
@@ -105,6 +193,8 @@ class HarborMQTTClient:
105
193
  self.config.serial,
106
194
  )
107
195
 
196
+ await self._publish_initial_commands()
197
+
108
198
  async for message in client.messages:
109
199
  if self._stop_event.is_set():
110
200
  break
@@ -123,33 +213,31 @@ class HarborMQTTClient:
123
213
 
124
214
  reconnect_delay = 2
125
215
  finally:
216
+ self._client = None
217
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client disconnected"))
126
218
  await self._set_connected(False)
127
219
 
128
220
  except TimeoutError as e:
129
221
  _LOGGER.warning("Harbor: MQTT connection timeout for %s: %s (reconnecting)", self.config.serial, e)
130
222
  # 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]
223
+ self._invalidate_ssl_cache()
133
224
 
134
225
  except MqttError as e:
135
226
  _LOGGER.warning("Harbor: MQTT error for %s: %s (reconnecting)", self.config.serial, e)
136
227
  _LOGGER.info("Harbor: MQTT disconnected from camera %s", self.config.serial)
137
228
  # 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]
229
+ self._invalidate_ssl_cache()
140
230
 
141
231
  except OSError as e:
142
232
  _LOGGER.warning("Harbor: MQTT OS error for %s: %s (reconnecting)", self.config.serial, e)
143
233
  # 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]
234
+ self._invalidate_ssl_cache()
146
235
  except asyncio.CancelledError:
147
236
  raise
148
237
  except Exception as e:
149
238
  _LOGGER.error("Harbor: MQTT unexpected error for %s: %s (reconnecting)", self.config.serial, e)
150
239
  # 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]
240
+ self._invalidate_ssl_cache()
153
241
  import traceback
154
242
 
155
243
  _LOGGER.error(traceback.format_exc())
@@ -195,7 +283,133 @@ class HarborMQTTClient:
195
283
  except asyncio.CancelledError:
196
284
  pass
197
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)
198
407
 
199
408
  def __del__(self) -> None:
200
409
  if self._stop_event and not self._stop_event.is_set():
201
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
harbor/utils.py CHANGED
@@ -1,5 +1,8 @@
1
+ import hashlib
1
2
  import logging
3
+ import os
2
4
  import ssl
5
+ import tempfile
3
6
 
4
7
  from .config import HarborCameraConfig
5
8
 
@@ -12,12 +15,52 @@ def get_camera_host(camera_config: HarborCameraConfig) -> str:
12
15
  return f"harborc-{camera_config.serial}.local"
13
16
 
14
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
+
15
40
  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
41
  ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
20
- ctx.load_cert_chain(certfile=camera_config.cert_path, keyfile=camera_config.key_path)
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
+
21
64
  ctx.check_hostname = False
22
65
  ctx.verify_mode = ssl.CERT_NONE
23
66
  return ctx
@@ -28,7 +71,7 @@ def get_ssl_context(camera_config: HarborCameraConfig, cache: dict | None = None
28
71
  _LOGGER.info("Harbor: Creating new SSL context (no cache)")
29
72
  return build_ssl_context(camera_config)
30
73
 
31
- key = camera_config.serial
74
+ key = get_ssl_cache_key(camera_config)
32
75
  if key in cache:
33
76
  _LOGGER.debug("Harbor: Returning cached SSL context for camera %s", camera_config.serial)
34
77
  return cache[key]
@@ -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
@@ -0,0 +1,17 @@
1
+ harbor/__init__.py,sha256=F2Yo9T1Hpf0lSioVAk-m8GJnEh1tLd2En4Mzz9RxczY,1606
2
+ harbor/config.py,sha256=LXMDaTC_0Z9wF_vTXJG9C0vQ0XHXo4EuFL0-Kwa4-S4,1373
3
+ harbor/core.py,sha256=Alm6ojRBCibsQ5xGGyaAOQimi-_kE6Q6fuS2ZAxzaNM,3468
4
+ harbor/device.py,sha256=YvaFWieXJ-EzQJU8VQo_ZERl65CynITTAnMFto-PF_I,5933
5
+ harbor/events.py,sha256=4Y3JT_NP1x8rUR7U6n2-LvAENt9zpqD4jVy28AAYo4Q,20400
6
+ harbor/mqtt.py,sha256=ZhGuR90U449q4OY9WCCJVLIuYVEHrG1uT32EGjux7rU,16712
7
+ harbor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ harbor/state.py,sha256=jQDLV8zpkLV2f6zR4LuOtk_B5BtGdp-8dq4NyGZCa-8,1210
9
+ harbor/utils.py,sha256=7A3Tn1aZ93SlxRKY-0b-50ntQL4Pw1j5bXwkJvnarpY,3312
10
+ harbor/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ harbor/data/mqtt_models.py,sha256=OF36ZoUCOeoaKp4TPggM6HCbowgtkH1zLFTozVJUZYY,4314
12
+ harbor/devices/camera.py,sha256=ciq8ynsAaZgW0CWYjKiJGAZwk04pNz4uYfnsN0GT0O4,8429
13
+ harbor/devices/monitor.py,sha256=hzx4lu92h3i_34QyYK392gE7CihqERiOZuLhyzDtDk0,452
14
+ harbor_python-1.2.0.dist-info/METADATA,sha256=ZEPjl-fqvicWCUsIa5dbS18zAFgm5YeYXbGUqvMFe9A,688
15
+ harbor_python-1.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ harbor_python-1.2.0.dist-info/licenses/LICENSE,sha256=QEOXEfrOQetemKy4k1WN5XCApJdpiRtyV5jLMSToVVw,11344
17
+ harbor_python-1.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,17 +0,0 @@
1
- harbor/__init__.py,sha256=H-yZkUph-wcFtKPjl6KBrsvRr0Yz5d99t5hjoeM6We8,1464
2
- harbor/config.py,sha256=G259PU-g2Vvuw0NZ0p6A7HVAbqC7gHgBecNkCRqC0_Y,192
3
- harbor/core.py,sha256=lfO1FAwYKSSW7YZ8fqDdhgpWXS6eA-vL1NgZKmN_H_g,2186
4
- harbor/device.py,sha256=YvaFWieXJ-EzQJU8VQo_ZERl65CynITTAnMFto-PF_I,5933
5
- harbor/events.py,sha256=q_WNG_yIkCczOzloe9mDwulgHhXmeQdZFDyyKpCz1Ig,20363
6
- harbor/mqtt.py,sha256=U0hfRscFzaqeLso0PqDh0EJoXHt-b0Qqrk4MhbRiBBc,8496
7
- harbor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- harbor/state.py,sha256=jQDLV8zpkLV2f6zR4LuOtk_B5BtGdp-8dq4NyGZCa-8,1210
9
- harbor/utils.py,sha256=c-QT2ZDacEIIJInnoQ9twJ82D83TBlmtAygJI6ovuB4,1336
10
- harbor/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- harbor/data/mqtt_models.py,sha256=yir1maFUxmk_9E2J_JEnTzDJwiAGDdSrbkGN1YaZd_o,4031
12
- harbor/devices/camera.py,sha256=zBYLBq_Q8lUkmsBVSSGVWgAIskkpQCmiurxuXE7jjPM,6301
13
- harbor/devices/monitor.py,sha256=hzx4lu92h3i_34QyYK392gE7CihqERiOZuLhyzDtDk0,452
14
- harbor_python-1.0.0.dist-info/METADATA,sha256=vjy56XY1ZNfKlWMRXF_Ef36of_itlM7xsZukKYQRg2I,688
15
- harbor_python-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
- harbor_python-1.0.0.dist-info/licenses/LICENSE,sha256=QEOXEfrOQetemKy4k1WN5XCApJdpiRtyV5jLMSToVVw,11344
17
- harbor_python-1.0.0.dist-info/RECORD,,