python-aidot-cameras 0.10.0__tar.gz → 0.10.1__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.
- {python_aidot_cameras-0.10.0/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.10.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/client.py +28 -8
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/lan_control.py +11 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/models.py +46 -31
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/protocol.py +10 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/webrtc_open.py +85 -29
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/client.py +15 -3
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/credentials.py +14 -3
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/device_client.py +7 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.10.1/tests/test_dtls_pinning.py +63 -0
- python_aidot_cameras-0.10.1/tests/test_security_hardening.py +67 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/LICENSE +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/README.md +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/sdes_open.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_wait_or_event.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.10.
|
|
7
|
+
version = "0.10.1"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -213,6 +213,7 @@ def _save_frame_as_jpeg(image_data: Any, output_path: str) -> bool:
|
|
|
213
213
|
],
|
|
214
214
|
input=image_data.tobytes(),
|
|
215
215
|
capture_output=True,
|
|
216
|
+
timeout=15, # bound the hang; a wedged ffmpeg must not block forever
|
|
216
217
|
)
|
|
217
218
|
return r.returncode == 0
|
|
218
219
|
except Exception as exc:
|
|
@@ -1929,7 +1930,6 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1929
1930
|
# ── SDES path: stream briefly to a temp TS file, extract one JPEG ──── #
|
|
1930
1931
|
if self.is_sdes_camera:
|
|
1931
1932
|
import os as _os
|
|
1932
|
-
import subprocess as _sp
|
|
1933
1933
|
import tempfile as _tf
|
|
1934
1934
|
|
|
1935
1935
|
with _tf.NamedTemporaryFile(suffix=".ts", delete=False) as _tmpf:
|
|
@@ -1970,17 +1970,33 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1970
1970
|
if _ffmpeg_path() is None:
|
|
1971
1971
|
_LOGGER.error("async_snapshot SDES: %s", _FFMPEG_MISSING_MSG)
|
|
1972
1972
|
return False
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1973
|
+
# Async subprocess (not subprocess.run): a synchronous ffmpeg
|
|
1974
|
+
# here blocks the whole event loop - every camera, keepalive and
|
|
1975
|
+
# MQTT drain - for up to the timeout.
|
|
1976
|
+
_snap_proc = await _asyncio.create_subprocess_exec(
|
|
1977
|
+
"ffmpeg", "-y", "-i", _tmp_ts,
|
|
1978
|
+
"-frames:v", "1", "-f", "image2", output_path,
|
|
1979
|
+
stdout=_asyncio.subprocess.DEVNULL,
|
|
1980
|
+
stderr=_asyncio.subprocess.PIPE,
|
|
1977
1981
|
)
|
|
1978
|
-
|
|
1982
|
+
try:
|
|
1983
|
+
_, _snap_err = await _asyncio.wait_for(
|
|
1984
|
+
_snap_proc.communicate(), timeout=15
|
|
1985
|
+
)
|
|
1986
|
+
except TimeoutError:
|
|
1987
|
+
_snap_proc.kill()
|
|
1988
|
+
await _snap_proc.communicate()
|
|
1989
|
+
_LOGGER.warning(
|
|
1990
|
+
"async_snapshot SDES: ffmpeg frame extract timed out for %s",
|
|
1991
|
+
self.device_id,
|
|
1992
|
+
)
|
|
1993
|
+
return False
|
|
1994
|
+
if _snap_proc.returncode == 0 and _os.path.exists(output_path):
|
|
1979
1995
|
return True
|
|
1980
1996
|
_LOGGER.warning(
|
|
1981
1997
|
"async_snapshot SDES: ffmpeg frame extract failed for %s: %s",
|
|
1982
1998
|
self.device_id,
|
|
1983
|
-
|
|
1999
|
+
(_snap_err or b"").decode(errors="replace")[-200:],
|
|
1984
2000
|
)
|
|
1985
2001
|
return False
|
|
1986
2002
|
except Exception as _snap_exc:
|
|
@@ -2032,7 +2048,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2032
2048
|
|
|
2033
2049
|
if captured[0] is None:
|
|
2034
2050
|
return False
|
|
2035
|
-
|
|
2051
|
+
# _save_frame_as_jpeg does a blocking PIL encode (or a blocking ffmpeg
|
|
2052
|
+
# fallback); run it off the event loop so it can't stall other cameras.
|
|
2053
|
+
return await _asyncio.get_running_loop().run_in_executor(
|
|
2054
|
+
None, _save_frame_as_jpeg, captured[0], output_path
|
|
2055
|
+
)
|
|
2036
2056
|
|
|
2037
2057
|
async def async_start_streaming(self) -> None:
|
|
2038
2058
|
"""Start a persistent background WebRTC stream that updates latest_jpeg.
|
|
@@ -59,6 +59,11 @@ ATTR_KEYS = {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
# Control-channel bodies are small JSON; reject anything wildly larger than that
|
|
63
|
+
# so a hostile/broken LAN peer can't drive memory exhaustion via the size field.
|
|
64
|
+
_MAX_FRAME_BODY = 1024 * 1024 # 1 MiB
|
|
65
|
+
|
|
66
|
+
|
|
62
67
|
def _pack(msgtype: int, body: bytes) -> bytes:
|
|
63
68
|
return struct.pack(">Hhi", _MAGIC, msgtype, len(body)) + body
|
|
64
69
|
|
|
@@ -69,6 +74,12 @@ async def _read_frame(reader: asyncio.StreamReader, timeout: float) -> bytes:
|
|
|
69
74
|
The caller decrypts and parses the JSON (the AES key lives on the client)."""
|
|
70
75
|
header = await asyncio.wait_for(reader.readexactly(8), timeout)
|
|
71
76
|
_magic, _mtype, bodysize = struct.unpack(">HHI", header)
|
|
77
|
+
# Cap the server-supplied body size: `bodysize` is an unsigned 32-bit field
|
|
78
|
+
# (up to ~4 GiB) from a LAN host, and control-channel bodies are small JSON.
|
|
79
|
+
# Reject an implausibly large frame instead of allocating on a malicious or
|
|
80
|
+
# malfunctioning peer (mirrors the 4 MiB cap in playback._read_frame).
|
|
81
|
+
if bodysize > _MAX_FRAME_BODY:
|
|
82
|
+
raise ValueError(f"LAN control frame body too large: {bodysize} bytes")
|
|
72
83
|
body = await asyncio.wait_for(reader.readexactly(bodysize), timeout)
|
|
73
84
|
return body # caller decrypts (key is on the client)
|
|
74
85
|
|
|
@@ -30,6 +30,24 @@ from .constants import (
|
|
|
30
30
|
_LOGGER = logging.getLogger(__name__)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _as_int(v: Any) -> Optional[int]:
|
|
34
|
+
"""Coerce a cloud-supplied attr value to int, or None if not numeric.
|
|
35
|
+
|
|
36
|
+
Cloud/device attributes are untrusted: a non-numeric value must be skipped,
|
|
37
|
+
not raise ValueError/TypeError out of the status-refresh path.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return int(v)
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _as_bool(v: Any) -> Optional[bool]:
|
|
46
|
+
"""Coerce a 0/1 (int or numeric string) attr to bool, or None if not numeric."""
|
|
47
|
+
i = _as_int(v)
|
|
48
|
+
return None if i is None else bool(i)
|
|
49
|
+
|
|
50
|
+
|
|
33
51
|
class CameraStatusData(DeviceStatusData):
|
|
34
52
|
"""Core status plus camera fields; accepts both typed-model and dict updates."""
|
|
35
53
|
|
|
@@ -74,44 +92,41 @@ class CameraStatusData(DeviceStatusData):
|
|
|
74
92
|
# feeders either strip them (update_from_camera_attributes) or have
|
|
75
93
|
# already applied them via the model (receive_data raw-dict pass).
|
|
76
94
|
# Camera attributes
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
# Every conversion goes through _as_int/_as_bool so a malformed value
|
|
96
|
+
# from the cloud (e.g. "on" where 0/1 is expected) is skipped rather than
|
|
97
|
+
# raising out of update() and aborting the whole status refresh.
|
|
98
|
+
if (b := _as_bool(attr.get("MotionDetection_Enable"))) is not None:
|
|
99
|
+
self.motion_detection = b
|
|
100
|
+
if (i := _as_int(attr.get("MotionDetection_Sen"))) is not None:
|
|
101
|
+
self.motion_sensitivity = i
|
|
102
|
+
if (b := _as_bool(attr.get("LedOnOff"))) is not None:
|
|
103
|
+
self.status_led = b
|
|
104
|
+
if (b := _as_bool(attr.get("micEnable"))) is not None:
|
|
105
|
+
self.microphone = b
|
|
85
106
|
if (v := attr.get("nightVisionMode")) is not None:
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
nv = _as_int(v)
|
|
108
|
+
if nv is not None:
|
|
88
109
|
self.night_vision_mode = {0: "auto", 1: "on", 2: "off"}.get(nv, str(nv))
|
|
89
|
-
|
|
110
|
+
else:
|
|
90
111
|
# Camera may send string "on"/"off"/"auto" instead of 0/1/2
|
|
91
112
|
self.night_vision_mode = str(v)
|
|
92
|
-
if (
|
|
93
|
-
self.ir_light =
|
|
94
|
-
if (
|
|
95
|
-
self.floodlight =
|
|
96
|
-
if (
|
|
97
|
-
self.ptz_tracking =
|
|
98
|
-
if (
|
|
99
|
-
self.speaker_volume =
|
|
113
|
+
if (b := _as_bool(attr.get("nightVisionIRLight"))) is not None:
|
|
114
|
+
self.ir_light = b
|
|
115
|
+
if (b := _as_bool(attr.get("LightOnOff"))) is not None:
|
|
116
|
+
self.floodlight = b
|
|
117
|
+
if (b := _as_bool(attr.get("trackingMode"))) is not None:
|
|
118
|
+
self.ptz_tracking = b
|
|
119
|
+
if (i := _as_int(attr.get("SoundLevel"))) is not None:
|
|
120
|
+
self.speaker_volume = i
|
|
100
121
|
# Diagnostic / read-only
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
_LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
|
|
106
|
-
if (v := attr.get("Occupancy")) is not None:
|
|
107
|
-
self.occupancy = bool(int(v))
|
|
122
|
+
if (i := _as_int(attr.get("Battery_remaining"))) is not None:
|
|
123
|
+
self.battery_remaining = i
|
|
124
|
+
if (b := _as_bool(attr.get("Occupancy"))) is not None:
|
|
125
|
+
self.occupancy = b
|
|
108
126
|
if (v := attr.get("SDcardStatus")) is not None:
|
|
109
127
|
self.sd_card_status = str(v)
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
self.wifi_rssi = int(v)
|
|
113
|
-
except (ValueError, TypeError):
|
|
114
|
-
_LOGGER.debug("swallowed exception in %s", 'update', exc_info=True)
|
|
128
|
+
if (i := _as_int(attr.get("networkRssi"))) is not None:
|
|
129
|
+
self.wifi_rssi = i
|
|
115
130
|
|
|
116
131
|
# Cloud "properties" keys that belong to lights, not cameras. A camera's
|
|
117
132
|
# image "Dimming" must not be read as a light brightness (and could TypeError
|
|
@@ -8,11 +8,13 @@ imports client.py -- the import edge is one-way (client -> protocol).
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import hashlib
|
|
11
12
|
import ipaddress
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
14
15
|
import os
|
|
15
16
|
import random
|
|
17
|
+
import re
|
|
16
18
|
import select
|
|
17
19
|
import socket
|
|
18
20
|
import struct
|
|
@@ -552,7 +554,14 @@ def _build_sprop(sps: bytes, pps: bytes) -> str:
|
|
|
552
554
|
|
|
553
555
|
|
|
554
556
|
def _sprop_cache_path(devid: str) -> str:
|
|
555
|
-
|
|
557
|
+
# `devid` originates from the cloud/device and is interpolated into a
|
|
558
|
+
# filesystem path, so a value containing "/", "\\", ".." or an absolute path
|
|
559
|
+
# could escape _SPROP_DIR (arbitrary read/write). Reduce it to a safe,
|
|
560
|
+
# collision-resistant filename component.
|
|
561
|
+
safe = re.sub(r"[^A-Za-z0-9_-]", "_", devid or "")
|
|
562
|
+
if not safe or len(devid or "") > 128:
|
|
563
|
+
safe = hashlib.sha256((devid or "").encode("utf-8", "replace")).hexdigest()
|
|
564
|
+
return os.path.join(_SPROP_DIR, f"{safe}.sprop")
|
|
556
565
|
|
|
557
566
|
|
|
558
567
|
def _load_sprop(devid: str) -> "Optional[str]":
|
|
@@ -39,6 +39,47 @@ from .protocol import (
|
|
|
39
39
|
_LOGGER = logging.getLogger(__name__)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _normalize_fingerprint(value: str) -> str:
|
|
43
|
+
"""Normalize a sha-256 fingerprint for comparison.
|
|
44
|
+
|
|
45
|
+
Accepts either the colon-separated uppercase hex aiortc emits or an operator-
|
|
46
|
+
supplied pin that may differ in case, contain/omit colons, or carry stray
|
|
47
|
+
whitespace. Reduces both to lowercase hex with no separators.
|
|
48
|
+
"""
|
|
49
|
+
return value.replace(":", "").replace(" ", "").strip().lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dtls_pin_matches(real_fp: str, pinned_fp: str) -> bool:
|
|
53
|
+
"""True if a cert's fingerprint matches the pin (format-insensitive)."""
|
|
54
|
+
return _normalize_fingerprint(real_fp) == _normalize_fingerprint(pinned_fp)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _verified_dtls_fingerprint(cam_cert, pinned_fp: str, cert_fp_fn) -> Optional[str]:
|
|
58
|
+
"""Return the peer cert's sha-256 fingerprint, enforcing the pin if set.
|
|
59
|
+
|
|
60
|
+
Fails CLOSED under pinning: if a pin is configured, a missing peer cert or a
|
|
61
|
+
fingerprint that does not match the pin raises ``ValueError`` (which the
|
|
62
|
+
caller propagates to fail the DTLS handshake). Any error computing the
|
|
63
|
+
fingerprint is the caller's responsibility to treat as a failure when pinned.
|
|
64
|
+
|
|
65
|
+
Returns the fingerprint string, or ``None`` when no cert is present and no
|
|
66
|
+
pin is configured (accept-any default — the caller may skip verification).
|
|
67
|
+
"""
|
|
68
|
+
if cam_cert is None:
|
|
69
|
+
if pinned_fp:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"DTLS peer presented no certificate; cannot verify against pin"
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
real_fp = cert_fp_fn(cam_cert, "sha-256")
|
|
75
|
+
if pinned_fp and not _dtls_pin_matches(real_fp, pinned_fp):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"DTLS fingerprint mismatch: camera presented {real_fp}, "
|
|
78
|
+
f"pinned {pinned_fp}"
|
|
79
|
+
)
|
|
80
|
+
return real_fp
|
|
81
|
+
|
|
82
|
+
|
|
42
83
|
async def _keyframe_prompter(send_pli, first_frame, interval: float = 0.7,
|
|
43
84
|
max_tries: int = 8) -> int:
|
|
44
85
|
"""Send an RTCP PLI (keyframe request) immediately and then every `interval`
|
|
@@ -1221,7 +1262,15 @@ class _WebRTCOpenMixin:
|
|
|
1221
1262
|
await asyncio.wait_for(mqtt_fut, timeout=5.0)
|
|
1222
1263
|
except Exception:
|
|
1223
1264
|
pass # MQTT thread exit errors don't affect the retry
|
|
1224
|
-
|
|
1265
|
+
# Call the UNGATED impl, not the public async_open_webrtc_stream:
|
|
1266
|
+
# we are already holding the global open-gate permit (acquired in
|
|
1267
|
+
# async_open_webrtc_stream), and the gate's semaphore is not
|
|
1268
|
+
# reentrant. Re-entering the public method here would try to
|
|
1269
|
+
# acquire a second permit and deadlock whenever the gate is
|
|
1270
|
+
# saturated (two SDES->DTLS fallbacks at once, or
|
|
1271
|
+
# AIDOT_MAX_CONCURRENT_OPENS=1). The impl runs the same handshake
|
|
1272
|
+
# under the permit we already hold.
|
|
1273
|
+
return await self._async_open_webrtc_stream_impl(
|
|
1225
1274
|
on_frame,
|
|
1226
1275
|
stream_id=stream_id,
|
|
1227
1276
|
timeout=timeout,
|
|
@@ -2352,27 +2401,33 @@ class _WebRTCOpenMixin:
|
|
|
2352
2401
|
)
|
|
2353
2402
|
|
|
2354
2403
|
def _accept_camera_cert(self, remote_params):
|
|
2355
|
-
"""Verify (if pinned) or accept the camera's actual DTLS cert.
|
|
2404
|
+
"""Verify (if pinned) or accept the camera's actual DTLS cert.
|
|
2405
|
+
|
|
2406
|
+
Fails CLOSED when AIDOT_DTLS_PINNED_FP is set: a missing cert
|
|
2407
|
+
or any error computing/comparing the fingerprint fails the
|
|
2408
|
+
handshake rather than silently accepting an unverified peer.
|
|
2409
|
+
"""
|
|
2356
2410
|
try:
|
|
2357
2411
|
_cam_cert = self._ssl.get_peer_certificate(
|
|
2358
2412
|
as_cryptography=True
|
|
2359
2413
|
)
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
raise ValueError(
|
|
2365
|
-
"DTLS fingerprint mismatch: camera "
|
|
2366
|
-
f"presented {_real_fp}, pinned "
|
|
2367
|
-
f"{_pinned_fp}"
|
|
2368
|
-
)
|
|
2414
|
+
_real_fp = _verified_dtls_fingerprint(
|
|
2415
|
+
_cam_cert, _pinned_fp, _rr_cert_fp
|
|
2416
|
+
)
|
|
2417
|
+
if _real_fp is not None:
|
|
2369
2418
|
remote_params.fingerprints[:] = [
|
|
2370
2419
|
_RRFp(algorithm="sha-256", value=_real_fp)
|
|
2371
2420
|
]
|
|
2372
2421
|
except ValueError:
|
|
2373
|
-
raise #
|
|
2422
|
+
raise # pin mismatch / no cert under pin: fail the handshake
|
|
2374
2423
|
except Exception:
|
|
2375
|
-
|
|
2424
|
+
if _pinned_fp:
|
|
2425
|
+
raise # fail closed: never accept an unverifiable cert
|
|
2426
|
+
_LOGGER.debug(
|
|
2427
|
+
"camera %s: DTLS cert retrieval failed; accept-any "
|
|
2428
|
+
"(unpinned)", getattr(self, "device_id", "?"),
|
|
2429
|
+
exc_info=True,
|
|
2430
|
+
)
|
|
2376
2431
|
|
|
2377
2432
|
for _rr_tc in pc.getTransceivers():
|
|
2378
2433
|
_rr_tc.receiver.transport._validate_peer_identity = (
|
|
@@ -2837,7 +2892,16 @@ class _WebRTCOpenMixin:
|
|
|
2837
2892
|
# Apply DTLS cert bypass - always, whenever we patched
|
|
2838
2893
|
# fingerprints (camera's empty-fingerprint quirk applies on
|
|
2839
2894
|
# every webrtcResp from this firmware family).
|
|
2840
|
-
|
|
2895
|
+
# Install our peer-cert validator when EITHER the camera's empty-
|
|
2896
|
+
# fingerprint quirk needs the workaround (_fp_subs) OR a pin is
|
|
2897
|
+
# configured. Pinning must be enforced even when the camera sent a
|
|
2898
|
+
# REAL fingerprint: that fingerprint rides attacker-influenceable
|
|
2899
|
+
# signaling, so a MITM can present a well-formed value matching its
|
|
2900
|
+
# own cert and aiortc's native check would pass. Gating the pin on
|
|
2901
|
+
# _fp_subs (empty-fingerprint only) left it silently inert for cameras
|
|
2902
|
+
# that present a real fingerprint.
|
|
2903
|
+
_np_pinned_fp = (os.environ.get("AIDOT_DTLS_PINNED_FP") or "").strip()
|
|
2904
|
+
if _fp_subs or _np_pinned_fp:
|
|
2841
2905
|
import types as _np_types
|
|
2842
2906
|
try:
|
|
2843
2907
|
from aiortc.rtcdtlstransport import (
|
|
@@ -2845,11 +2909,6 @@ class _WebRTCOpenMixin:
|
|
|
2845
2909
|
certificate_digest as _np_cert_fp,
|
|
2846
2910
|
)
|
|
2847
2911
|
|
|
2848
|
-
# See AIDOT_DTLS_PINNED_FP note above: optional pinning,
|
|
2849
|
-
# otherwise accept-any with a one-time warning.
|
|
2850
|
-
_np_pinned_fp = (
|
|
2851
|
-
os.environ.get("AIDOT_DTLS_PINNED_FP") or ""
|
|
2852
|
-
).strip()
|
|
2853
2912
|
if not _np_pinned_fp:
|
|
2854
2913
|
_LOGGER.warning(
|
|
2855
2914
|
"camera %s: DTLS peer certificate is NOT authenticated "
|
|
@@ -2865,21 +2924,18 @@ class _WebRTCOpenMixin:
|
|
|
2865
2924
|
_cam_cert = self._ssl.get_peer_certificate(
|
|
2866
2925
|
as_cryptography=True
|
|
2867
2926
|
)
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
raise ValueError(
|
|
2873
|
-
"DTLS fingerprint mismatch: camera "
|
|
2874
|
-
f"presented {_real_fp}, pinned "
|
|
2875
|
-
f"{_np_pinned_fp}"
|
|
2876
|
-
)
|
|
2927
|
+
_real_fp = _verified_dtls_fingerprint(
|
|
2928
|
+
_cam_cert, _np_pinned_fp, _np_cert_fp
|
|
2929
|
+
)
|
|
2930
|
+
if _real_fp is not None:
|
|
2877
2931
|
remote_params.fingerprints[:] = [
|
|
2878
2932
|
_NPFp(algorithm="sha-256", value=_real_fp)
|
|
2879
2933
|
]
|
|
2880
2934
|
except ValueError:
|
|
2881
|
-
raise #
|
|
2935
|
+
raise # pin mismatch / no cert under pin: fail handshake
|
|
2882
2936
|
except Exception:
|
|
2937
|
+
if _np_pinned_fp:
|
|
2938
|
+
raise # fail closed: never accept an unverifiable cert
|
|
2883
2939
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_np_accept_cam_cert', exc_info=True)
|
|
2884
2940
|
|
|
2885
2941
|
# Diag: log PC/ICE state at patch-application time so we
|
|
@@ -245,7 +245,12 @@ class AidotClient:
|
|
|
245
245
|
except AidotUserOrPassIncorrect:
|
|
246
246
|
raise
|
|
247
247
|
except aiohttp.ClientError as e:
|
|
248
|
-
|
|
248
|
+
# Do NOT log response_data: the login body carries accessToken /
|
|
249
|
+
# refreshToken. Log only the status code the server returned.
|
|
250
|
+
_LOGGER.error(
|
|
251
|
+
"async_post_login ClientError %s (code=%s)",
|
|
252
|
+
e, (response_data or {}).get(CONF_CODE),
|
|
253
|
+
)
|
|
249
254
|
raise
|
|
250
255
|
|
|
251
256
|
async def _async_fetch_user_config(self) -> None:
|
|
@@ -298,9 +303,12 @@ class AidotClient:
|
|
|
298
303
|
self.login_info["mqttPassword"] = pwd
|
|
299
304
|
_LOGGER.info("_async_fetch_user_config: mqttPassword stored (len=%d)", len(pwd))
|
|
300
305
|
else:
|
|
306
|
+
# Do NOT log `body`: the userConfig response often still carries
|
|
307
|
+
# the live mqttPassword under an unexpected key, which is exactly
|
|
308
|
+
# when this branch fires. Log the key names only.
|
|
301
309
|
_LOGGER.warning(
|
|
302
310
|
"_async_fetch_user_config: mqttPassword not found in response. "
|
|
303
|
-
"keys=%s
|
|
311
|
+
"keys=%s", list(data.keys())
|
|
304
312
|
)
|
|
305
313
|
# Also extract MQTT clientId if provided.
|
|
306
314
|
client_id = data.get("mqttClientId") or mqtt_block.get("clientId") or ""
|
|
@@ -408,7 +416,11 @@ class AidotClient:
|
|
|
408
416
|
response.raise_for_status()
|
|
409
417
|
return response_data
|
|
410
418
|
except aiohttp.ClientError as e:
|
|
411
|
-
|
|
419
|
+
# Do NOT log response_data: /devices carries per-device aesKey and
|
|
420
|
+
# password. Log the response code only.
|
|
421
|
+
_LOGGER.error(
|
|
422
|
+
"async_get ClientError %s (code=%s)", e, response_data.get(CONF_CODE)
|
|
423
|
+
)
|
|
412
424
|
code = response_data.get(CONF_CODE)
|
|
413
425
|
if code == ServerErrorCode.TOKEN_EXPIRED:
|
|
414
426
|
if not _retry:
|
|
@@ -173,9 +173,20 @@ def _load_plain(path: str) -> dict:
|
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
def _write_secret(path: str, data: bytes) -> None:
|
|
176
|
-
"""Write binary data to path with 0600 permissions."""
|
|
177
|
-
os.
|
|
178
|
-
|
|
176
|
+
"""Write binary data to path, created atomically with 0600 permissions."""
|
|
177
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
178
|
+
os.makedirs(d, exist_ok=True)
|
|
179
|
+
try:
|
|
180
|
+
os.chmod(d, 0o700) # restrict the config dir too (best-effort)
|
|
181
|
+
except OSError:
|
|
182
|
+
pass
|
|
183
|
+
# Create with 0600 from the start so the secret is never briefly
|
|
184
|
+
# world/group-readable under the process umask (a chmod-after-open leaves a
|
|
185
|
+
# window where another local user could read the key/ciphertext).
|
|
186
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
187
|
+
with os.fdopen(fd, "wb") as f:
|
|
179
188
|
f.write(data)
|
|
180
189
|
f.write(b"\n")
|
|
190
|
+
# If the file pre-existed with looser perms, O_CREAT's mode was ignored;
|
|
191
|
+
# tighten it explicitly.
|
|
181
192
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -200,7 +200,13 @@ class DeviceClient(CameraMixin):
|
|
|
200
200
|
self.ping_data = None
|
|
201
201
|
self.heart_time = 10
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
# Log a redacted subset only: the raw device record carries aesKey and
|
|
204
|
+
# password, which must not reach logs even at DEBUG.
|
|
205
|
+
if isinstance(device, dict):
|
|
206
|
+
_LOGGER.debug(
|
|
207
|
+
"%s: device id=%s modelId=%s",
|
|
208
|
+
self._TAG, device.get("id"), device.get("modelId"),
|
|
209
|
+
)
|
|
204
210
|
self._init_camera_state(device, user_info)
|
|
205
211
|
|
|
206
212
|
async def connect(self, ip_address) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,6 +44,7 @@ tests/test_backoff.py
|
|
|
44
44
|
tests/test_device_login_guard.py
|
|
45
45
|
tests/test_device_user_info_cache.py
|
|
46
46
|
tests/test_dtls_not_ready_burst.py
|
|
47
|
+
tests/test_dtls_pinning.py
|
|
47
48
|
tests/test_dtls_skip_signaling_wait.py
|
|
48
49
|
tests/test_egress_guard.py
|
|
49
50
|
tests/test_go2rtc.py
|
|
@@ -71,6 +72,7 @@ tests/test_sdes_serve_cmd.py
|
|
|
71
72
|
tests/test_sdes_sprop.py
|
|
72
73
|
tests/test_sdes_talk.py
|
|
73
74
|
tests/test_sdes_watchdog.py
|
|
75
|
+
tests/test_security_hardening.py
|
|
74
76
|
tests/test_serve_relay.py
|
|
75
77
|
tests/test_session_stats.py
|
|
76
78
|
tests/test_speak.py
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""DTLS fingerprint-pinning verification (AIDOT_DTLS_PINNED_FP).
|
|
2
|
+
|
|
3
|
+
These guard the fail-closed contract: with a pin configured, a missing cert or a
|
|
4
|
+
non-matching fingerprint must fail the handshake, never silently accept. The
|
|
5
|
+
absence of exactly this test is how the earlier fail-open bugs survived.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from aidot.camera.webrtc_open import (
|
|
11
|
+
_dtls_pin_matches,
|
|
12
|
+
_normalize_fingerprint,
|
|
13
|
+
_verified_dtls_fingerprint,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# aiortc formats sha-256 fingerprints as uppercase colon-separated hex.
|
|
17
|
+
REAL = ":".join(["AB", "CD", "EF", "01"] * 8) # 32 bytes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fp_fn(cert, alg):
|
|
21
|
+
# In these tests the "cert" object IS its fingerprint string.
|
|
22
|
+
assert alg == "sha-256"
|
|
23
|
+
return cert
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_normalize_strips_colons_case_and_space():
|
|
27
|
+
assert _normalize_fingerprint("AB:CD:ef 12") == "abcdef12"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_pin_matches_is_format_insensitive():
|
|
31
|
+
assert _dtls_pin_matches(REAL, REAL.lower().replace(":", ""))
|
|
32
|
+
assert _dtls_pin_matches(REAL, f" {REAL} ")
|
|
33
|
+
assert not _dtls_pin_matches(REAL, "00:11")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_matching_pin_returns_fingerprint():
|
|
37
|
+
assert _verified_dtls_fingerprint(REAL, REAL, _fp_fn) == REAL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_pin_format_differences_still_match():
|
|
41
|
+
# Operator supplies the pin without colons / lowercased.
|
|
42
|
+
assert _verified_dtls_fingerprint(REAL, REAL.lower().replace(":", ""), _fp_fn) == REAL
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_mismatching_pin_raises():
|
|
46
|
+
with pytest.raises(ValueError, match="mismatch"):
|
|
47
|
+
_verified_dtls_fingerprint(REAL, "00:11:22", _fp_fn)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_missing_cert_with_pin_fails_closed():
|
|
51
|
+
# No peer cert while pinning MUST fail, not accept-any.
|
|
52
|
+
with pytest.raises(ValueError, match="no certificate"):
|
|
53
|
+
_verified_dtls_fingerprint(None, REAL, _fp_fn)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_missing_cert_without_pin_accepts():
|
|
57
|
+
assert _verified_dtls_fingerprint(None, "", _fp_fn) is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_present_cert_without_pin_returns_fingerprint():
|
|
61
|
+
# Unpinned but a cert is present: return the real fp so the caller can
|
|
62
|
+
# rewrite remote_params.fingerprints (the empty-fingerprint workaround).
|
|
63
|
+
assert _verified_dtls_fingerprint(REAL, "", _fp_fn) == REAL
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Regression tests for the 0.10.x security-review hardening fixes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from aidot.camera.lan_control import _MAGIC, _MAX_FRAME_BODY, _read_frame
|
|
10
|
+
from aidot.camera.models import _as_bool, _as_int
|
|
11
|
+
from aidot.camera.protocol import _SPROP_DIR, _sprop_cache_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── models: malformed cloud attrs are skipped, not fatal ──────────────────────
|
|
15
|
+
|
|
16
|
+
def test_as_int_coerces_or_none():
|
|
17
|
+
assert _as_int("5") == 5
|
|
18
|
+
assert _as_int(5) == 5
|
|
19
|
+
assert _as_int("on") is None # non-numeric string must not raise
|
|
20
|
+
assert _as_int(None) is None
|
|
21
|
+
assert _as_int({}) is None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_as_bool_coerces_or_none():
|
|
25
|
+
assert _as_bool("0") is False
|
|
26
|
+
assert _as_bool("1") is True
|
|
27
|
+
assert _as_bool(1) is True
|
|
28
|
+
assert _as_bool("yes") is None # non-numeric must not raise
|
|
29
|
+
assert _as_bool(None) is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── sprop cache: devid can't escape the cache dir ─────────────────────────────
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("devid", ["../../etc/passwd", "/etc/passwd", "a/b/c", "..", "x\\y"])
|
|
35
|
+
def test_sprop_path_stays_in_cache_dir(devid):
|
|
36
|
+
p = os.path.abspath(_sprop_cache_path(devid))
|
|
37
|
+
assert os.path.dirname(p) == os.path.abspath(_SPROP_DIR)
|
|
38
|
+
assert p.endswith(".sprop")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_sprop_path_normal_devid_preserved():
|
|
42
|
+
p = _sprop_cache_path("LK-IPC-A000088-abc123")
|
|
43
|
+
assert os.path.basename(p) == "LK-IPC-A000088-abc123.sprop"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── LAN control: oversized frame body is rejected before allocation ───────────
|
|
47
|
+
|
|
48
|
+
def test_read_frame_rejects_oversized_body():
|
|
49
|
+
async def _run():
|
|
50
|
+
reader = asyncio.StreamReader()
|
|
51
|
+
reader.feed_data(struct.pack(">HHI", _MAGIC, 1, _MAX_FRAME_BODY + 1))
|
|
52
|
+
reader.feed_eof()
|
|
53
|
+
with pytest.raises(ValueError, match="too large"):
|
|
54
|
+
await _read_frame(reader, timeout=1.0)
|
|
55
|
+
|
|
56
|
+
asyncio.run(_run())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_read_frame_accepts_small_body():
|
|
60
|
+
async def _run():
|
|
61
|
+
body = b'{"ok":1}'
|
|
62
|
+
reader = asyncio.StreamReader()
|
|
63
|
+
reader.feed_data(struct.pack(">HHI", _MAGIC, 1, len(body)) + body)
|
|
64
|
+
reader.feed_eof()
|
|
65
|
+
assert await _read_frame(reader, timeout=1.0) == body
|
|
66
|
+
|
|
67
|
+
asyncio.run(_run())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_dtls_skip_signaling_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_persistent_mqtt_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.0 → python_aidot_cameras-0.10.1}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|