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 +5 -1
- harbor/config.py +26 -3
- harbor/core.py +39 -2
- harbor/data/mqtt_models.py +11 -3
- harbor/devices/camera.py +53 -3
- harbor/events.py +2 -2
- harbor/mqtt.py +234 -20
- harbor/utils.py +48 -5
- {harbor_python-1.0.0.dist-info → harbor_python-1.2.0.dist-info}/METADATA +1 -1
- harbor_python-1.2.0.dist-info/RECORD +17 -0
- {harbor_python-1.0.0.dist-info → harbor_python-1.2.0.dist-info}/WHEEL +1 -1
- harbor_python-1.0.0.dist-info/RECORD +0 -17
- {harbor_python-1.0.0.dist-info → harbor_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
harbor/data/mqtt_models.py
CHANGED
|
@@ -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 [
|
|
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(
|
|
64
|
-
|
|
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
|
|
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
|
|
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 .
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
@@ -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,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,,
|
|
File without changes
|