python-aidot-cameras 0.13.2__tar.gz → 0.15.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 (172) hide show
  1. {python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0}/PKG-INFO +1 -1
  2. python_aidot_cameras-0.15.0/aidot_cameras/ble_gateway.py +564 -0
  3. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/client.py +32 -5
  4. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/constants.py +52 -0
  5. python_aidot_cameras-0.15.0/aidot_cameras/camera/hwaccel.py +447 -0
  6. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/protocol.py +48 -1
  7. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes.py +31 -0
  8. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes_open.py +254 -16
  9. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc_open.py +35 -9
  10. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/client.py +6 -2
  11. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/pyproject.toml +16 -1
  12. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  13. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/SOURCES.txt +13 -0
  14. python_aidot_cameras-0.15.0/tests/test_answer_sdp_video_pt.py +181 -0
  15. python_aidot_cameras-0.15.0/tests/test_audio_mux_offline.py +147 -0
  16. python_aidot_cameras-0.15.0/tests/test_ble_gateway.py +393 -0
  17. python_aidot_cameras-0.15.0/tests/test_env_seams.py +156 -0
  18. python_aidot_cameras-0.15.0/tests/test_hwaccel_probe.py +303 -0
  19. python_aidot_cameras-0.15.0/tests/test_media_wait_floor.py +38 -0
  20. python_aidot_cameras-0.15.0/tests/test_restart_cycle.py +103 -0
  21. python_aidot_cameras-0.15.0/tests/test_scripts_importable.py +50 -0
  22. python_aidot_cameras-0.15.0/tests/test_sdes_terminal_ack_latency.py +138 -0
  23. python_aidot_cameras-0.15.0/tests/test_sdp_narrowing_properties.py +133 -0
  24. python_aidot_cameras-0.15.0/tests/test_sprop_staleness.py +83 -0
  25. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/LICENSE +0 -0
  26. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/MANIFEST.in +0 -0
  27. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/README.md +0 -0
  28. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/__init__.py +0 -0
  29. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/__main__.py +0 -0
  30. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_upstream.py +0 -0
  31. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/__init__.py +0 -0
  32. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  33. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  34. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  35. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  36. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  37. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  38. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  39. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  40. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  41. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  42. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  43. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  44. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  45. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  46. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  47. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  48. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  49. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  50. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  51. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  52. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  53. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  54. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  55. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  56. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  57. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  58. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  59. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  60. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  61. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  62. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  63. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  64. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/__init__.py +0 -0
  65. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/controls.py +0 -0
  66. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/go2rtc.py +0 -0
  67. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/lan_control.py +0 -0
  68. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/models.py +0 -0
  69. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/playback.py +0 -0
  70. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/tutk.py +0 -0
  71. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc.py +0 -0
  72. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/const.py +0 -0
  73. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/credentials.py +0 -0
  74. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/crypto.py +0 -0
  75. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/device_client.py +0 -0
  76. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/discover.py +0 -0
  77. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/exceptions.py +0 -0
  78. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/g711.py +0 -0
  79. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/py.typed +0 -0
  80. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  81. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  82. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  83. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  84. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/setup.cfg +0 -0
  85. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_aioice_compat.py +0 -0
  86. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_alarm_event.py +0 -0
  87. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_backoff.py +0 -0
  88. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_detection.py +0 -0
  89. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_evidence_corroboration.py +0 -0
  90. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_skip_turn.py +0 -0
  91. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_constants_imports_resolve.py +0 -0
  92. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_deferred_hardening.py +0 -0
  93. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_device_login_guard.py +0 -0
  94. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_device_user_info_cache.py +0 -0
  95. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dormant_camera_cleanup.py +0 -0
  96. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_mux_terminal_write.py +0 -0
  97. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_not_ready_burst.py +0 -0
  98. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_pinning.py +0 -0
  99. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_decode_skip.py +0 -0
  100. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_open_timeout.py +0 -0
  101. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  102. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_slow_probe.py +0 -0
  103. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_egress_guard.py +0 -0
  104. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_go2rtc.py +0 -0
  105. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_go2rtc_cli.py +0 -0
  106. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_h264_decode_rate_limit.py +0 -0
  107. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_highport_nomination.py +0 -0
  108. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_ice_config_cache.py +0 -0
  109. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_ice_peer_reflexive.py +0 -0
  110. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_idle_release_both_loops.py +0 -0
  111. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_keepalive_renew.py +0 -0
  112. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_key_restart_sdp.py +0 -0
  113. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_keyframe_prompter.py +0 -0
  114. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_lan_control.py +0 -0
  115. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_light_active_color_mode.py +0 -0
  116. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_live_stream_param.py +0 -0
  117. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_live_validate_policy.py +0 -0
  118. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_logging_caps.py +0 -0
  119. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_motion_poll.py +0 -0
  120. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_refetch.py +0 -0
  121. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_self_heal.py +0 -0
  122. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_password_not_persisted.py +0 -0
  123. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_session_reconnect.py +0 -0
  124. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_narrow_pc_ice.py +0 -0
  125. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_no_self_referential_go2rtc.py +0 -0
  126. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_no_undefined_names.py +0 -0
  127. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_offline_keepalive_pause.py +0 -0
  128. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_open_fail_logger.py +0 -0
  129. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_open_gate_delay.py +0 -0
  130. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_peerid_reuse.py +0 -0
  131. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt.py +0 -0
  132. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  133. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_playback_tls.py +0 -0
  134. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_post_merge_hardening.py +0 -0
  135. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_retry_policy.py +0 -0
  136. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_adaptive.py +0 -0
  137. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_bridge_break_gate.py +0 -0
  138. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  139. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_fast_liveplay.py +0 -0
  140. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  141. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_idle_release.py +0 -0
  142. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_open_cleanup.py +0 -0
  143. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_audio.py +0 -0
  144. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_cmd.py +0 -0
  145. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_sprop.py +0 -0
  146. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_talk.py +0 -0
  147. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_teardown_exit_log.py +0 -0
  148. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_terminal_ack.py +0 -0
  149. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_watchdog.py +0 -0
  150. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
  151. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_security_hardening.py +0 -0
  152. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_serializable_login_info.py +0 -0
  153. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_serve_relay.py +0 -0
  154. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_session_stats.py +0 -0
  155. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_shared_house_seam.py +0 -0
  156. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_speak.py +0 -0
  157. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_cap.py +0 -0
  158. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_drain_ownership.py +0 -0
  159. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_idle.py +0 -0
  160. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_slot_cap.py +0 -0
  161. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_teardown.py +0 -0
  162. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_talk.py +0 -0
  163. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_terminal_ack.py +0 -0
  164. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_token_file_robustness.py +0 -0
  165. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_token_refresh.py +0 -0
  166. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_turn_create_permission.py +0 -0
  167. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_upstream_compat.py +0 -0
  168. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_check_throttle.py +0 -0
  169. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_presence.py +0 -0
  170. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_present_push_mode.py +0 -0
  171. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_wait_or_event.py +0 -0
  172. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_wake_readiness_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.13.2
