Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ repos:
- id: check-toml
- id: check-yaml
- id: detect-private-key
# Throwaway self-signed key generated for the test suite only
exclude: ^tests/data/certs\.py$
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/uv-pre-commit
Expand Down
4 changes: 3 additions & 1 deletion harbor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
ViewerLeftEvent,
)
from .device import HarborDevice
from .devices.camera import HarborCamera
from .devices.camera import SPEAKER_STATES, STREAM_QUALITIES, HarborCamera
from .devices.monitor import HarborMonitor
from .events import (
CameraEventUpdate,
Expand Down Expand Up @@ -59,4 +59,6 @@
"HarborViewer",
"HarborEventState",
"HarborDeviceState",
"SPEAKER_STATES",
"STREAM_QUALITIES",
]
29 changes: 26 additions & 3 deletions harbor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,32 @@

@dataclass(frozen=True)
class HarborCameraConfig:
"""Connection configuration for a Harbor camera.

Client certificate material can be provided either as in-memory PEM
strings (``cert_pem``/``key_pem``) or as file paths
(``cert_path``/``key_path``). PEM strings are preferred for consumers
that should not manage certificate files themselves: while building the
SSL context the library briefly stages the PEM data in a private
temporary directory (0600 files) that is deleted before the build
returns, so the caller never handles files. When both are provided, the
PEM strings win.
"""

serial: str
cert_path: str
key_path: str
cert_dir: str
cert_path: str | None = None
key_path: str | None = None
cert_dir: str | None = None

ip_address: str | None = None

cert_pem: str | None = None
key_pem: str | None = None

def __post_init__(self) -> None:
if (self.cert_pem is None) != (self.key_pem is None):
raise ValueError("cert_pem and key_pem must be provided together")
if (self.cert_path is None) != (self.key_path is None):
raise ValueError("cert_path and key_path must be provided together")
if self.cert_pem is None and self.cert_path is None:
raise ValueError("Certificate material is required: provide cert_pem/key_pem or cert_path/key_path")
43 changes: 41 additions & 2 deletions harbor/devices/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
DEFAULT_CAMERA_EVENT_KEYS = ("motion_detection", "cry_detection", "noise_detection")
DEFAULT_EVENT_ACTIVE_SECONDS = 5.0

# Known values for enumerated state fields. The device reports these in
# mixed/upper case (e.g. "PLAYING", "GOOD"); they are normalized to the
# lowercase values below before being stored in ``HarborDeviceState.values``.
# A value outside these sets is still stored lowercased (and logged once),
# so consumers that declare options up front should treat these sets as the
# baseline, not a hard guarantee.
SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"})
STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"})


class HarborCamera(HarborDevice):
"""Represents a Harbor camera device."""
Expand All @@ -29,6 +38,7 @@ def __init__(self, config: HarborCameraConfig) -> None:
super().__init__(config.serial, "camera")
self.config = config
self._event_reset_handles: dict[str, asyncio.TimerHandle] = {}
self._unexpected_enum_values: set[tuple[str, str]] = set()

