harbor-python 1.0.0__tar.gz → 1.1.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.1.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
@@ -9,7 +9,7 @@ from .data.mqtt_models import (
9
9
  ViewerLeftEvent,
10
10
  )
11
11
  from .device import HarborDevice
12
- from .devices.camera import HarborCamera
12
+ from .devices.camera import SPEAKER_STATES, STREAM_QUALITIES, HarborCamera
13
13
  from .devices.monitor import HarborMonitor
14
14
  from .events import (
15
15
  CameraEventUpdate,
@@ -59,4 +59,6 @@ __all__ = [
59
59
  "HarborViewer",
60
60
  "HarborEventState",
61
61
  "HarborDeviceState",
62
+ "SPEAKER_STATES",
63
+ "STREAM_QUALITIES",
62
64
  ]
@@ -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")
@@ -20,6 +20,15 @@ _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 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.
29
+ SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"})
30
+ STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"})
31
+
23
32
 
24
33
  class HarborCamera(HarborDevice):
25
34
  """Represents a Harbor camera device."""
@@ -29,6 +38,7 @@ class HarborCamera(HarborDevice):
29
38
  super().__init__(config.serial, "camera")
30
39
  self.config = config
31
40
  self._event_reset_handles: dict[str, asyncio.TimerHandle] = {}
41
+ self._unexpected_enum_values: set[tuple[str, str]] = set()
32
42
 
33
43
  for event_key in DEFAULT_CAMERA_EVENT_KEYS:
34
44
  self._ensure_camera_event(event_key)
@@ -60,8 +70,14 @@ class HarborCamera(HarborDevice):
60
70
  self._set_state_value("bitrate", payload.bitrate)
61
71
  self._set_state_value("wifi_strength", payload.network_bars)
62
72
  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)
73
+ self._set_state_value(
74
+ "speaker_state",
75
+ self._normalize_enum_value("speaker_state", payload.speaker_state, SPEAKER_STATES),
76
+ )
77
+ self._set_state_value(
78
+ "stream_quality",
79
+ self._normalize_enum_value("stream_quality", payload.stream_quality, STREAM_QUALITIES),
80
+ )
65
81
  self._set_state_value("app_start_time", payload.app_start_time)
66
82
  self._set_state_value("stream_start_time", payload.stream_start_time)
67
83
 
@@ -77,6 +93,29 @@ class HarborCamera(HarborDevice):
77
93
  }
78
94
  self.state.values["num_viewers"] = len(self.state.viewers)
79
95
 
96
+ def _normalize_enum_value(
97
+ self,
98
+ field_name: str,
99
+ value: str | None,
100
+ known_values: frozenset[str],
101
+ ) -> str | None:
102
+ """Normalize an enumerated device value to a stable lowercase string."""
103
+ if value is None:
104
+ return None
105
+ normalized = value.strip().lower()
106
+ if not normalized:
107
+ 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
+ return normalized
118
+
80
119
  def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
81
120
  """Apply a viewer joined update."""
82
121
  if viewer is None:
@@ -8,10 +8,12 @@ from typing import Any
8
8
  from aiomqtt import Client, MqttError
9
9
 
10
10
  from .config import HarborCameraConfig
11
- from .utils import get_camera_host, get_ssl_context
11
+ from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context
12
12
 
13
13
  _LOGGER = logging.getLogger(__name__)
14
14
 
15
+ DEFAULT_CONNECTION_GRACE_PERIOD = 90.0
16
+
15
17
 
16
18
  class HarborMQTTClient:
17
19
  def __init__(
@@ -22,16 +24,29 @@ class HarborMQTTClient:
22
24
  client_id: str | None = None,
23
25
  ssl_context_cache: dict | None = None,
24
26
  on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
27
+ connection_grace_period: float = DEFAULT_CONNECTION_GRACE_PERIOD,
25
28
  ) -> None:
29
+ """Initialize the MQTT client.
30
+
31
+ ``on_connection_change`` fires only on stable connection-state
32
+ transitions: a disconnect is reported only if the client stays
33
+ disconnected for ``connection_grace_period`` seconds, so routine
34
+ TCP flapping never reaches the listener. Set the grace period to 0
35
+ to report every raw transition. ``connected`` always reflects the
36
+ raw transport state.
37
+ """
26
38
  self.config = config
27
39
  self.topics = topics
28
40
  self.message_handler = message_handler
29
41
  self.client_id = client_id
30
42
  self.ssl_context_cache = ssl_context_cache or {}
31
43
  self.on_connection_change = on_connection_change
44
+ self.connection_grace_period = connection_grace_period
32
45
  self.connected: bool = False
33
46
  self._stop_event = asyncio.Event()
34
47
  self._task: asyncio.Task | None = None
48
+ self._reported_connected: bool | None = None
49
+ self._disconnect_grace_task: asyncio.Task | None = None
35
50
 
36
51
  async def _handle_message(self, topic: str, payload_raw: str) -> None:
37
52
  try:
@@ -42,10 +57,44 @@ class HarborMQTTClient:
42
57
  await self.message_handler(topic, payload)
43
58
 
44
59
  async def _set_connected(self, connected: bool) -> None:
45
- """Update the connection flag and notify the listener if it changed."""
60
+ """Update the raw connection flag and debounce listener notifications."""
46
61
  if self.connected == connected:
47
62
  return
48
63
  self.connected = connected
64
+
65
+ if connected:
66
+ self._cancel_disconnect_grace()
67
+ if self._reported_connected is not True:
68
+ await self._notify_connection_change(True)
69
+ return
70
+
71
+ if self._reported_connected is not True:
72
+ return
73
+ if self.connection_grace_period <= 0:
74
+ await self._notify_connection_change(False)
75
+ return
76
+ if self._disconnect_grace_task is None or self._disconnect_grace_task.done():
77
+ self._disconnect_grace_task = asyncio.create_task(self._disconnect_after_grace())
78
+
79
+ async def _disconnect_after_grace(self) -> None:
80
+ """Report a disconnect only if it survives the grace period."""
81
+ await asyncio.sleep(self.connection_grace_period)
82
+ self._disconnect_grace_task = None
83
+ if not self.connected:
84
+ _LOGGER.info(
85
+ "Harbor: camera %s still disconnected after %s second grace period",
86
+ self.config.serial,
87
+ self.connection_grace_period,
88
+ )
89
+ await self._notify_connection_change(False)
90
+
91
+ def _cancel_disconnect_grace(self) -> None:
92
+ if self._disconnect_grace_task is not None:
93
+ self._disconnect_grace_task.cancel()
94
+ self._disconnect_grace_task = None
95
+
96
+ async def _notify_connection_change(self, connected: bool) -> None:
97
+ self._reported_connected = connected
49
98
  if self.on_connection_change is None:
50
99
  return
51
100
  try:
@@ -56,23 +105,28 @@ class HarborMQTTClient:
56
105
  self.config.serial,
57
106
  )
58
107
 
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
108
+ def _invalidate_ssl_cache(self) -> None:
109
+ self.ssl_context_cache.pop(get_ssl_cache_key(self.config), None)
69
110
 
111
+ async def run(self) -> None:
70
112
  reconnect_delay = 2
71
113
 
72
114
  _LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)
73
115
 
74
116
  try:
75
117
  while not self._stop_event.is_set():
118
+ # Fetch the SSL context each attempt so invalidations in the
119
+ # error handlers below take effect on the next reconnect;
120
+ # unchanged material is a cheap cache hit.
121
+ try:
122
+ loop = asyncio.get_running_loop()
123
+ ssl_ctx = await loop.run_in_executor(None, get_ssl_context, self.config, self.ssl_context_cache)
124
+ except Exception as e:
125
+ _LOGGER.error("Harbor: Failed to create SSL context for camera %s: %s", self.config.serial, e)
126
+ # Ensure we clear any partial state
127
+ self._invalidate_ssl_cache()
128
+ return
129
+
76
130
  try:
77
131
  host = get_camera_host(self.config)
78
132
  _LOGGER.info(
@@ -128,28 +182,24 @@ class HarborMQTTClient:
128
182
  except TimeoutError as e:
129
183
  _LOGGER.warning("Harbor: MQTT connection timeout for %s: %s (reconnecting)", self.config.serial, e)
130
184
  # 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]
185
+ self._invalidate_ssl_cache()
133
186
 
134
187
  except MqttError as e:
135
188
  _LOGGER.warning("Harbor: MQTT error for %s: %s (reconnecting)", self.config.serial, e)
136
189
  _LOGGER.info("Harbor: MQTT disconnected from camera %s", self.config.serial)
137
190
  # 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]
191
+ self._invalidate_ssl_cache()
140
192
 
141
193
  except OSError as e:
142
194
  _LOGGER.warning("Harbor: MQTT OS error for %s: %s (reconnecting)", self.config.serial, e)
143
195
  # 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]
196
+ self._invalidate_ssl_cache()
146
197
  except asyncio.CancelledError:
147
198
  raise
148
199
  except Exception as e:
149
200
  _LOGGER.error("Harbor: MQTT unexpected error for %s: %s (reconnecting)", self.config.serial, e)
150
201
  # 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]
202
+ self._invalidate_ssl_cache()
153
203
  import traceback
154
204
 
155
205
  _LOGGER.error(traceback.format_exc())
@@ -195,6 +245,10 @@ class HarborMQTTClient:
195
245
  except asyncio.CancelledError:
196
246
  pass
197
247
  _LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
248
+ # An intentional stop is a stable disconnect: skip the grace period.
249
+ self._cancel_disconnect_grace()
250
+ if self._reported_connected:
251
+ await self._notify_connection_change(False)
198
252
 
199
253
  def __del__(self) -> None:
200
254
  if self._stop_event and not self._stop_event.is_set():
@@ -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.1.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,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