3
+ Version: 0.15.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
@@ -0,0 +1,564 @@
1
+ """Control for BLE-mesh devices that sit behind an AiDot mesh hub.
2
+
3
+ A BLE-mesh bulb has no IP of its own: it is reached by talking to its **hub**
4
+ (``type == "BleMesh_Hub"``) over the hub's local TCP:10000 control channel and
5
+ naming the child in the payload. The wire format is the one
6
+ :mod:`aidot_cameras.camera.lan_control` already speaks - 8-byte header
7
+ (``magic 0x1EED`` + ``msgtype:int16`` + ``bodysize:int32``) over an AES-ECB JSON
8
+ body - with three differences:
9
+
10
+ * the socket, ``aesKey`` and ``password`` are the **hub's**, not the child's;
11
+ * ``payload.parentId`` is the hub id while ``payload.devId`` / ``deviceId`` name
12
+ the child;
13
+ * ``payload.channel`` is ``"ble"`` rather than ``"tcp"``, which is what tells the
14
+ hub to relay the command onto the mesh.
15
+
16
+ **Session policy.** The camera client opens a short-lived session per command
17
+ because that channel evicts an existing login on the next ``loginReq``. A hub
18
+ multiplexes every bulb behind it, so paying connect+login per command shows up
19
+ directly as slider lag. This module keeps one lock-serialised connection per hub
20
+ and closes it after :data:`_IDLE_CLOSE_S` of silence - bursts (a brightness drag)
21
+ reuse one login, and an idle hub is left free for the app rather than being held
22
+ indefinitely.
23
+
24
+ Nothing here is Home Assistant specific: it is the hub-relay transport plus the
25
+ cloud-record predicates needed to recognise which devices use it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import ctypes
32
+ import json
33
+ import logging
34
+ import time
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime
37
+ from typing import Any, Optional
38
+
39
+ # Single source of truth for the local control wire format - the hub relay is the
40
+ # same framing as the direct-to-device channel, so it reuses those helpers rather
41
+ # than restating the header layout (and its frame-size cap) a second time.
42
+ from .camera.lan_control import _pack, _read_frame
43
+ from .crypto import aes_decrypt, aes_encrypt
44
+
45
+ _LOGGER = logging.getLogger(__name__)
46
+
47
+ _CONTROL_PORT = 10000
48
+ _CONNECT_TIMEOUT = 8.0
49
+ _READ_TIMEOUT = 8.0
50
+ _MAX_RETRIES = 3
51
+ _BACKOFF_S = 0.5
52
+ # How long a hub connection is kept warm after the last command. Long enough to
53
+ # coalesce a slider drag, short enough that an idle hub is not held against the
54
+ # phone app.
55
+ _IDLE_CLOSE_S = 5.0
56
+
57
+ BLE_MESH_HUB_TYPE = "BleMesh_Hub"
58
+
59
+
60
+ class BleMeshError(Exception):
61
+ """A hub-relayed control operation failed (connect, login, or protocol)."""
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Cloud-record predicates
66
+ # --------------------------------------------------------------------------- #
67
+
68
+
69
+ def find_ble_mesh_hubs(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
70
+ """Return ``{hub_id: device}`` for every BLE-mesh hub in a cloud device list."""
71
+ return {
72
+ d["id"]: d
73
+ for d in devices
74
+ if d.get("type") == BLE_MESH_HUB_TYPE and d.get("id")
75
+ }
76
+
77
+
78
+ def hub_id_of(device: dict[str, Any]) -> str:
79
+ """Return the id of the hub a device sits behind, or ``""`` if it is direct.
80
+
81
+ The cloud record is not consistent about which field carries this:
82
+ ``directGateway`` is the documented one, but ``parentId`` is populated on some
83
+ firmware. A device that is not proxied reports both as ``""`` and sets
84
+ ``directId`` to its own id, so an empty result here means "talks for itself".
85
+ """
86
+ for key in ("directGateway", "parentId"):
87
+ value = device.get(key)
88
+ if value and value != device.get("id"):
89
+ return str(value)
90
+ return ""
91
+
92
+
93
+ def is_ble_mesh_child(
94
+ device: dict[str, Any], hubs: dict[str, dict[str, Any]]
95
+ ) -> bool:
96
+ """True when ``device`` must be driven through one of ``hubs``.
97
+
98
+ Requires both that the record points at a known hub **and** that it carries
99
+ mesh addressing (``bleMeshDeviceKey`` / ``bleMeshAddr``). The corroboration
100
+ matters: a hub id alone also appears on Zigbee children and on records that
101
+ merely share a house, and building a mesh client for one of those produces a
102
+ device that accepts commands and silently controls nothing.
103
+ """
104
+ if hub_id_of(device) not in hubs:
105
+ return False
106
+ return bool(device.get("bleMeshDeviceKey")) or bool(device.get("bleMeshAddr"))
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # Device state
111
+ # --------------------------------------------------------------------------- #
112
+
113
+
114
+ @dataclass
115
+ class BleMeshDeviceInfo:
116
+ """Static description of a mesh device, derived from its cloud record."""
117
+
118
+ dev_id: str
119
+ hub_id: str
120
+ model_id: str
121
+ mac: str
122
+ name: str
123
+ hw_version: Optional[str] = None
124
+ enable_rgbw: bool = False
125
+ enable_cct: bool = False
126
+ enable_dimming: bool = False
127
+ cct_min: int = 2700
128
+ cct_max: int = 6500
129
+
130
+
131
+ @dataclass
132
+ class BleMeshDeviceStatus:
133
+ """Last-known state of a mesh device.
134
+
135
+ Mesh children do not push status - the hub acks a command and says nothing
136
+ afterwards - so these values are seeded from the cloud record and then
137
+ advanced optimistically by each accepted command.
138
+ """
139
+
140
+ online: bool = True
141
+ on: bool = False
142
+ dimming: int = 255
143
+ cct: int = 2700
144
+ rgbw: tuple[int, int, int, int] = field(default_factory=lambda: (255, 255, 255, 0))
145
+
146
+
147
+ def _unpack_rgbw(raw: Any) -> tuple[int, int, int, int]:
148
+ """Split a packed RGBW int into components.
149
+
150
+ The cloud sends this as a *signed* 32-bit value, so any colour with the red
151
+ channel above 0x7F arrives negative; masking through ``c_uint32`` first is
152
+ what keeps those from shifting into nonsense.
153
+ """
154
+ packed = ctypes.c_uint32(int(raw or 0)).value
155
+ return (
156
+ (packed >> 24) & 0xFF,
157
+ (packed >> 16) & 0xFF,
158
+ (packed >> 8) & 0xFF,
159
+ packed & 0xFF,
160
+ )
161
+
162
+
163
+ def _pack_rgbw(rgbw: tuple[int, int, int, int]) -> int:
164
+ """Pack RGBW components into the signed 32-bit int the wire format expects."""
165
+ packed = (
166
+ ((rgbw[0] & 0xFF) << 24)
167
+ | ((rgbw[1] & 0xFF) << 16)
168
+ | ((rgbw[2] & 0xFF) << 8)
169
+ | (rgbw[3] & 0xFF)
170
+ )
171
+ return ctypes.c_int32(packed).value
172
+
173
+
174
+ def _service_modules(device: dict[str, Any]) -> list[dict[str, Any]]:
175
+ return (device.get("product") or {}).get("serviceModules") or []
176
+
177
+
178
+ def _cct_range(device: dict[str, Any]) -> tuple[int, int]:
179
+ """Read the device's CCT limits, falling back to the common 2700-6500 K."""
180
+ cct_min, cct_max = 2700, 6500
181
+ for module in _service_modules(device):
182
+ if (module.get("identity") or "").lower() != "control.light.cct":
183
+ continue
184
+ for prop in module.get("properties") or []:
185
+ if (prop.get("identity") or "").upper() != "CCT":
186
+ continue
187
+ try:
188
+ cct_min = int(prop.get("minValue", cct_min))
189
+ cct_max = int(prop.get("maxValue", cct_max))
190
+ except (TypeError, ValueError):
191
+ pass
192
+ return cct_min, cct_max
193
+
194
+
195
+ # --------------------------------------------------------------------------- #
196
+ # Hub session
197
+ # --------------------------------------------------------------------------- #
198
+
199
+
200
+ class BleMeshHubSession:
201
+ """One lock-serialised, idle-closed connection to a mesh hub.
202
+
203
+ Every child behind a hub shares this object: the hub resets the socket when a
204
+ second login lands, so commands must be serialised rather than merely
205
+ connection-pooled. A dropped connection is retried, which also covers the
206
+ case where the hub closed an idle socket before our own timer fired.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ hub_id: str,
212
+ hub_ip: str,
213
+ aes_key: bytes,
214
+ user_id: str,
215
+ hub_password: str,
216
+ idle_close_s: float = _IDLE_CLOSE_S,
217
+ ) -> None:
218
+ self.hub_id = hub_id
219
+ self.hub_ip = hub_ip
220
+ self._key = aes_key
221
+ self._user_id = user_id
222
+ self._password = hub_password
223
+ self._idle_close_s = idle_close_s
224
+ self._lock = asyncio.Lock()
225
+ self._reader: Optional[asyncio.StreamReader] = None
226
+ self._writer: Optional[asyncio.StreamWriter] = None
227
+ self._asc = 1
228
+ self._idle_task: Optional[asyncio.Task] = None
229
+
230
+ async def async_send_attributes(self, dev_id: str, attr: dict[str, Any]) -> bool:
231
+ """Relay one ``setDevAttrReq`` to ``dev_id``; True on a ``setDevAttrResp``."""
232
+ async with self._lock:
233
+ self._cancel_idle_timer()
234
+ try:
235
+ for attempt in range(_MAX_RETRIES):
236
+ try:
237
+ await self._ensure_connected()
238
+ return await self._set_attr(dev_id, attr)
239
+ except (
240
+ OSError,
241
+ TimeoutError,
242
+ asyncio.IncompleteReadError,
243
+ ) as exc:
244
+ await self._close()
245
+ if attempt == _MAX_RETRIES - 1:
246
+ raise BleMeshError(
247
+ f"{self.hub_id}: relay to {dev_id} failed: {exc}"
248
+ ) from exc
249
+ await asyncio.sleep(_BACKOFF_S * (attempt + 1))
250
+ raise BleMeshError(f"{self.hub_id}: relay to {dev_id} exhausted retries")
251
+ finally:
252
+ self._start_idle_timer()
253
+
254
+ # -- connection lifecycle ---------------------------------------------- #
255
+
256
+ async def _ensure_connected(self) -> None:
257
+ if self._writer is None or self._writer.is_closing():
258
+ await self._connect_and_login()
259
+
260
+ async def _connect_and_login(self) -> None:
261
+ self._reader, self._writer = await asyncio.wait_for(
262
+ asyncio.open_connection(self.hub_ip, _CONTROL_PORT),
263
+ timeout=_CONNECT_TIMEOUT,
264
+ )
265
+ msg = {
266
+ "service": "device",
267
+ "method": "loginReq",
268
+ "seq": str(int(time.time() * 1000))[-9:],
269
+ "srcAddr": self._user_id,
270
+ "deviceId": self.hub_id,
271
+ "payload": {
272
+ "userId": self._user_id,
273
+ "password": self._password,
274
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S.000"),
275
+ "ascNumber": 1,
276
+ },
277
+ }
278
+ self._writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
279
+ await self._writer.drain()
280
+ try:
281
+ resp = json.loads(
282
+ aes_decrypt(await _read_frame(self._reader, _READ_TIMEOUT), self._key)
283
+ )
284
+ except Exception as exc:
285
+ await self._close()
286
+ # A host that cannot answer with something our hub key decrypts is not
287
+ # the hub. Surfacing that distinctly matters because the hub's IP comes
288
+ # from an unauthenticated cloud "properties" field that can go stale
289
+ # onto whatever else now holds the address.
290
+ raise BleMeshError(
291
+ f"{self.hub_id}: login response undecryptable (wrong host?)"
292
+ ) from exc
293
+ ack = (resp.get("ack") or {}).get("code")
294
+ if ack not in (None, 200):
295
+ await self._close()
296
+ raise BleMeshError(f"{self.hub_id}: login rejected ack={ack}")
297
+ self._asc = (resp.get("payload") or {}).get("ascNumber", 1) + 1
298
+
299
+ async def _set_attr(self, dev_id: str, attr: dict[str, Any]) -> bool:
300
+ assert self._writer is not None and self._reader is not None
301
+ msg = {
302
+ "method": "setDevAttrReq",
303
+ "service": "device",
304
+ "clientId": "ha-" + self._user_id,
305
+ "srcAddr": "0." + self._user_id,
306
+ "seq": "b" + str(int(time.time() * 1000))[-9:],
307
+ "payload": {
308
+ "devId": dev_id,
309
+ "parentId": self.hub_id,
310
+ "userId": self._user_id,
311
+ "password": self._password,
312
+ "attr": attr,
313
+ "channel": "ble",
314
+ "ascNumber": self._asc,
315
+ },
316
+ "tst": int(time.time() * 1000),
317
+ "deviceId": dev_id,
318
+ }
319
+ self._asc += 1
320
+ self._writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
321
+ await self._writer.drain()
322
+ # The hub may emit an unrelated frame (a status echo for another child on
323
+ # the mesh) before our ack, so read until the response method arrives.
324
+ for _ in range(4):
325
+ frame = json.loads(
326
+ aes_decrypt(await _read_frame(self._reader, _READ_TIMEOUT), self._key)
327
+ )
328
+ method = str(frame.get("method") or "")
329
+ if method == "setDevAttrResp":
330
+ return True
331
+ if method.endswith("Resp"):
332
+ return False
333
+ return False
334
+
335
+ def _start_idle_timer(self) -> None:
336
+ if self._writer is None or self._idle_close_s <= 0:
337
+ return
338
+ loop = asyncio.get_running_loop()
339
+ self._idle_task = loop.create_task(self._idle_close())
340
+
341
+ def _cancel_idle_timer(self) -> None:
342
+ task = self._idle_task
343
+ self._idle_task = None
344
+ if task is not None and not task.done():
345
+ task.cancel()
346
+
347
+ async def _idle_close(self) -> None:
348
+ try:
349
+ await asyncio.sleep(self._idle_close_s)
350
+ except asyncio.CancelledError:
351
+ return
352
+ # Take the lock so this can never close a socket mid-command.
353
+ async with self._lock:
354
+ if self._idle_task is not None:
355
+ await self._close()
356
+
357
+ async def async_close(self) -> None:
358
+ """Close the hub connection and stop the idle timer."""
359
+ self._cancel_idle_timer()
360
+ async with self._lock:
361
+ await self._close()
362
+
363
+ async def _close(self) -> None:
364
+ writer, self._writer, self._reader = self._writer, None, None
365
+ if writer is None:
366
+ return
367
+ try:
368
+ writer.close()
369
+ await writer.wait_closed()
370
+ except Exception: # teardown is best-effort
371
+ pass
372
+
373
+
374
+ _HUB_SESSIONS: dict[str, BleMeshHubSession] = {}
375
+
376
+
377
+ def get_hub_session(
378
+ hub_id: str, hub_ip: str, aes_key: bytes, user_id: str, hub_password: str
379
+ ) -> BleMeshHubSession:
380
+ """Return the process-wide session for a hub, creating it on first use.
381
+
382
+ Keyed by hub id; a changed IP (DHCP move) replaces the session rather than
383
+ reusing one pointed at the old address.
384
+ """
385
+ session = _HUB_SESSIONS.get(hub_id)
386
+ if session is None or session.hub_ip != hub_ip:
387
+ session = BleMeshHubSession(hub_id, hub_ip, aes_key, user_id, hub_password)
388
+ _HUB_SESSIONS[hub_id] = session
389
+ return session
390
+
391
+
392
+ async def close_all_hub_sessions() -> None:
393
+ """Close every open hub connection and clear the registry (call on unload)."""
394
+ sessions = list(_HUB_SESSIONS.values())
395
+ _HUB_SESSIONS.clear()
396
+ for session in sessions:
397
+ await session.async_close()
398
+
399
+
400
+ # --------------------------------------------------------------------------- #
401
+ # Per-device client
402
+ # --------------------------------------------------------------------------- #
403
+
404
+
405
+ class BleMeshGatewayClient:
406
+ """Drives one BLE-mesh light by relaying commands through its hub.
407
+
408
+ Shaped to match the attribute surface the device clients expose (``info``,
409
+ ``status``, ``on_status_update``) so a consumer can treat it as one more
410
+ client flavour rather than a special case.
411
+ """
412
+
413
+ def __init__(
414
+ self,
415
+ device: dict[str, Any],
416
+ hub_device: dict[str, Any],
417
+ user_id: str,
418
+ ) -> None:
419
+ self.device_id: str = device.get("id") or ""
420
+ if not self.device_id:
421
+ raise BleMeshError("mesh device record has no id")
422
+
423
+ self._hub_id: str = hub_device.get("id") or ""
424
+ self._hub_ip: str = (hub_device.get("properties") or {}).get("ipAddress") or ""
425
+ if not self._hub_id:
426
+ raise BleMeshError(f"{self.device_id}: hub record has no id")
427
+ if not self._hub_ip:
428
+ raise BleMeshError(f"{self.device_id}: hub {self._hub_id} has no ipAddress")
429
+
430
+ aes = hub_device.get("aesKey") or []
431
+ key_str = aes[0] if isinstance(aes, list) and aes else (aes or "")
432
+ if not key_str:
433
+ raise BleMeshError(f"{self.device_id}: hub {self._hub_id} has no aesKey")
434
+ key = bytearray(16)
435
+ raw = str(key_str).encode()
436
+ key[: len(raw)] = raw[:16]
437
+ self._key = bytes(key)
438
+ self._hub_password: str = hub_device.get("password") or ""
439
+ self._user_id = user_id
440
+
441
+ modules = {
442
+ (m.get("identity") or "").lower() for m in _service_modules(device)
443
+ }
444
+ cct_min, cct_max = _cct_range(device)
445
+ self.info = BleMeshDeviceInfo(
446
+ dev_id=self.device_id,
447
+ hub_id=self._hub_id,
448
+ model_id=device.get("modelId") or "unknown",
449
+ mac=device.get("mac") or "",
450
+ name=device.get("name") or "",
451
+ hw_version=device.get("hardwareVersion"),
452
+ enable_rgbw="control.light.rgbw" in modules,
453
+ enable_cct="control.light.cct" in modules,
454
+ enable_dimming="control.light.dimming" in modules,
455
+ cct_min=cct_min,
456
+ cct_max=cct_max,
457
+ )
458
+
459
+ props = device.get("properties") or {}
460
+ self.status = BleMeshDeviceStatus(
461
+ online=bool(device.get("online", True)),
462
+ on=str(props.get("OnOff", "0")) == "1",
463
+ # The cloud reports Dimming as 0-100; the client surface is 0-255.
464
+ dimming=_pct_to_255(props.get("Dimming", 100)),
465
+ cct=_as_int(props.get("CCT"), cct_min),
466
+ rgbw=_unpack_rgbw(props.get("RGBW", 0)),
467
+ )
468
+ self.on_status_update: Any = None
469
+
470
+ # -- commands ----------------------------------------------------------- #
471
+
472
+ async def async_set_attributes(self, attr: dict[str, Any]) -> bool:
473
+ """Relay ``attr`` (on-the-wire keys) and advance the optimistic status."""
474
+ session = get_hub_session(
475
+ self._hub_id, self._hub_ip, self._key, self._user_id, self._hub_password
476
+ )
477
+ acked = await session.async_send_attributes(self.device_id, attr)
478
+ if not acked:
479
+ _LOGGER.debug(
480
+ "mesh device %s: hub %s did not ack %s",
481
+ self.device_id,
482
+ self._hub_id,
483
+ sorted(attr),
484
+ )
485
+ return False
486
+ self._apply_optimistic(attr)
487
+ return True
488
+
489
+ def _apply_optimistic(self, attr: dict[str, Any]) -> None:
490
+ """Fold an acked command into ``status``.
491
+
492
+ Mesh children never report back, so this is the only thing that moves
493
+ state between cloud refreshes. It runs *only* after an ack, so a command
494
+ the hub dropped does not leave a state the device never reached.
495
+ """
496
+ if "OnOff" in attr:
497
+ self.status.on = bool(attr["OnOff"])
498
+ if "Dimming" in attr:
499
+ self.status.dimming = _pct_to_255(attr["Dimming"])
500
+ if "CCT" in attr:
501
+ self.status.cct = _as_int(attr["CCT"], self.status.cct)
502
+ if "RGBW" in attr:
503
+ self.status.rgbw = _unpack_rgbw(attr["RGBW"])
504
+ if self.on_status_update:
505
+ self.on_status_update(self.status)
506
+
507
+ async def async_turn_on(self) -> bool:
508
+ return await self.async_set_attributes({"OnOff": 1})
509
+
510
+ async def async_turn_off(self) -> bool:
511
+ return await self.async_set_attributes({"OnOff": 0})
512
+
513
+ async def async_set_brightness(self, brightness: int) -> bool:
514
+ """Set brightness from a 0-255 value (the wire field is 0-100)."""
515
+ return await self.async_set_attributes(
516
+ {"OnOff": 1, "Dimming": _255_to_pct(brightness)}
517
+ )
518
+
519
+ async def async_set_cct(self, cct: int) -> bool:
520
+ clamped = max(self.info.cct_min, min(self.info.cct_max, int(cct)))
521
+ return await self.async_set_attributes({"OnOff": 1, "CCT": clamped})
522
+
523
+ async def async_set_rgbw(self, rgbw: tuple[int, int, int, int]) -> bool:
524
+ return await self.async_set_attributes(
525
+ {"OnOff": 1, "RGBW": _pack_rgbw(rgbw)}
526
+ )
527
+
528
+ def update_status_from_device(self, device: dict[str, Any]) -> None:
529
+ """Re-seed status from a fresh cloud record (the only inbound signal)."""
530
+ props = device.get("properties") or {}
531
+ self.status.online = bool(device.get("online", self.status.online))
532
+ if "OnOff" in props:
533
+ self.status.on = str(props["OnOff"]) == "1"
534
+ if "Dimming" in props:
535
+ self.status.dimming = _pct_to_255(props["Dimming"])
536
+ if "CCT" in props:
537
+ self.status.cct = _as_int(props["CCT"], self.status.cct)
538
+ if "RGBW" in props:
539
+ self.status.rgbw = _unpack_rgbw(props["RGBW"])
540
+ if self.on_status_update:
541
+ self.on_status_update(self.status)
542
+
543
+
544
+ def _as_int(value: Any, default: int) -> int:
545
+ try:
546
+ return int(value)
547
+ except (TypeError, ValueError):
548
+ return default
549
+
550
+
551
+ def _pct_to_255(value: Any) -> int:
552
+ """0-100 wire percentage -> 0-255, clamped."""
553
+ pct = max(0, min(100, _as_int(value, 100)))
554
+ return round(pct * 255 / 100)
555
+
556
+
557
+ def _255_to_pct(value: Any) -> int:
558
+ """0-255 -> 0-100 wire percentage, clamped to a minimum of 1.
559
+
560
+ Rounding 1-2/255 down to 0% would send "off" on a brightness command, which
561
+ reads as the light ignoring the slider.
562
+ """
563
+ raw = max(0, min(255, _as_int(value, 255)))
564
+ return max(1, round(raw * 100 / 255))
@@ -22,9 +22,11 @@ from ..const import (
22
22
  from .constants import (
23
23
  _LEEDARSON_APP_ID,
24
24
  _LIVE_PLAY_NOT_READY,
25
- _SMARTHOME_URL_TEMPLATE,
25
+ _SMARTHOME_URL_TEMPLATE, # noqa: F401 - re-exported for back-compat; reads go through smarthome_base()
26
26
  GETSTREAMCTRL_CMD, # noqa: F401 - re-exported for back-compat (public pair; unused in-module)
27
27
  SDES_SPEAKERSTART_DELAY, # noqa: F401 - used by the sdes_open mixin; kept re-exported
28
+ aidot_api_base,
29
+ smarthome_base,
28
30
  )
29
31
  from .constants import ( # noqa: F401 - re-exported for device_client / back-compat
30
32
  TALK_PCM_FRAME_BYTES,
@@ -372,6 +374,7 @@ def _build_sdes_serve_cmd(
372
374
  sdes_audio: bool = False,
373
375
  audio_gain_db: float = -8.0,
374
376
  push_video_only: bool = False,
377
+ video_decoder: Optional[list] = None,
375
378
  ) -> list:
376
379
  """Build the ffmpeg argv for the SDES bridge serve.
