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