python-aidot-cameras 0.10.3__tar.gz → 0.11.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.
- {python_aidot_cameras-0.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/client.py +35 -7
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/lan_control.py +18 -2
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/playback.py +18 -8
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/sdes_open.py +12 -4
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/webrtc_open.py +27 -6
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
- python_aidot_cameras-0.11.0/tests/test_deferred_hardening.py +98 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/LICENSE +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/README.md +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/protocol.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/client.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/const.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
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
|
|
@@ -67,6 +67,7 @@ from .protocol import ( # noqa: F401 - used here and/or by the webrtc_open mixi
|
|
|
67
67
|
_h264_has_keyframe,
|
|
68
68
|
_mqtt_session_sync,
|
|
69
69
|
_mqtt_session,
|
|
70
|
+
_mqtt_session_with_status,
|
|
70
71
|
_mqtt_get_playback_server_info,
|
|
71
72
|
_ip_looks_ascii_garbled,
|
|
72
73
|
_normalize_bundle_ice_credentials,
|
|
@@ -93,10 +94,34 @@ _BG_TASKS: set = set()
|
|
|
93
94
|
def _spawn_bg(coro):
|
|
94
95
|
_t = asyncio.ensure_future(coro)
|
|
95
96
|
_BG_TASKS.add(_t)
|
|
96
|
-
|
|
97
|
+
|
|
98
|
+
def _done(task):
|
|
99
|
+
_BG_TASKS.discard(task)
|
|
100
|
+
# Retrieve the exception so a failing background task doesn't surface as
|
|
101
|
+
# an unhandled "Task exception was never retrieved" at GC time; log it.
|
|
102
|
+
if not task.cancelled():
|
|
103
|
+
exc = task.exception()
|
|
104
|
+
if exc is not None:
|
|
105
|
+
_LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
|
|
106
|
+
|
|
107
|
+
_t.add_done_callback(_done)
|
|
97
108
|
return _t
|
|
98
109
|
|
|
99
110
|
|
|
111
|
+
def _mqtt_publish_delivered(status) -> bool:
|
|
112
|
+
"""True if an MQTT command reached the broker (connection up, no error).
|
|
113
|
+
|
|
114
|
+
Distinguishes a genuine fire-and-forget publish (broker connected, the device
|
|
115
|
+
simply doesn't ACK) from a refused/failed connection - which returns an empty
|
|
116
|
+
message list too, and must NOT be reported as "sent".
|
|
117
|
+
"""
|
|
118
|
+
if not status:
|
|
119
|
+
return True # no status available: preserve prior best-effort behavior
|
|
120
|
+
if status.get("error"):
|
|
121
|
+
return False
|
|
122
|
+
return status.get("connected", True) is not False
|
|
123
|
+
|
|
124
|
+
|
|
100
125
|
def _retry_policy(failure_kind: str, burst_attempt: int, *,
|
|
101
126
|
burst_delay: float = 3.0, burst_max: int = 4,
|
|
102
127
|
base_gate: float = 15.0) -> "tuple[float, bool]":
|
|
@@ -1418,14 +1443,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1418
1443
|
"_mqtt_device_cmd: persistent MQTT failed (%s); falling back "
|
|
1419
1444
|
"to a per-op session for %s", _st.get("error"), device_id,
|
|
1420
1445
|
)
|
|
1421
|
-
messages = await
|
|
1446
|
+
messages, _st = await _mqtt_session_with_status(
|
|
1422
1447
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1423
1448
|
subscribe_topics=sub_topics,
|
|
1424
1449
|
publish_items=publish_items,
|
|
1425
1450
|
duration=timeout,
|
|
1426
1451
|
)
|
|
1427
1452
|
else:
|
|
1428
|
-
messages = await
|
|
1453
|
+
messages, _st = await _mqtt_session_with_status(
|
|
1429
1454
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1430
1455
|
subscribe_topics=sub_topics,
|
|
1431
1456
|
publish_items=publish_items,
|
|
@@ -1449,10 +1474,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1449
1474
|
_LOGGER.debug("device cmd ack 200 (inner): topic=%s", topic)
|
|
1450
1475
|
return True
|
|
1451
1476
|
|
|
1452
|
-
# Fire-and-forget fallback:
|
|
1453
|
-
#
|
|
1454
|
-
#
|
|
1455
|
-
|
|
1477
|
+
# Fire-and-forget fallback: the official app uses a delivery callback,
|
|
1478
|
+
# not a device response, so a published command with no 200-ack is
|
|
1479
|
+
# normal. But only treat it as success if the broker connection actually
|
|
1480
|
+
# succeeded - a refused/failed connection returns an empty message list
|
|
1481
|
+
# too, and reporting that as "sent" makes HA show a state change that
|
|
1482
|
+
# never reached the camera.
|
|
1483
|
+
if messages is not None and _mqtt_publish_delivered(_st):
|
|
1456
1484
|
_LOGGER.debug(
|
|
1457
1485
|
"device cmd: sent (no explicit 200-ack on %s, %d msgs total) "
|
|
1458
1486
|
"- treating as sent-ok (official app is fire-and-forget)",
|
|
@@ -274,9 +274,25 @@ class CameraLanClient:
|
|
|
274
274
|
}
|
|
275
275
|
writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
|
|
276
276
|
await writer.drain()
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
try:
|
|
278
|
+
resp = json.loads(
|
|
279
|
+
aes_decrypt(await _read_frame(reader, timeout=8.0), self._key)
|
|
280
|
+
)
|
|
281
|
+
ack = (resp.get("ack") or {}).get("code")
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
# A host that can't produce a response we can decrypt with the
|
|
284
|
+
# device's real AES key is not the camera (e.g. a LAN peer that
|
|
285
|
+
# spoofed this devId in discovery - discovery is unauthenticated).
|
|
286
|
+
# Mark ineligible so control falls back to the cloud instead of
|
|
287
|
+
# repeatedly targeting a bogus/broken host.
|
|
288
|
+
self._eligible = False
|
|
289
|
+
raise CameraLanError(
|
|
290
|
+
f"{self.device_id}: login response undecryptable (wrong host?)"
|
|
291
|
+
) from exc
|
|
279
292
|
if ack != 200:
|
|
293
|
+
# The real device rejected our key-authenticated login; stop using
|
|
294
|
+
# the LAN path and revert to cloud.
|
|
295
|
+
self._eligible = False
|
|
280
296
|
raise CameraLanError(f"{self.device_id}: login rejected ack={ack}")
|
|
281
297
|
return (resp.get("payload") or {}).get("ascNumber", 1)
|
|
282
298
|
|
|
@@ -29,6 +29,7 @@ from .models import VideoFrame
|
|
|
29
29
|
_LOGGER = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
_PLAYBACK_TLS_VERIFY_ENV = "AIDOT_PLAYBACK_TLS_VERIFY"
|
|
32
|
+
_PLAYBACK_TLS_WARNED = False # module-level once-guard for the insecure-default warning
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def _playback_ssl_context() -> ssl.SSLContext:
|
|
@@ -48,13 +49,16 @@ def _playback_ssl_context() -> ssl.SSLContext:
|
|
|
48
49
|
else:
|
|
49
50
|
ctx.check_hostname = False
|
|
50
51
|
ctx.verify_mode = ssl.CERT_NONE
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
global _PLAYBACK_TLS_WARNED
|
|
53
|
+
if not _PLAYBACK_TLS_WARNED:
|
|
54
|
+
_PLAYBACK_TLS_WARNED = True
|
|
55
|
+
_LOGGER.warning(
|
|
56
|
+
"playback: TLS certificate verification is DISABLED; the "
|
|
57
|
+
"live/playback stream is not authenticated and is exposed to "
|
|
58
|
+
"on-path tampering. Set %s=1 to enable verification once a trust "
|
|
59
|
+
"anchor for the camera's certificate is available. (logged once)",
|
|
60
|
+
_PLAYBACK_TLS_VERIFY_ENV,
|
|
61
|
+
)
|
|
58
62
|
return ctx
|
|
59
63
|
|
|
60
64
|
|
|
@@ -140,6 +144,7 @@ class CloudPlaybackSession:
|
|
|
140
144
|
client_id: str,
|
|
141
145
|
start_ts_s: int,
|
|
142
146
|
on_frame: Callable[[VideoFrame], None],
|
|
147
|
+
use_tls: bool = False,
|
|
143
148
|
) -> None:
|
|
144
149
|
self._server_ip = server_ip
|
|
145
150
|
self._server_port = server_port
|
|
@@ -148,6 +153,10 @@ class CloudPlaybackSession:
|
|
|
148
153
|
self._client_id = client_id
|
|
149
154
|
self._start_ts = start_ts_s
|
|
150
155
|
self._on_frame = on_frame
|
|
156
|
+
# Opt-in TLS for the cloud-playback TCP stream. Default False preserves
|
|
157
|
+
# the historical plaintext behavior (the cloud playback server may not
|
|
158
|
+
# speak TLS on this port); pass True once verified against a live server.
|
|
159
|
+
self._use_tls = use_tls
|
|
151
160
|
self._reader: Optional[asyncio.StreamReader] = None
|
|
152
161
|
self._writer: Optional[asyncio.StreamWriter] = None
|
|
153
162
|
self._running = False
|
|
@@ -157,8 +166,9 @@ class CloudPlaybackSession:
|
|
|
157
166
|
|
|
158
167
|
async def _connect_and_login(self) -> bool:
|
|
159
168
|
try:
|
|
169
|
+
ssl_ctx = _playback_ssl_context() if self._use_tls else None
|
|
160
170
|
self._reader, self._writer = await asyncio.open_connection(
|
|
161
|
-
self._server_ip, self._server_port
|
|
171
|
+
self._server_ip, self._server_port, ssl=ssl_ctx
|
|
162
172
|
)
|
|
163
173
|
except OSError as exc:
|
|
164
174
|
_LOGGER.error(
|
|
@@ -3174,16 +3174,24 @@ class _SdesOpenMixin:
|
|
|
3174
3174
|
# refresh (RFC 7675). Also handles the case where the initial
|
|
3175
3175
|
# USE-CANDIDATE (sent right after the STUN window) was lost.
|
|
3176
3176
|
_br_now = _time_br.monotonic()
|
|
3177
|
-
|
|
3177
|
+
# Relay-only battery cams (LAN IP unknown) answer AFTER the STUN
|
|
3178
|
+
# window and send no probes, so the in-window proactive send saw
|
|
3179
|
+
# empty _cam_ice_* and the probe-gated late-send above never runs.
|
|
3180
|
+
# Fall back to the creds parsed late into _bridge_uc_info so this
|
|
3181
|
+
# ungated periodic tick still nominates them. [SDES-LATECREDS-FIX]
|
|
3182
|
+
_uc_cands = _cam_ice_cands or _bridge_uc_info.get("cands")
|
|
3183
|
+
_uc_cufrag = _cam_ice_ufrag or _bridge_uc_info.get("ufrag")
|
|
3184
|
+
_uc_cpwd = _cam_ice_pwd or _bridge_uc_info.get("pwd")
|
|
3185
|
+
if _uc_cands and _uc_cufrag and _uc_cpwd and (_br_now - _br_last_uc) >= 2.5:
|
|
3178
3186
|
_br_last_uc = _br_now
|
|
3179
|
-
for _c_ip, _c_port in
|
|
3187
|
+
for _c_ip, _c_port in _uc_cands:
|
|
3180
3188
|
_send_use_candidate(
|
|
3181
3189
|
_audio_sock, _ufrag_a, _pwd_a,
|
|
3182
|
-
|
|
3190
|
+
_uc_cufrag, _uc_cpwd, (_c_ip, _c_port),
|
|
3183
3191
|
)
|
|
3184
3192
|
_send_use_candidate(
|
|
3185
3193
|
_video_sock, _ufrag_v, _pwd_v,
|
|
3186
|
-
|
|
3194
|
+
_uc_cufrag, _uc_cpwd, (_c_ip, _c_port),
|
|
3187
3195
|
)
|
|
3188
3196
|
finally:
|
|
3189
3197
|
try:
|
|
@@ -356,12 +356,20 @@ class _WebRTCOpenMixin:
|
|
|
356
356
|
self._key
|
|
357
357
|
)
|
|
358
358
|
_ctx = _orig_create_ctx(self, srtp_profiles)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
# Self-scoped: only lower the DTLS floor for certificates
|
|
360
|
+
# WE tagged for an aidot camera session (tagged right
|
|
361
|
+
# after RTCPeerConnection creation below). Other aiortc
|
|
362
|
+
# peer connections in the same process (e.g. another Home
|
|
363
|
+
# Assistant integration) use untagged certs and keep the
|
|
364
|
+
# modern DTLS 1.2 floor - the class-level patch is now a
|
|
365
|
+
# no-op for them.
|
|
366
|
+
if getattr(self, "_aidot_dtls10", False):
|
|
367
|
+
try:
|
|
368
|
+
_ctx.set_min_proto_version(_DTLS1_VERSION)
|
|
369
|
+
except Exception as _e:
|
|
370
|
+
_LOGGER.warning(
|
|
371
|
+
"DTLS 1.0 enable failed: %s", _e
|
|
372
|
+
)
|
|
365
373
|
return _ctx
|
|
366
374
|
_AidotRTCCert._create_ssl_context = _aidot_create_ssl_context
|
|
367
375
|
_AidotRTCCert._aidot_dtls10_patched = True
|
|
@@ -1465,6 +1473,19 @@ class _WebRTCOpenMixin:
|
|
|
1465
1473
|
pc = RTCPeerConnection(
|
|
1466
1474
|
configuration=RTCConfiguration(iceServers=_pc_ice_servers)
|
|
1467
1475
|
)
|
|
1476
|
+
# Tag THIS pc's DTLS certificate(s) so the self-scoped DTLS-1.0 floor
|
|
1477
|
+
# (see _aidot_create_ssl_context) applies only to aidot camera sessions,
|
|
1478
|
+
# not to other aiortc peer connections in the same process. Best-effort:
|
|
1479
|
+
# if aiortc's internal layout changes, the tag is simply absent and the
|
|
1480
|
+
# session keeps the default DTLS 1.2 floor.
|
|
1481
|
+
try:
|
|
1482
|
+
for _aidot_cert in (
|
|
1483
|
+
getattr(pc, "_RTCPeerConnection__certificates", None) or []
|
|
1484
|
+
):
|
|
1485
|
+
_aidot_cert._aidot_dtls10 = True
|
|
1486
|
+
except Exception:
|
|
1487
|
+
_LOGGER.debug("camera %s: could not tag DTLS cert for 1.0 scope",
|
|
1488
|
+
getattr(self, "device_id", "?"), exc_info=True)
|
|
1468
1489
|
# Audio: sendrecv WITHOUT a real audio sender. Empirical findings
|
|
1469
1490
|
# from 2026-04-26 testing (commits aa341a1b, aeaea893):
|
|
1470
1491
|
#
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
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"
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
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
|
|
@@ -74,6 +74,7 @@ python_aidot_cameras.egg-info/top_level.txt
|
|
|
74
74
|
tests/test_aioice_compat.py
|
|
75
75
|
tests/test_alarm_event.py
|
|
76
76
|
tests/test_backoff.py
|
|
77
|
+
tests/test_deferred_hardening.py
|
|
77
78
|
tests/test_device_login_guard.py
|
|
78
79
|
tests/test_device_user_info_cache.py
|
|
79
80
|
tests/test_dtls_not_ready_burst.py
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for the deferred security-review hardening (prep branch).
|
|
2
|
+
|
|
3
|
+
Covers the parts that don't need real cameras. The transport-level behaviours
|
|
4
|
+
(self-scoped DTLS 1.0, cloud-playback TLS on the wire) need on-device validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import struct
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from aidot.aes_utils import aes_encrypt
|
|
14
|
+
from aidot.camera.client import _mqtt_publish_delivered
|
|
15
|
+
from aidot.camera.lan_control import _MAGIC, CameraLanClient, CameraLanError
|
|
16
|
+
from aidot.camera.playback import CloudPlaybackSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -- _mqtt_device_cmd no longer reports success on a failed broker connection --
|
|
20
|
+
|
|
21
|
+
def test_mqtt_publish_delivered_semantics():
|
|
22
|
+
assert _mqtt_publish_delivered(None) is True # no status: prior behavior
|
|
23
|
+
assert _mqtt_publish_delivered({}) is True # connected assumed
|
|
24
|
+
assert _mqtt_publish_delivered({"connected": True}) is True
|
|
25
|
+
assert _mqtt_publish_delivered({"connected": False}) is False # broker refused
|
|
26
|
+
assert _mqtt_publish_delivered({"error": "timeout"}) is False # connect error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -- CloudPlaybackSession TLS is opt-in, default off ---------------------------
|
|
30
|
+
|
|
31
|
+
def _cloud_session(use_tls):
|
|
32
|
+
return CloudPlaybackSession(
|
|
33
|
+
server_ip="10.0.0.5", server_port=443, heartbeat_interval=10,
|
|
34
|
+
task_id=1, client_id="c", start_ts_s=0, on_frame=lambda f: None,
|
|
35
|
+
use_tls=use_tls,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cloud_playback_tls_defaults_off():
|
|
40
|
+
assert _cloud_session(False)._use_tls is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_cloud_playback_tls_opt_in():
|
|
44
|
+
assert _cloud_session(True)._use_tls is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- LAN control: a login we can't complete de-eligibilities the client --------
|
|
48
|
+
|
|
49
|
+
class _FakeWriter:
|
|
50
|
+
def write(self, _data):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
async def drain(self):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _frame(body: bytes) -> bytes:
|
|
58
|
+
return struct.pack(">HHI", _MAGIC, 2, len(body)) + body
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lan_client():
|
|
62
|
+
return CameraLanClient(
|
|
63
|
+
{"id": "cam1", "aesKey": ["0123456789abcdef"], "password": "pw",
|
|
64
|
+
"modelId": "LK.IPC.A000088"},
|
|
65
|
+
{"id": "user1"},
|
|
66
|
+
ip="10.0.0.9",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reader_with(body: bytes) -> asyncio.StreamReader:
|
|
71
|
+
r = asyncio.StreamReader()
|
|
72
|
+
r.feed_data(_frame(body))
|
|
73
|
+
r.feed_eof()
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_login_rejection_marks_ineligible():
|
|
78
|
+
c = _lan_client()
|
|
79
|
+
resp = aes_encrypt(json.dumps({"ack": {"code": 403}}).encode(), c._key)
|
|
80
|
+
|
|
81
|
+
async def _run():
|
|
82
|
+
with pytest.raises(CameraLanError, match="rejected"):
|
|
83
|
+
await c._login(_reader_with(resp), _FakeWriter())
|
|
84
|
+
|
|
85
|
+
asyncio.run(_run())
|
|
86
|
+
assert c.eligible is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_undecryptable_login_response_marks_ineligible():
|
|
90
|
+
# A spoofing host that lacks the device key returns bytes we can't decrypt.
|
|
91
|
+
c = _lan_client()
|
|
92
|
+
|
|
93
|
+
async def _run():
|
|
94
|
+
with pytest.raises(CameraLanError, match="undecryptable"):
|
|
95
|
+
await c._login(_reader_with(b"not-valid-aes-ciphertext-xxxxxxx"), _FakeWriter())
|
|
96
|
+
|
|
97
|
+
asyncio.run(_run())
|
|
98
|
+
assert c.eligible is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/base.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g711.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g722.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/h264.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/opus.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/vpx.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/media.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/exceptions.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/jitterbuffer.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/mediastreams.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcconfiguration.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdatachannel.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdtlstransport.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcicetransport.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpparameters.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpreceiver.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpsender.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcsctptransport.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
|
|
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.3 → python_aidot_cameras-0.11.0}/aidot/models/device_client_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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/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.3 → python_aidot_cameras-0.11.0}/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.3 → python_aidot_cameras-0.11.0}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_persistent_mqtt_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/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
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_security_hardening.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
|