377
380
 
@@ -445,10 +448,25 @@ def _build_sdes_serve_cmd(
445
448
  dest_args = ["-c", "copy", *time_args, output_path]
446
449
  else:
447
450
  dest_args = [*time_args, "-f", "null", "/dev/null"]
451
+
452
+ # Decoder selection applies to the ``-f null`` drain alone - it is the only
453
+ # destination that decodes; every other one is ``-c copy``. The caller
454
+ # passes ready-made input arguments, either ``["-hwaccel", m]`` or
455
+ # ``["-c:v", name]``, and they must sit BEFORE ``-i``: after ``-i`` ffmpeg
456
+ # reads ``-c:v`` as an ENCODER, and ``-hwaccel`` is an input option that has
457
+ # no meaning there at all. Empty or None means ffmpeg chooses, as always. Keep the decode: it is what proves frames are not merely
458
+ # arriving but decodable, which is what catches a parameter-set mismatch
459
+ # that otherwise presents as a permanently black picture.
460
+ decode_args = (
461
+ list(video_decoder)
462
+ if video_decoder and not rtsp_push_url and not output_path
463
+ else []
464
+ )
448
465
  return [
449
466
  "ffmpeg", "-y",
450
467
  "-loglevel", "warning",
451
468
  "-protocol_whitelist", "file,rtp,udp,srtp",
469
+ *decode_args,
452
470
  # 2 s analyzeduration: the camera sends SPS+PPS+IDR in the first burst;
453
471
  # 15 s consumed nearly all packets during analysis. PLI re-arms an IDR
454
472
  # every 5 s so parameters always re-arrive.
@@ -1156,7 +1174,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1156
1174
 
1157
1175
  @property
1158
1176
  def _smarthome_base(self) -> str:
1159
- return _SMARTHOME_URL_TEMPLATE.format(region=self._region)
1177
+ return smarthome_base(self._region)
1160
1178
 
1161
1179
  def set_token_refresh_cb(self, cb: "Callable") -> None:
1162
1180
  """Register the AidotClient coroutine that refreshes the access token."""
@@ -1371,6 +1389,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1371
1389
  if self._mqtt_url:
1372
1390
  return self._mqtt_url
1373
1391
 
1392
+ # Test seam: point the whole client at a local broker without any cloud
1393
+ # call (paho derives ws-vs-wss and the port from the scheme, so a plain
1394
+ # "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
1395
+ _env_mqtt = os.environ.get("AIDOT_MQTT_URL")
1396
+ if _env_mqtt:
1397
+ _LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
1398
+ self._mqtt_url = _env_mqtt
1399
+ return self._mqtt_url
1400
+
1374
1401
  import aiohttp
1375
1402
 
1376
1403
  async def _fetch():
@@ -1603,11 +1630,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
1603
1630
 
1604
1631
  @property
1605
1632
  def _aidot_v21_base(self) -> str:
1606
- return f"https://prod-{self._region}-api.arnoo.com/v21"
1633
+ return f"{aidot_api_base(self._region)}/v21"
1607
1634
 
1608
1635
  @property
1609
1636
  def _aidot_v32_base(self) -> str:
1610
- return f"https://prod-{self._region}-api.arnoo.com/v32/api/ipc"
1637
+ return f"{aidot_api_base(self._region)}/v32/api/ipc"
1611
1638
 
1612
1639
  def _aidot_headers(self) -> dict:
1613
1640
  # Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
@@ -4847,7 +4874,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
4847
4874
  return None
4848
4875
 
4849
4876
  url = (
4850
- f"https://prod-{self._region}-api.arnoo.com"
4877
+ f"{aidot_api_base(self._region)}"
4851
4878
  f"/v29/api/webrtc/iceConfig?forceRefresh=0"
4852
4879
  )
4853
4880