for event_key in DEFAULT_CAMERA_EVENT_KEYS:
self._ensure_camera_event(event_key)
Expand Down Expand Up @@ -60,8 +70,14 @@ def _apply_local_livekit_heartbeat(
self._set_state_value("bitrate", payload.bitrate)
self._set_state_value("wifi_strength", payload.network_bars)
self._set_state_value("camera_present", payload.camera_present)
self._set_state_value("speaker_state", payload.speaker_state)
self._set_state_value("stream_quality", payload.stream_quality)
self._set_state_value(
"speaker_state",
self._normalize_enum_value("speaker_state", payload.speaker_state, SPEAKER_STATES),
)
self._set_state_value(
"stream_quality",
self._normalize_enum_value("stream_quality", payload.stream_quality, STREAM_QUALITIES),
)
self._set_state_value("app_start_time", payload.app_start_time)
self._set_state_value("stream_start_time", payload.stream_start_time)

Expand All @@ -77,6 +93,29 @@ def _apply_local_livekit_heartbeat(
}
self.state.values["num_viewers"] = len(self.state.viewers)

def _normalize_enum_value(
self,
field_name: str,
value: str | None,
known_values: frozenset[str],
) -> str | None:
"""Normalize an enumerated device value to a stable lowercase string."""
if value is None:
return None
normalized = value.strip().lower()
if not normalized:
return None
if normalized not in known_values and (field_name, normalized) not in self._unexpected_enum_values:
self._unexpected_enum_values.add((field_name, normalized))
_LOGGER.warning(
"Camera %s reported unexpected %s value %r (known values: %s)",
self.serial,
field_name,
normalized,
sorted(known_values),
)
return normalized

def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
"""Apply a viewer joined update."""
if viewer is None:
Expand Down
94 changes: 74 additions & 20 deletions harbor/mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from aiomqtt import Client, MqttError

from .config import HarborCameraConfig
from .utils import get_camera_host, get_ssl_context
from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context

_LOGGER = logging.getLogger(__name__)

DEFAULT_CONNECTION_GRACE_PERIOD = 90.0


class HarborMQTTClient:
def __init__(
Expand All @@ -22,16 +24,29 @@ def __init__(
client_id: str | None = None,
ssl_context_cache: dict | None = None,
on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
connection_grace_period: float = DEFAULT_CONNECTION_GRACE_PERIOD,
) -> None:
"""Initialize the MQTT client.

``on_connection_change`` fires only on stable connection-state
transitions: a disconnect is reported only if the client stays
disconnected for ``connection_grace_period`` seconds, so routine
TCP flapping never reaches the listener. Set the grace period to 0
to report every raw transition. ``connected`` always reflects the
raw transport state.
"""
self.config = config
self.topics = topics
self.message_handler = message_handler
self.client_id = client_id
self.ssl_context_cache = ssl_context_cache or {}
self.on_connection_change = on_connection_change
self.connection_grace_period = connection_grace_period
self.connected: bool = False
self._stop_event = asyncio.Event()
self._task: asyncio.Task | None = None
self._reported_connected: bool | None = None
self._disconnect_grace_task: asyncio.Task | None = None

async def _handle_message(self, topic: str, payload_raw: str) -> None:
try:
Expand All @@ -42,10 +57,44 @@ async def _handle_message(self, topic: str, payload_raw: str) -> None:
await self.message_handler(topic, payload)

async def _set_connected(self, connected: bool) -> None:
"""Update the connection flag and notify the listener if it changed."""
"""Update the raw connection flag and debounce listener notifications."""
if self.connected == connected:
return
self.connected = connected

if connected:
self._cancel_disconnect_grace()
if self._reported_connected is not True:
await self._notify_connection_change(True)
return

if self._reported_connected is not True:
return
if self.connection_grace_period <= 0:
await self._notify_connection_change(False)
return
if self._disconnect_grace_task is None or self._disconnect_grace_task.done():
self._disconnect_grace_task = asyncio.create_task(self._disconnect_after_grace())

async def _disconnect_after_grace(self) -> None:
"""Report a disconnect only if it survives the grace period."""
await asyncio.sleep(self.connection_grace_period)
self._disconnect_grace_task = None
if not self.connected:
_LOGGER.info(
"Harbor: camera %s still disconnected after %s second grace period",
self.config.serial,
self.connection_grace_period,
)
await self._notify_connection_change(False)

def _cancel_disconnect_grace(self) -> None:
if self._disconnect_grace_task is not None:
self._disconnect_grace_task.cancel()
self._disconnect_grace_task = None

async def _notify_connection_change(self, connected: bool) -> None:
self._reported_connected = connected
if self.on_connection_change is None:
return
try:
Expand All @@ -56,23 +105,28 @@ async def _set_connected(self, connected: bool) -> None:
self.config.serial,
)

async def run(self) -> None:
try:
loop = asyncio.get_running_loop()
ssl_ctx = await loop.run_in_executor(None, get_ssl_context, self.config, self.ssl_context_cache)
except Exception as e:
_LOGGER.error("Harbor: Failed to create SSL context for camera %s: %s", self.config.serial, e)
# Ensure we clear any partial state
if self.config.serial in self.ssl_context_cache:
del self.ssl_context_cache[self.config.serial]
return
def _invalidate_ssl_cache(self) -> None:
self.ssl_context_cache.pop(get_ssl_cache_key(self.config), None)

async def run(self) -> None:
reconnect_delay = 2

_LOGGER.info("Harbor: MQTT client starting for camera %s", self.config.serial)

try:
while not self._stop_event.is_set():
# Fetch the SSL context each attempt so invalidations in the
# error handlers below take effect on the next reconnect;
# unchanged material is a cheap cache hit.
try:
loop = asyncio.get_running_loop()
ssl_ctx = await loop.run_in_executor(None, get_ssl_context, self.config, self.ssl_context_cache)
except Exception as e:
_LOGGER.error("Harbor: Failed to create SSL context for camera %s: %s", self.config.serial, e)
# Ensure we clear any partial state
self._invalidate_ssl_cache()
return

try:
host = get_camera_host(self.config)
_LOGGER.info(
Expand Down Expand Up @@ -128,28 +182,24 @@ async def run(self) -> None:
except TimeoutError as e:
_LOGGER.warning("Harbor: MQTT connection timeout for %s: %s (reconnecting)", self.config.serial, e)
# Clear SSL context on timeout as it might be a stale session
if self.config.serial in self.ssl_context_cache:
del self.ssl_context_cache[self.config.serial]
self._invalidate_ssl_cache()

except MqttError as e:
_LOGGER.warning("Harbor: MQTT error for %s: %s (reconnecting)", self.config.serial, e)
_LOGGER.info("Harbor: MQTT disconnected from camera %s", self.config.serial)
# Clear SSL context on MQTT error
if self.config.serial in self.ssl_context_cache:
del self.ssl_context_cache[self.config.serial]
self._invalidate_ssl_cache()

except OSError as e:
_LOGGER.warning("Harbor: MQTT OS error for %s: %s (reconnecting)", self.config.serial, e)
# Critical to clear context here for WinError 10065 cleanup
if self.config.serial in self.ssl_context_cache:
del self.ssl_context_cache[self.config.serial]
self._invalidate_ssl_cache()
except asyncio.CancelledError:
raise
except Exception as e:
_LOGGER.error("Harbor: MQTT unexpected error for %s: %s (reconnecting)", self.config.serial, e)
# Clear context on unexpected errors too
if self.config.serial in self.ssl_context_cache:
del self.ssl_context_cache[self.config.serial]
self._invalidate_ssl_cache()
import traceback

_LOGGER.error(traceback.format_exc())
Expand Down Expand Up @@ -195,6 +245,10 @@ async def stop(self) -> None:
except asyncio.CancelledError:
pass
_LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
# An intentional stop is a stable disconnect: skip the grace period.
self._cancel_disconnect_grace()
if self._reported_connected:
await self._notify_connection_change(False)

def __del__(self) -> None:
if self._stop_event and not self._stop_event.is_set():
Expand Down
53 changes: 48 additions & 5 deletions harbor/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import hashlib
import logging
import os
import ssl
import tempfile

from .config import HarborCameraConfig

Expand All @@ -12,12 +15,52 @@ def get_camera_host(camera_config: HarborCameraConfig) -> str:
return f"harborc-{camera_config.serial}.local"


def get_ssl_cache_key(camera_config: HarborCameraConfig) -> str:
"""Return the cache key for the SSL context built from this config.

Keyed off the certificate material itself so a config carrying new
credentials never reuses a stale context.
"""
if camera_config.cert_pem is not None and camera_config.key_pem is not None:
digest = hashlib.sha256()
digest.update(camera_config.cert_pem.encode())
digest.update(b"\x00")
digest.update(camera_config.key_pem.encode())
return f"pem:{digest.hexdigest()}"
return f"path:{camera_config.cert_path}:{camera_config.key_path}"


def _write_private_file(path: str, data: str) -> None:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
# PEM must stay byte-exact: no platform newline translation or locale encoding.
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(data)


def build_ssl_context(camera_config: HarborCameraConfig) -> ssl.SSLContext:
_LOGGER.info(
"Harbor: Building SSL context with cert_path=%s, key_path=%s", camera_config.cert_path, camera_config.key_path
)
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
ctx.load_cert_chain(certfile=camera_config.cert_path, keyfile=camera_config.key_path)

if camera_config.cert_pem is not None and camera_config.key_pem is not None:
_LOGGER.info("Harbor: Building SSL context from in-memory PEM data for camera %s", camera_config.serial)
# ssl.SSLContext.load_cert_chain only accepts file paths, so stage the
# PEM data in a short-lived private temp dir that is removed before
# this function returns.
with tempfile.TemporaryDirectory(prefix="harbor-tls-") as tmp_dir:
cert_file = os.path.join(tmp_dir, "cert.pem")
key_file = os.path.join(tmp_dir, "key.pem")
_write_private_file(cert_file, camera_config.cert_pem)
_write_private_file(key_file, camera_config.key_pem)
ctx.load_cert_chain(certfile=cert_file, keyfile=key_file)
else:
_LOGGER.info(
"Harbor: Building SSL context with cert_path=%s, key_path=%s",
camera_config.cert_path,
camera_config.key_path,
)
if camera_config.cert_path is None or camera_config.key_path is None:
raise ValueError("HarborCameraConfig has no certificate material")
ctx.load_cert_chain(certfile=camera_config.cert_path, keyfile=camera_config.key_path)

ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
Expand All @@ -28,7 +71,7 @@ def get_ssl_context(camera_config: HarborCameraConfig, cache: dict | None = None
_LOGGER.info("Harbor: Creating new SSL context (no cache)")
return build_ssl_context(camera_config)

key = camera_config.serial
key = get_ssl_cache_key(camera_config)
if key in cache:
_LOGGER.debug("Harbor: Returning cached SSL context for camera %s", camera_config.serial)
return cache[key]
Expand Down
Loading
Loading