python-aidot-cameras 0.10.3__tar.gz → 0.11.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.
Files changed (122) hide show
  1. {python_aidot_cameras-0.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.1}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/client.py +93 -10
  3. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/lan_control.py +18 -2
  4. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/playback.py +18 -8
  5. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/sdes_open.py +12 -4
  6. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc_open.py +27 -6
  7. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/pyproject.toml +1 -1
  8. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  9. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  10. python_aidot_cameras-0.11.1/tests/test_deferred_hardening.py +98 -0
  11. python_aidot_cameras-0.11.1/tests/test_offline_keepalive_pause.py +112 -0
  12. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/LICENSE +0 -0
  13. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/MANIFEST.in +0 -0
  14. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/README.md +0 -0
  15. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/__init__.py +0 -0
  16. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/__main__.py +0 -0
  17. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/__init__.py +0 -0
  18. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/__init__.py +0 -0
  19. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/clock.py +0 -0
  20. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
  21. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/base.py +0 -0
  22. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
  23. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
  24. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
  25. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
  26. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
  27. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
  28. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/media.py +0 -0
  29. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
  30. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/events.py +0 -0
  31. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/exceptions.py +0 -0
  32. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
  33. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/mediastreams.py +0 -0
  34. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rate.py +0 -0
  35. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
  36. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
  37. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
  38. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
  39. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
  40. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
  41. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  42. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
  43. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  44. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
  45. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
  46. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtp.py +0 -0
  47. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/sdp.py +0 -0
  48. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/stats.py +0 -0
  49. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/utils.py +0 -0
  50. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/aes_utils.py +0 -0
  51. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/__init__.py +0 -0
  52. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/constants.py +0 -0
  53. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/controls.py +0 -0
  54. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/go2rtc.py +0 -0
  55. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/models.py +0 -0
  56. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/protocol.py +0 -0
  57. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/sdes.py +0 -0
  58. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/tutk.py +0 -0
  59. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc.py +0 -0
  60. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/client.py +0 -0
  61. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/const.py +0 -0
  62. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/credentials.py +0 -0
  63. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/device_client.py +0 -0
  64. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/discover.py +0 -0
  65. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/exceptions.py +0 -0
  66. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/g711.py +0 -0
  67. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/login_const.py +0 -0
  68. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/__init__.py +0 -0
  69. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/device_client_model.py +0 -0
  70. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/device_model.py +0 -0
  71. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/discover_model.py +0 -0
  72. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/py.typed +0 -0
  73. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  74. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  75. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
  76. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  77. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/setup.cfg +0 -0
  78. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_aioice_compat.py +0 -0
  79. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_alarm_event.py +0 -0
  80. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_backoff.py +0 -0
  81. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_login_guard.py +0 -0
  82. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_user_info_cache.py +0 -0
  83. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_not_ready_burst.py +0 -0
  84. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_pinning.py +0 -0
  85. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  86. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_egress_guard.py +0 -0
  87. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_go2rtc.py +0 -0
  88. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_go2rtc_cli.py +0 -0
  89. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_keyframe_prompter.py +0 -0
  92. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_lan_control.py +0 -0
  93. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_live_stream_param.py +0 -0
  94. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_motion_poll.py +0 -0
  95. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_narrow_pc_ice.py +0 -0
  96. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_no_undefined_names.py +0 -0
  97. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_open_gate_delay.py +0 -0
  98. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt.py +0 -0
  99. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  100. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_playback_tls.py +0 -0
  101. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_post_merge_hardening.py +0 -0
  102. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_retry_policy.py +0 -0
  103. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_adaptive.py +0 -0
  104. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  105. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_fast_liveplay.py +0 -0
  106. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_idle_release.py +0 -0
  107. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_audio.py +0 -0
  108. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_cmd.py +0 -0
  109. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_sprop.py +0 -0
  110. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_talk.py +0 -0
  111. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_watchdog.py +0 -0
  112. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_security_hardening.py +0 -0
  113. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_serve_relay.py +0 -0
  114. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_session_stats.py +0 -0
  115. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_speak.py +0 -0
  116. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_cap.py +0 -0
  117. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_idle.py +0 -0
  118. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_teardown.py +0 -0
  119. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_talk.py +0 -0
  120. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_terminal_ack.py +0 -0
  121. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_token_refresh.py +0 -0
  122. {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.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
3
+ Version: 0.11.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
@@ -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,
@@ -85,6 +86,13 @@ from .protocol import ( # noqa: F401 - re-exported for device_client / back-com
85
86
 
86
87
  _LOGGER = logging.getLogger(__name__)
87
88
 
89
+ # Offline keepalive pause (see CameraMixin._backoff_or_offline_pause): while the
90
+ # cloud explicitly reports a device offline, failed-open retries re-check the
91
+ # flag every RECHECK seconds and only probe a real open every PROBE seconds,
92
+ # instead of consuming an open-gate slot on the normal reconnect cadence.
93
+ _OFFLINE_RECHECK_S = float(os.environ.get("AIDOT_OFFLINE_RECHECK_S", "30"))
94
+ _OFFLINE_PROBE_S = float(os.environ.get("AIDOT_OFFLINE_PROBE_S", "600"))
95
+
88
96
  # Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
89
97
  # discarded task can be garbage-collected mid-flight. Discarded on completion.
90
98
  _BG_TASKS: set = set()
@@ -93,10 +101,34 @@ _BG_TASKS: set = set()
93
101
  def _spawn_bg(coro):
94
102
  _t = asyncio.ensure_future(coro)
95
103
  _BG_TASKS.add(_t)
96
- _t.add_done_callback(_BG_TASKS.discard)
104
+
105
+ def _done(task):
106
+ _BG_TASKS.discard(task)
107
+ # Retrieve the exception so a failing background task doesn't surface as
108
+ # an unhandled "Task exception was never retrieved" at GC time; log it.
109
+ if not task.cancelled():
110
+ exc = task.exception()
111
+ if exc is not None:
112
+ _LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
113
+
114
+ _t.add_done_callback(_done)
97
115
  return _t
98
116
 
99
117
 
118
+ def _mqtt_publish_delivered(status) -> bool:
119
+ """True if an MQTT command reached the broker (connection up, no error).
120
+
121
+ Distinguishes a genuine fire-and-forget publish (broker connected, the device
122
+ simply doesn't ACK) from a refused/failed connection - which returns an empty
123
+ message list too, and must NOT be reported as "sent".
124
+ """
125
+ if not status:
126
+ return True # no status available: preserve prior best-effort behavior
127
+ if status.get("error"):
128
+ return False
129
+ return status.get("connected", True) is not False
130
+
131
+
100
132
  def _retry_policy(failure_kind: str, burst_attempt: int, *,
101
133
  burst_delay: float = 3.0, burst_max: int = 4,
102
134
  base_gate: float = 15.0) -> "tuple[float, bool]":
@@ -736,8 +768,56 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
736
768
  self.status.update_from_camera_attributes(props)
737
769
  if device.get("online") is not None:
738
770
  self.status.online = bool(device.get("online"))
771
+ # The cloud explicitly reported reachability - only then may the
772
+ # keepalive loops trust an offline flag (see
773
+ # _backoff_or_offline_pause; DeviceStatusData.online defaults to
774
+ # False, which must not read as "offline").
775
+ self._cloud_online_explicit = True
739
776
  return self.status
740
777
 
778
+ async def _backoff_or_offline_pause(self, delay: float) -> None:
779
+ """Sleep a reconnect backoff, extended while the device is cloud-offline.
780
+
781
+ Normal case (device online, or the cloud never reported an online
782
+ flag): plain ``asyncio.sleep(delay)`` - behavior unchanged.
783
+
784
+ When an open just failed AND the cloud has explicitly reported the
785
+ device offline (``update_status_from_device`` saw ``online: false``),
786
+ hold here instead of retrying on the pacer cadence: re-check the flag
787
+ every ``AIDOT_OFFLINE_RECHECK_S`` (default 30 s) and resume the moment
788
+ it flips back on, or fall through after ``AIDOT_OFFLINE_PROBE_S``
789
+ (default 600 s) so one probe open still runs in case the cloud flag is
790
+ stale. This keeps a dead/unpowered camera from consuming an open-gate
791
+ slot every backoff cycle and starving live cameras' cold opens
792
+ (observed live: two unpowered A000088s cycling 30 s open attempts
793
+ pushed a healthy camera's cold open past two minutes).
794
+ """
795
+ if self.status.online or not getattr(self, "_cloud_online_explicit", False):
796
+ await asyncio.sleep(delay)
797
+ return
798
+ _LOGGER.info(
799
+ "keepalive[%s]: device is cloud-offline - pausing open retries "
800
+ "(recheck %.0fs, probe %.0fs)",
801
+ self.device_id, _OFFLINE_RECHECK_S, _OFFLINE_PROBE_S,
802
+ )
803
+ _t0 = time.monotonic()
804
+ while (_elapsed := time.monotonic() - _t0) < _OFFLINE_PROBE_S:
805
+ await asyncio.sleep(
806
+ min(_OFFLINE_RECHECK_S, max(0.1, _OFFLINE_PROBE_S - _elapsed))
807
+ )
808
+ if not getattr(self, "_streaming_active", False):
809
+ return
810
+ if self.status.online:
811
+ _LOGGER.info(
812
+ "keepalive[%s]: device back online - resuming opens",
813
+ self.device_id,
814
+ )
815
+ return
816
+ _LOGGER.debug(
817
+ "keepalive[%s]: offline probe interval elapsed - probing one open",
818
+ self.device_id,
819
+ )
820
+
741
821
  # -- Camera helpers ------------------------------------------------------ #
742
822
 
743
823
  @property
@@ -1418,14 +1498,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1418
1498
  "_mqtt_device_cmd: persistent MQTT failed (%s); falling back "
1419
1499
  "to a per-op session for %s", _st.get("error"), device_id,
1420
1500
  )
1421
- messages = await _mqtt_session(
1501
+ messages, _st = await _mqtt_session_with_status(
1422
1502
  mqtt_url, mqtt_user, mqtt_pwd, client_id,
1423
1503
  subscribe_topics=sub_topics,
1424
1504
  publish_items=publish_items,
1425
1505
  duration=timeout,
1426
1506
  )
1427
1507
  else:
1428
- messages = await _mqtt_session(
1508
+ messages, _st = await _mqtt_session_with_status(
1429
1509
  mqtt_url, mqtt_user, mqtt_pwd, client_id,
1430
1510
  subscribe_topics=sub_topics,
1431
1511
  publish_items=publish_items,
@@ -1449,10 +1529,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1449
1529
  _LOGGER.debug("device cmd ack 200 (inner): topic=%s", topic)
1450
1530
  return True
1451
1531
 
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:
1532
+ # Fire-and-forget fallback: the official app uses a delivery callback,
1533
+ # not a device response, so a published command with no 200-ack is
1534
+ # normal. But only treat it as success if the broker connection actually
1535
+ # succeeded - a refused/failed connection returns an empty message list
1536
+ # too, and reporting that as "sent" makes HA show a state change that
1537
+ # never reached the camera.
1538
+ if messages is not None and _mqtt_publish_delivered(_st):
1456
1539
  _LOGGER.debug(
1457
1540
  "device cmd: sent (no explicit 200-ack on %s, %d msgs total) "
1458
1541
  "- treating as sent-ok (official app is fire-and-forget)",
@@ -2532,7 +2615,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2532
2615
  self.device_id, _delay, exc,
2533
2616
  )
2534
2617
  try:
2535
- await asyncio.sleep(_delay)
2618
+ await self._backoff_or_offline_pause(_delay)
2536
2619
  except asyncio.CancelledError:
2537
2620
  return
2538
2621
  continue
@@ -2707,7 +2790,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2707
2790
  self.device_id, _delay, exc,
2708
2791
  )
2709
2792
  try:
2710
- await asyncio.sleep(_delay)
2793
+ await self._backoff_or_offline_pause(_delay)
2711
2794
  except asyncio.CancelledError:
2712
2795
  return
2713
2796
  continue
@@ -3203,7 +3286,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3203
3286
  self.device_id, _delay, exc,
3204
3287
  )
3205
3288
  try:
3206
- await asyncio.sleep(_delay)
3289
+ await self._backoff_or_offline_pause(_delay)
3207
3290
  except asyncio.CancelledError:
3208
3291
  return
3209
3292
  continue
@@ -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.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"
@@ -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.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
@@ -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
@@ -90,6 +91,7 @@ tests/test_live_stream_param.py
90
91
  tests/test_motion_poll.py
91
92
  tests/test_narrow_pc_ice.py
92
93
  tests/test_no_undefined_names.py
94
+ tests/test_offline_keepalive_pause.py
93
95
  tests/test_open_gate_delay.py
94
96
  tests/test_persistent_mqtt.py
95
97
  tests/test_persistent_mqtt_robustness.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
@@ -0,0 +1,112 @@
1
+ """Offline keepalive pause: failed-open retries hold while the device is
2
+ cloud-offline (CameraMixin._backoff_or_offline_pause) instead of consuming an
3
+ open-gate slot on the normal reconnect cadence."""
4
+
5
+ import asyncio
6
+ import time
7
+ from types import SimpleNamespace
8
+
9
+ import aidot.camera.client as camera_client
10
+ from aidot.camera.client import CameraMixin
11
+ from aidot.device_client import DeviceStatusData
12
+
13
+
14
+ def _stub(online: bool, explicit: bool, streaming: bool = True) -> SimpleNamespace:
15
+ s = SimpleNamespace(
16
+ status=DeviceStatusData(),
17
+ device_id="test-device",
18
+ _streaming_active=streaming,
19
+ )
20
+ s.status.online = online
21
+ if explicit:
22
+ s._cloud_online_explicit = True
23
+ return s
24
+
25
+
26
+ def _run(coro):
27
+ return asyncio.run(coro)
28
+
29
+
30
+ def test_online_device_sleeps_plain_delay():
31
+ stub = _stub(online=True, explicit=True)
32
+ t0 = time.monotonic()
33
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
34
+ took = time.monotonic() - t0
35
+ assert 0.04 <= took < 1.0
36
+
37
+
38
+ def test_unreported_online_state_never_pauses(monkeypatch):
39
+ # DeviceStatusData.online defaults to False; without the explicit marker
40
+ # that must NOT read as "offline" (a long pause here would slow every
41
+ # camera whose device dict was never merged).
42
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 60.0)
43
+ stub = _stub(online=False, explicit=False)
44
+ t0 = time.monotonic()
45
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
46
+ took = time.monotonic() - t0
47
+ assert took < 1.0
48
+
49
+
50
+ def test_offline_device_holds_then_probes(monkeypatch):
51
+ # Explicitly offline: the pause holds past the plain delay and falls
52
+ # through after the probe interval so one probe open still runs.
53
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
54
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 0.3)
55
+ stub = _stub(online=False, explicit=True)
56
+ t0 = time.monotonic()
57
+ _run(CameraMixin._backoff_or_offline_pause(stub, 0.01))
58
+ took = time.monotonic() - t0
59
+ assert took >= 0.3 # held for the whole probe interval
60
+ assert took < 2.0
61
+
62
+
63
+ def test_offline_device_resumes_when_back_online(monkeypatch):
64
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
65
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
66
+ stub = _stub(online=False, explicit=True)
67
+
68
+ async def scenario():
69
+ async def flip():
70
+ await asyncio.sleep(0.15)
71
+ stub.status.online = True
72
+
73
+ t0 = time.monotonic()
74
+ await asyncio.gather(
75
+ CameraMixin._backoff_or_offline_pause(stub, 0.01), flip()
76
+ )
77
+ return time.monotonic() - t0
78
+
79
+ took = _run(scenario())
80
+ # resumed within a couple of recheck ticks of the flip, far short of PROBE
81
+ assert 0.15 <= took < 2.0
82
+
83
+
84
+ def test_offline_pause_exits_when_streaming_stops(monkeypatch):
85
+ monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
86
+ monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
87
+ stub = _stub(online=False, explicit=True)
88
+
89
+ async def scenario():
90
+ async def stop():
91
+ await asyncio.sleep(0.15)
92
+ stub._streaming_active = False
93
+
94
+ t0 = time.monotonic()
95
+ await asyncio.gather(
96
+ CameraMixin._backoff_or_offline_pause(stub, 0.01), stop()
97
+ )
98
+ return time.monotonic() - t0
99
+
100
+ took = _run(scenario())
101
+ assert took < 2.0
102
+
103
+
104
+ def test_update_status_sets_explicit_marker():
105
+ stub = SimpleNamespace(status=DeviceStatusData())
106
+ CameraMixin.update_status_from_device(stub, {"online": False})
107
+ assert stub.status.online is False
108
+ assert getattr(stub, "_cloud_online_explicit", False) is True
109
+
110
+ stub2 = SimpleNamespace(status=DeviceStatusData())
111
+ CameraMixin.update_status_from_device(stub2, {"name": "no online key"})
112
+ assert getattr(stub2, "_cloud_online_explicit", False) is False