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.
Files changed (121) hide show
  1. {python_aidot_cameras-0.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.0}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/client.py +35 -7
  3. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/lan_control.py +18 -2
  4. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/playback.py +18 -8
  5. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/sdes_open.py +12 -4
  6. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/webrtc_open.py +27 -6
  7. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  10. python_aidot_cameras-0.11.0/tests/test_deferred_hardening.py +98 -0
  11. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/LICENSE +0 -0
  12. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/MANIFEST.in +0 -0
  13. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/README.md +0 -0
  14. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/__init__.py +0 -0
  15. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/__main__.py +0 -0
  16. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/__init__.py +0 -0
  17. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/__init__.py +0 -0
  18. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/clock.py +0 -0
  19. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  20. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  21. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  22. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  23. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  24. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  25. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  26. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  27. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  28. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  29. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/events.py +0 -0
  30. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/exceptions.py +0 -0
  31. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  32. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  33. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rate.py +0 -0
  34. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  35. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  36. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  37. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  38. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  39. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  40. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  41. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  42. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  43. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  44. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  45. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/rtp.py +0 -0
  46. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/sdp.py +0 -0
  47. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/stats.py +0 -0
  48. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/_vendor/aiortc/utils.py +0 -0
  49. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/aes_utils.py +0 -0
  50. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/__init__.py +0 -0
  51. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/constants.py +0 -0
  52. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/controls.py +0 -0
  53. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/go2rtc.py +0 -0
  54. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/models.py +0 -0
  55. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/protocol.py +0 -0
  56. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/sdes.py +0 -0
  57. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/tutk.py +0 -0
  58. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/camera/webrtc.py +0 -0
  59. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/client.py +0 -0
  60. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/const.py +0 -0
  61. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/credentials.py +0 -0
  62. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/device_client.py +0 -0
  63. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/discover.py +0 -0
  64. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/exceptions.py +0 -0
  65. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/g711.py +0 -0
  66. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/login_const.py +0 -0
  67. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/__init__.py +0 -0
  68. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/device_client_model.py +0 -0
  69. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/device_model.py +0 -0
  70. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/models/discover_model.py +0 -0
  71. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/aidot/py.typed +0 -0
  72. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  73. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  74. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  75. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  76. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/setup.cfg +0 -0
  77. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_aioice_compat.py +0 -0
  78. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_alarm_event.py +0 -0
  79. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_backoff.py +0 -0
  80. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_login_guard.py +0 -0
  81. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_device_user_info_cache.py +0 -0
  82. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_not_ready_burst.py +0 -0
  83. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_pinning.py +0 -0
  84. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  85. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_highport_nomination.py +0 -0
  89. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_ice_config_cache.py +0 -0
  90. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_keyframe_prompter.py +0 -0
  91. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_lan_control.py +0 -0
  92. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_live_stream_param.py +0 -0
  93. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_motion_poll.py +0 -0
  94. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_narrow_pc_ice.py +0 -0
  95. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_no_undefined_names.py +0 -0
  96. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_open_gate_delay.py +0 -0
  97. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_persistent_mqtt.py +0 -0
  98. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  99. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_playback_tls.py +0 -0
  100. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_post_merge_hardening.py +0 -0
  101. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_retry_policy.py +0 -0
  102. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_adaptive.py +0 -0
  103. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  104. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_fast_liveplay.py +0 -0
  105. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_idle_release.py +0 -0
  106. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_serve_audio.py +0 -0
  107. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_serve_cmd.py +0 -0
  108. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_sprop.py +0 -0
  109. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_talk.py +0 -0
  110. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_sdes_watchdog.py +0 -0
  111. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_security_hardening.py +0 -0
  112. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_serve_relay.py +0 -0
  113. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_session_stats.py +0 -0
  114. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_speak.py +0 -0
  115. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_cap.py +0 -0
  116. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_idle.py +0 -0
  117. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_stream_teardown.py +0 -0
  118. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_talk.py +0 -0
  119. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_terminal_ack.py +0 -0
  120. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/tests/test_token_refresh.py +0 -0
  121. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.0}/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
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
- _t.add_done_callback(_BG_TASKS.discard)
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 _mqtt_session(
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 _mqtt_session(
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: if we published successfully and got ANY
1453
- # broker delivery confirmation (no MQTT error), treat as success.
1454
- # The official app uses a delivery callback, not a device response.
1455
- if messages is not None:
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
- resp = json.loads(aes_decrypt(await _read_frame(reader, timeout=8.0), self._key))
278
- ack = (resp.get("ack") or {}).get("code")
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
- _LOGGER.warning(
52
- "playback: TLS certificate verification is DISABLED; the live/playback "
53
- "stream is not authenticated and is exposed to on-path tampering. Set "
54
- "%s=1 to enable verification once a trust anchor for the camera's "
55
- "certificate is available.",
56
- _PLAYBACK_TLS_VERIFY_ENV,
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
- if _cam_ice_cands and (_br_now - _br_last_uc) >= 2.5:
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 _cam_ice_cands:
3187
+ for _c_ip, _c_port in _uc_cands:
3180
3188
  _send_use_candidate(
3181
3189
  _audio_sock, _ufrag_a, _pwd_a,
3182
- _cam_ice_ufrag, _cam_ice_pwd, (_c_ip, _c_port),
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
- _cam_ice_ufrag, _cam_ice_pwd, (_c_ip, _c_port),
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
- try:
360
- _ctx.set_min_proto_version(_DTLS1_VERSION)
361
- except Exception as _e:
362
- _LOGGER.warning(
363
- "DTLS 1.0 enable failed: %s", _e
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.10.3"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.10.3
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