python-aidot-cameras 0.12.17__tar.gz → 0.13.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 (159) hide show
  1. {python_aidot_cameras-0.12.17/python_aidot_cameras.egg-info → python_aidot_cameras-0.13.1}/PKG-INFO +2 -2
  2. python_aidot_cameras-0.13.1/aidot_cameras/_upstream.py +396 -0
  3. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/client.py +70 -18
  4. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes_open.py +79 -0
  5. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc_open.py +1 -1
  6. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/client.py +243 -65
  7. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/const.py +16 -3
  8. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/crypto.py +12 -6
  9. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/device_client.py +90 -24
  10. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/discover.py +36 -12
  11. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/pyproject.toml +15 -4
  12. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  13. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/SOURCES.txt +6 -0
  14. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/requires.txt +1 -1
  15. python_aidot_cameras-0.13.1/tests/test_constants_imports_resolve.py +62 -0
  16. python_aidot_cameras-0.13.1/tests/test_ice_peer_reflexive.py +92 -0
  17. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_light_active_color_mode.py +4 -6
  18. python_aidot_cameras-0.13.1/tests/test_live_validate_policy.py +281 -0
  19. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_post_merge_hardening.py +37 -20
  20. python_aidot_cameras-0.13.1/tests/test_shared_house_seam.py +98 -0
  21. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_token_refresh.py +34 -20
  22. python_aidot_cameras-0.13.1/tests/test_upstream_compat.py +568 -0
  23. python_aidot_cameras-0.13.1/tests/test_viewer_present_push_mode.py +236 -0
  24. python_aidot_cameras-0.12.17/tests/test_upstream_compat.py +0 -268
  25. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/LICENSE +0 -0
  26. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/MANIFEST.in +0 -0
  27. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/README.md +0 -0
  28. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/__init__.py +0 -0
  29. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/__main__.py +0 -0
  30. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/__init__.py +0 -0
  31. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  32. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  33. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  34. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  35. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  36. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  37. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  38. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  39. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  40. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  41. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  42. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  43. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  44. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  45. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  46. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  47. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  48. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  49. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  50. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  51. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  52. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  53. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  54. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  55. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  56. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  57. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  58. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  59. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  60. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  61. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  62. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  63. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/__init__.py +0 -0
  64. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/constants.py +0 -0
  65. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/controls.py +0 -0
  66. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/go2rtc.py +0 -0
  67. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/lan_control.py +0 -0
  68. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/models.py +0 -0
  69. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/playback.py +0 -0
  70. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/protocol.py +0 -0
  71. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes.py +0 -0
  72. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/tutk.py +0 -0
  73. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc.py +0 -0
  74. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/credentials.py +0 -0
  75. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/exceptions.py +0 -0
  76. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/g711.py +0 -0
  77. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/py.typed +0 -0
  78. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  79. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  80. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  81. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/setup.cfg +0 -0
  82. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_aioice_compat.py +0 -0
  83. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_alarm_event.py +0 -0
  84. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_backoff.py +0 -0
  85. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_battery_detection.py +0 -0
  86. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_battery_skip_turn.py +0 -0
  87. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_deferred_hardening.py +0 -0
  88. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_device_login_guard.py +0 -0
  89. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_device_user_info_cache.py +0 -0
  90. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dormant_camera_cleanup.py +0 -0
  91. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_mux_terminal_write.py +0 -0
  92. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_not_ready_burst.py +0 -0
  93. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_pinning.py +0 -0
  94. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_decode_skip.py +0 -0
  95. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_open_timeout.py +0 -0
  96. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
  97. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_slow_probe.py +0 -0
  98. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_egress_guard.py +0 -0
  99. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_go2rtc.py +0 -0
  100. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_go2rtc_cli.py +0 -0
  101. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_h264_decode_rate_limit.py +0 -0
  102. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_highport_nomination.py +0 -0
  103. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_ice_config_cache.py +0 -0
  104. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_idle_release_both_loops.py +0 -0
  105. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_keepalive_renew.py +0 -0
  106. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_key_restart_sdp.py +0 -0
  107. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_keyframe_prompter.py +0 -0
  108. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_lan_control.py +0 -0
  109. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_live_stream_param.py +0 -0
  110. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_logging_caps.py +0 -0
  111. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_motion_poll.py +0 -0
  112. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_refetch.py +0 -0
  113. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_self_heal.py +0 -0
  114. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_password_not_persisted.py +0 -0
  115. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_session_reconnect.py +0 -0
  116. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_narrow_pc_ice.py +0 -0
  117. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_no_self_referential_go2rtc.py +0 -0
  118. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_no_undefined_names.py +0 -0
  119. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_offline_keepalive_pause.py +0 -0
  120. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_open_fail_logger.py +0 -0
  121. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_open_gate_delay.py +0 -0
  122. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_peerid_reuse.py +0 -0
  123. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt.py +0 -0
  124. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt_robustness.py +0 -0
  125. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_playback_tls.py +0 -0
  126. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_retry_policy.py +0 -0
  127. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_adaptive.py +0 -0
  128. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_bridge_break_gate.py +0 -0
  129. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
  130. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_fast_liveplay.py +0 -0
  131. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  132. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_idle_release.py +0 -0
  133. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_open_cleanup.py +0 -0
  134. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_audio.py +0 -0
  135. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_cmd.py +0 -0
  136. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_sprop.py +0 -0
  137. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_talk.py +0 -0
  138. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_teardown_exit_log.py +0 -0
  139. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_terminal_ack.py +0 -0
  140. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_watchdog.py +0 -0
  141. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdp_payload_type_narrowing.py +0 -0
  142. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_security_hardening.py +0 -0
  143. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_serializable_login_info.py +0 -0
  144. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_serve_relay.py +0 -0
  145. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_session_stats.py +0 -0
  146. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_speak.py +0 -0
  147. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_cap.py +0 -0
  148. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_drain_ownership.py +0 -0
  149. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_idle.py +0 -0
  150. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_slot_cap.py +0 -0
  151. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_teardown.py +0 -0
  152. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_talk.py +0 -0
  153. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_terminal_ack.py +0 -0
  154. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_token_file_robustness.py +0 -0
  155. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_turn_create_permission.py +0 -0
  156. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_viewer_check_throttle.py +0 -0
  157. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_viewer_presence.py +0 -0
  158. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_wait_or_event.py +0 -0
  159. {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/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.12.17
3
+ Version: 0.13.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
@@ -11,7 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: python-aidot==0.3.55
14
+ Requires-Dist: python-aidot<0.4,>=0.3.55
15
15
  Requires-Dist: aiohttp>=3.9
16
16
  Requires-Dist: paho-mqtt>=1.6.1
17
17
  Requires-Dist: cryptography>=42.0
@@ -0,0 +1,396 @@
1
+ """One place that knows which upstream ``python-aidot`` is installed.
2
+
3
+ Upstream ships two incompatible shapes of the same private API, and both are
4
+ live: Home Assistant core pins ``0.3.56`` while this library was written against
5
+ ``0.3.55``. They are not a forward/backward pair - ``0.3.56`` is a **revert** of
6
+ the refactor that ``0.3.54`` introduced and ``0.3.55`` carried, so the "old"
7
+ shape is also the current one:
8
+
9
+ 0.3.53 dict-based client, ``aes_utils``, ``login_const`` <- shape A
10
+ 0.3.54 typed dataclasses, ``api/``, ``utils/crypto`` <- shape B
11
+ 0.3.55 shape B
12
+ 0.3.56 shape A again (plus the ``models/`` package)
13
+
14
+ Because upstream has flip-flopped once, neither shape can be assumed permanent,
15
+ so both are supported rather than tracked. Every difference is resolved here,
16
+ once, at import time; no other module in this package may branch on the upstream
17
+ version. If you find yourself adding a second ``try: import aidot...`` anywhere
18
+ else, put it here instead.
19
+
20
+ The detection is by capability (does the name import?), never by parsing a
21
+ version string: upstream shipped shape A under both ``0.3.53`` and ``0.3.56``
22
+ with a five-day excursion in between, so a version comparison would encode the
23
+ excursion rather than the shape.
24
+ """
25
+
26
+ from enum import IntEnum
27
+ from typing import Any, Optional
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # Shape detection
31
+ # --------------------------------------------------------------------------- #
32
+
33
+ try: # shape B (0.3.54 - 0.3.55): typed account/device dataclasses + CloudApi
34
+ from aidot.models.auth_model import UserInformation as _UserInformation
35
+ HAS_TYPED_ACCOUNT = True
36
+ except ImportError: # shape A (<=0.3.53, >=0.3.56): plain dicts
37
+ _UserInformation = None
38
+ HAS_TYPED_ACCOUNT = False
39
+
40
+ #: True when upstream exposes the typed account layer (``user_info``,
41
+ #: ``_cloud_api``, ``UserInformation``). Prefer the named helpers below to
42
+ #: branching on this directly.
43
+ UPSTREAM_SHAPE = "typed" if HAS_TYPED_ACCOUNT else "dict"
44
+
45
+ #: Upstream's account record class, or ``None`` on shape A where the account is
46
+ #: a plain dict. Only used as a type annotation and an isinstance guard.
47
+ UserInformation = _UserInformation
48
+
49
+
50
+ # --------------------------------------------------------------------------- #
51
+ # Constants: four moved from ``aidot.const`` to ``aidot.login_const``
52
+ # --------------------------------------------------------------------------- #
53
+
54
+ try: # shape B
55
+ from aidot.const import (
56
+ API_URL_TEMPLATE,
57
+ APP_ID,
58
+ DEFAULT_REGION,
59
+ PUBLIC_KEY_PEM,
60
+ )
61
+ except ImportError: # shape A
62
+ from aidot.login_const import (
63
+ API_URL_TEMPLATE,
64
+ APP_ID,
65
+ DEFAULT_REGION,
66
+ PUBLIC_KEY_PEM,
67
+ )
68
+
69
+
70
+ # --------------------------------------------------------------------------- #
71
+ # Crypto: ``aidot.utils.crypto`` -> ``aidot.aes_utils``
72
+ # --------------------------------------------------------------------------- #
73
+
74
+ try: # shape B
75
+ from aidot.utils.crypto import (
76
+ aes_decrypt,
77
+ aes_decrypt_to_json,
78
+ aes_encrypt,
79
+ )
80
+ except ImportError: # shape A
81
+ from aidot.aes_utils import (
82
+ aes_decrypt,
83
+ aes_decrypt_to_json,
84
+ aes_encrypt,
85
+ )
86
+
87
+
88
+ def rsa_encrypt(message: str, public_key: Any = None) -> str:
89
+ """RSA/PKCS1v15 encrypt ``message``, returned base64.
90
+
91
+ Implemented here rather than re-exported. Shape B had
92
+ ``aidot.utils.crypto.rsa_encrypt(message, public_key)``; shape A replaced it
93
+ with ``aidot.client.rsa_password_encrypt(message)``, which bakes the key in
94
+ and takes one argument. Neither signature is available on both shapes, and
95
+ ``aidot_cameras.crypto.rsa_encrypt`` is public surface the integration repo
96
+ may import (see the back-compat rule in CLAUDE.md), so the two-argument form
97
+ is kept and satisfied locally. ``public_key`` defaults to upstream's
98
+ ``PUBLIC_KEY_PEM``, which is the only key any caller ever passed.
99
+ """
100
+ from cryptography.hazmat.backends import default_backend
101
+ from cryptography.hazmat.primitives import serialization
102
+ from cryptography.hazmat.primitives.asymmetric import padding
103
+ import base64
104
+
105
+ if public_key is None:
106
+ public_key = PUBLIC_KEY_PEM
107
+ loaded = serialization.load_pem_public_key(
108
+ public_key.encode() if isinstance(public_key, str) else public_key,
109
+ backend=default_backend(),
110
+ )
111
+ encrypted = loaded.encrypt(message.encode("utf-8"), padding.PKCS1v15())
112
+ return base64.b64encode(encrypted).decode("utf-8")
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # Device record: ``DeviceModel`` (shape B) / ``DeviceInformation`` (shape A)
117
+ # --------------------------------------------------------------------------- #
118
+
119
+ try: # shape B
120
+ from aidot.models.device_model import DeviceModel as DeviceRecord
121
+ except ImportError: # shape A
122
+ from aidot.models.device_model import DeviceInformation as DeviceRecord
123
+
124
+ #: The typed cloud device record. Both shapes expose ``from_json`` /
125
+ #: ``to_dict`` and the core field set this package reads (``id``, ``modelId``,
126
+ #: ``aesKey``, ``password``, ``product``); shape A drops ~20 fields shape B
127
+ #: carried and defaults everything to ``None`` rather than ``""``/empty, so
128
+ #: never rely on a nested default being present - check for ``None`` first.
129
+ DeviceModel = DeviceRecord
130
+
131
+
132
+ # --------------------------------------------------------------------------- #
133
+ # DeviceState: upstream's LAN session enum, deleted in shape A
134
+ # --------------------------------------------------------------------------- #
135
+
136
+ try: # shape B
137
+ from aidot.device_client import DeviceState as _DeviceState
138
+ DEVICE_STATE_IS_UPSTREAMS = True
139
+ except ImportError: # shape A: no state machine, just two booleans
140
+ DEVICE_STATE_IS_UPSTREAMS = False
141
+
142
+ class _DeviceState(IntEnum):
143
+ """Stand-in for upstream's deleted ``DeviceState``.
144
+
145
+ Shape A tracks the LAN session with ``_connecting`` /
146
+ ``_connect_and_login`` booleans instead of an enum. The enum is still
147
+ re-exported from ``aidot_cameras.device_client`` because the Home
148
+ Assistant integration annotates and compares against it, and dropping a
149
+ public name outright would break it at import.
150
+
151
+ Members and values are copied from 0.3.55 so a stored or hard-coded
152
+ comparison keeps its meaning. On shape A nothing upstream ever
153
+ *produces* one of these - use ``device_session_authenticated()`` rather
154
+ than comparing a client's private state against it.
155
+ """
156
+
157
+ IDLE = 0
158
+ INITIALIZING = 1
159
+ CONNECTING = 2
160
+ CONNECTED = 3
161
+ CONNECTION_FAILED = 4
162
+ AUTHENTICATING = 5
163
+ AUTHENTICATED = 6
164
+ AUTHENTICATION_FAILED = 7
165
+ DISCONNECTED = 8
166
+
167
+ DeviceState = _DeviceState
168
+
169
+
170
+ def device_session_authenticated(device_client: Any) -> bool:
171
+ """Whether a device client's LAN session is logged in, on either shape.
172
+
173
+ Shape B exposes ``_state == DeviceState.AUTHENTICATED``; shape A replaced
174
+ that with the ``connect_and_login`` property. Consumers that only want the
175
+ answer should call this instead of touching either private attribute.
176
+ """
177
+ if DEVICE_STATE_IS_UPSTREAMS:
178
+ return getattr(device_client, "_state", None) == DeviceState.AUTHENTICATED
179
+ return bool(getattr(device_client, "connect_and_login", False))
180
+
181
+
182
+ # --------------------------------------------------------------------------- #
183
+ # Reconnect timer handle: ``_reconnect_timer`` -> ``_reconnect_handle``
184
+ # --------------------------------------------------------------------------- #
185
+
186
+ #: Attribute names a pending reconnect may be parked under, newest first.
187
+ #: ``CameraClient.async_close`` cancels whichever exists so a closed account
188
+ #: cannot re-open a TCP connection ~45 s later (upstream cancels neither).
189
+ RECONNECT_HANDLE_ATTRS = ("_reconnect_handle", "_reconnect_timer")
190
+
191
+
192
+ def cancel_pending_reconnect(device_client: Any) -> bool:
193
+ """Cancel a device client's pending reconnect, whichever shape it uses.
194
+
195
+ Returns True if something was cancelled. Deliberately not a silent
196
+ ``getattr(x, "_reconnect_timer", None)``: that spelling exists only on shape
197
+ B, so on shape A it would find nothing, cancel nothing, and report success -
198
+ the leak fix would go quiet instead of failing. Callers that care should
199
+ log the False case.
200
+ """
201
+ for attr in RECONNECT_HANDLE_ATTRS:
202
+ handle = getattr(device_client, attr, None)
203
+ if handle is not None:
204
+ handle.cancel()
205
+ return True
206
+ return False
207
+
208
+
209
+ # --------------------------------------------------------------------------- #
210
+ # Account HTTP surface: ``CloudApi`` (shape B) -> client methods (shape A)
211
+ # --------------------------------------------------------------------------- #
212
+
213
+ async def api_get_houses(client: Any) -> Optional[list]:
214
+ """List the account's houses."""
215
+ if HAS_TYPED_ACCOUNT:
216
+ return await client._cloud_api.get_houses()
217
+ return await client.async_get_houses()
218
+
219
+
220
+ async def api_get_devices(client: Any, house_id: str) -> Optional[list]:
221
+ """List one house's devices."""
222
+ if HAS_TYPED_ACCOUNT:
223
+ return await client._cloud_api.get_devices(house_id)
224
+ return await client.async_get_devices(house_id)
225
+
226
+
227
+ async def api_get_products(client: Any, product_ids: str) -> Optional[list]:
228
+ """Fetch product metadata for a comma-joined id list."""
229
+ if HAS_TYPED_ACCOUNT:
230
+ return await client._cloud_api.get_products(product_ids)
231
+ return await client.async_get_products(product_ids)
232
+
233
+
234
+ async def api_refresh_token(client: Any) -> Any:
235
+ """Exchange the refresh token for a new access token.
236
+
237
+ Both shapes return the response body and raise ``AidotAuthFailed`` when the
238
+ refresh token itself is rejected; shape B additionally returns ``None`` on
239
+ some non-auth failures, so callers must treat a falsy return as "no token".
240
+ """
241
+ if HAS_TYPED_ACCOUNT:
242
+ return await client._cloud_api.refresh_token()
243
+ return await client.async_refresh_token()
244
+
245
+
246
+ # --------------------------------------------------------------------------- #
247
+ # Account fields: ``user_info`` dataclass (shape B) -> plain attrs (shape A)
248
+ # --------------------------------------------------------------------------- #
249
+
250
+ def account_region(client: Any) -> str:
251
+ """The account's API region ("us", "eu", ...)."""
252
+ if HAS_TYPED_ACCOUNT:
253
+ return getattr(client.user_info, "region", "") or ""
254
+ return getattr(client, "_region", "") or ""
255
+
256
+
257
+ def account_refresh_token(client: Any) -> str:
258
+ """The stored refresh token, or "" when there is none."""
259
+ if HAS_TYPED_ACCOUNT:
260
+ return getattr(client.user_info, "refreshToken", "") or ""
261
+ from .const import CONF_REFRESH_TOKEN
262
+ return (client.login_info or {}).get(CONF_REFRESH_TOKEN) or ""
263
+
264
+
265
+ def account_token_ttl(client: Any, default: int = 7200) -> int:
266
+ """Access-token lifetime in seconds, defaulting to the server's 7200."""
267
+ if HAS_TYPED_ACCOUNT:
268
+ raw = getattr(client.user_info, "expiresIn", 0)
269
+ else:
270
+ raw = (client.login_info or {}).get("expiresIn", 0)
271
+ try:
272
+ return int(raw or default)
273
+ except (TypeError, ValueError):
274
+ return default
275
+
276
+
277
+ def account_record(client: Any) -> Any:
278
+ """What to pass as a device client's second constructor argument.
279
+
280
+ Shape B wants the ``UserInformation`` dataclass; shape A wants the raw
281
+ ``login_info`` dict. See ``device_client_args``.
282
+ """
283
+ if HAS_TYPED_ACCOUNT:
284
+ return client.user_info
285
+ return client.login_info
286
+
287
+
288
+ def device_client_args(
289
+ device_record: Any,
290
+ raw_device: dict,
291
+ typed_account: Any,
292
+ login_info: Optional[dict],
293
+ ) -> tuple:
294
+ """The ``(device, user_info)`` pair upstream's ``DeviceClient`` expects.
295
+
296
+ Shape B: ``(DeviceModel, UserInformation)`` - typed dataclasses.
297
+ Shape A: ``(dict, dict)`` - the raw cloud records, natively.
298
+
299
+ Shape A taking raw dicts is why this package carried ``raw_device`` /
300
+ ``login_info`` alongside the typed models in the first place: the typed
301
+ round trip drops every camera-only field. On shape A the originals go
302
+ straight through and nothing is lost.
303
+
304
+ Takes the four values rather than the account client, because the only
305
+ caller (``CameraDeviceClient.__init__``) is handed them directly and has no
306
+ client reference. ``typed_account`` may be a ``UserInformation`` or, from
307
+ an older caller, already a dict - on shape A a dict ``login_info`` wins and
308
+ a dict ``typed_account`` is the fallback, so both call styles work.
309
+ """
310
+ if HAS_TYPED_ACCOUNT:
311
+ return (device_record, typed_account)
312
+ account = login_info if isinstance(login_info, dict) else None
313
+ if account is None:
314
+ account = typed_account if isinstance(typed_account, dict) else {}
315
+ if not isinstance(typed_account, dict) and hasattr(typed_account, "to_dict"):
316
+ account = typed_account.to_dict()
317
+ return (raw_device, account)
318
+
319
+
320
+ #: True when upstream's ``DeviceClient`` exposes a ``read_data`` seam - one
321
+ #: decrypted frame at a time, which this package hooks to recover camera-only
322
+ #: attribute keys that upstream's typed ``DeviceAttr`` model drops. Shape A
323
+ #: inlines the read into ``receive_data``, so there is no seam and the raw JSON
324
+ #: never escapes. See ``CameraDeviceClient._notify_status_update``.
325
+ try:
326
+ from aidot.device_client import DeviceClient as _DeviceClient
327
+ HAS_READ_DATA_SEAM = callable(getattr(_DeviceClient, "read_data", None))
328
+ except ImportError: # pragma: no cover - upstream is a hard dependency
329
+ HAS_READ_DATA_SEAM = False
330
+
331
+
332
+ # --------------------------------------------------------------------------- #
333
+ # Discovery: static ``Discover`` (shape B) -> instance ``Discover`` (shape A)
334
+ # --------------------------------------------------------------------------- #
335
+
336
+ #: True when upstream's ``Discover`` keeps discovered addresses in the
337
+ #: process-wide ``DISCOVERED_DEVICE`` class dict. Shape A made discovery
338
+ #: instance-scoped (``self._discover.discovered_device``), which is the shape
339
+ #: this package's ``CameraDiscover`` already presents - so on shape A the map
340
+ #: no longer has to be shared through the base class to reach upstream's
341
+ #: ``get_device_client``.
342
+ try:
343
+ from aidot.discover import Discover as _Discover
344
+ HAS_SHARED_DISCOVERY_MAP = isinstance(
345
+ getattr(_Discover, "DISCOVERED_DEVICE", None), dict
346
+ )
347
+ except ImportError: # pragma: no cover - upstream is a hard dependency
348
+ HAS_SHARED_DISCOVERY_MAP = False
349
+
350
+
351
+ def broadcast_protocol_args(callback: Any, user_id: str) -> tuple:
352
+ """Positional args for upstream's ``BroadcastProtocol.__init__``.
353
+
354
+ Shape B takes ``(callback)`` and has no notion of the account id; shape A
355
+ takes ``(callback, user_id)`` and uses it to build the request payload.
356
+ """
357
+ import inspect
358
+ from aidot.discover import BroadcastProtocol
359
+
360
+ params = list(inspect.signature(BroadcastProtocol.__init__).parameters)
361
+ return (callback, user_id) if "user_id" in params else (callback,)
362
+
363
+
364
+ #: Names this module intends as its public surface within the package.
365
+ __all__ = [
366
+ "API_URL_TEMPLATE",
367
+ "APP_ID",
368
+ "DEFAULT_REGION",
369
+ "DEVICE_STATE_IS_UPSTREAMS",
370
+ "HAS_READ_DATA_SEAM",
371
+ "HAS_SHARED_DISCOVERY_MAP",
372
+ "HAS_TYPED_ACCOUNT",
373
+ "PUBLIC_KEY_PEM",
374
+ "RECONNECT_HANDLE_ATTRS",
375
+ "UPSTREAM_SHAPE",
376
+ "DeviceModel",
377
+ "DeviceRecord",
378
+ "DeviceState",
379
+ "UserInformation",
380
+ "account_record",
381
+ "account_refresh_token",
382
+ "account_region",
383
+ "account_token_ttl",
384
+ "aes_decrypt",
385
+ "aes_decrypt_to_json",
386
+ "aes_encrypt",
387
+ "api_get_devices",
388
+ "api_get_houses",
389
+ "api_get_products",
390
+ "api_refresh_token",
391
+ "broadcast_protocol_args",
392
+ "cancel_pending_reconnect",
393
+ "device_client_args",
394
+ "device_session_authenticated",
395
+ "rsa_encrypt",
396
+ ]
@@ -854,6 +854,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
854
854
  self._last_frame_time: float = 0.0
855
855
  self._keepalive_rtsp_url: Optional[str] = None # local serve URL (go2rtc pulls)
856
856
  self._go2rtc_url: Optional[str] = None # go2rtc API base (prefer-go2rtc)
857
+ # Whether WE own this camera's go2rtc stream registration. False when the
858
+ # consumer registers the stream itself and passes the URL for viewer
859
+ # queries only - in which case we must neither add nor REMOVE it.
860
+ self._go2rtc_manages_stream: bool = True
857
861
  self._go2rtc_pull_url: Optional[str] = None # go2rtc pull URL once registered
858
862
  self._go2rtc_task: "Optional[asyncio.Task[None]]" = None
859
863
  # Set by the DTLS serve loop once ffmpeg is bound + serving, so
@@ -2789,6 +2793,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2789
2793
  fast_connect: Optional[bool] = None,
2790
2794
  sdes_audio: Optional[bool] = None,
2791
2795
  go2rtc_url: Optional[str] = None,
2796
+ go2rtc_register: bool = True,
2792
2797
  live_stream_param: Optional[bool] = None,
2793
2798
  serve_relay: Optional[bool] = None,
2794
2799
  stream_idle_s: Optional[float] = None,
@@ -2828,6 +2833,22 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2828
2833
  ``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
2829
2834
  directly on the public port (the pre-0.7.18 behaviour).
2830
2835
 
2836
+ ``go2rtc_url`` points at a go2rtc API base. It does two things: register
2837
+ the local serve with go2rtc (exposing its low-latency pull URL), and let
2838
+ the idle-release logic ask go2rtc **who is watching**.
2839
+
2840
+ ``go2rtc_register=False`` keeps the second and drops the first, for a
2841
+ consumer that registers the stream itself - registering it twice
2842
+ re-points the source mid-stream and was observed leaving DTLS cameras
2843
+ producing nothing at all. Such a consumer must still pass ``go2rtc_url``:
2844
+ withholding it does not merely lose a nicety, it silently disables viewer
2845
+ detection, and in SDES **push** mode that is the only viewer signal there
2846
+ is (the serve port is go2rtc's shared RTSP port, where every camera's own
2847
+ publisher is connected, so a socket check would report a viewer for every
2848
+ camera forever). With no signal ``_viewer_present`` answers "unknown",
2849
+ ``_idle_release_due`` correctly never releases on unknown, and a battery
2850
+ camera's keepalive renews forever against a camera nobody is watching.
2851
+
2831
2852
  ``stream_idle_s`` sets the no-viewer idle-release window in seconds,
2832
2853
  overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
2833
2854
  negative) keeps the warm WebRTC session forever so re-views are instant
@@ -2860,6 +2881,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2860
2881
  return
2861
2882
  self._keepalive_rtsp_url = rtsp_push_url
2862
2883
  self._go2rtc_url = go2rtc_url
2884
+ self._go2rtc_manages_stream = bool(go2rtc_register)
2863
2885
  self._streaming_active = True
2864
2886
  # App parity: keep a battery camera awake for the whole view (mains cams
2865
2887
  # never sleep, so this is a no-op there). Single-instance; cancelled in
@@ -2874,7 +2896,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2874
2896
  # Prefer-go2rtc: if a go2rtc server is configured, register the local
2875
2897
  # serve with it once ready and expose its pull URL (ffmpeg fallback
2876
2898
  # otherwise). Opt-in: with no go2rtc_url, behaviour is unchanged.
2877
- if go2rtc_url:
2899
+ #
2900
+ # ``go2rtc_register=False`` stores the URL for QUERIES ONLY and skips
2901
+ # registration. That exists for a consumer that registers the stream
2902
+ # itself: registering it twice re-points the source while a stream is
2903
+ # mid-flight, which was observed leaving DTLS cameras producing nothing
2904
+ # at all. Such a consumer previously had to withhold go2rtc_url
2905
+ # entirely, which silently disabled viewer detection - and in SDES push
2906
+ # mode that means a battery camera can never idle-release, because
2907
+ # ``_viewer_present`` answers "unknown" and ``_idle_release_due``
2908
+ # (correctly) never releases on unknown. The keepalive then renews
2909
+ # forever against a camera nobody is watching.
2910
+ if go2rtc_url and go2rtc_register:
2878
2911
  self._go2rtc_task = asyncio.ensure_future(self._register_with_go2rtc())
2879
2912
 
2880
2913
  @property
@@ -2934,9 +2967,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
2934
2967
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
2935
2968
 
2936
2969
  async def _deregister_go2rtc(self) -> None:
2937
- """Remove this camera's stream from go2rtc (best-effort)."""
2970
+ """Remove this camera's stream from go2rtc (best-effort).
2971
+
2972
+ Only ever removes a registration this client created. With
2973
+ ``go2rtc_register=False`` the consumer owns the stream and merely lent us
2974
+ the URL to ask who is watching; deleting its stream on stop or
2975
+ idle-release would tear down the very thing it is serving from.
2976
+ """
2938
2977
  base = self._go2rtc_url
2939
2978
  self._go2rtc_pull_url = None
2979
+ if not self._go2rtc_manages_stream:
2980
+ return
2940
2981
  # Deliberately NOT gated on `pull`: go2rtc answers the register call with
2941
2982
  # 400 when it cannot immediately validate the source (a camera that has
2942
2983
  # not started producing yet), yet it still keeps the stream registered.
@@ -3099,22 +3140,21 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3099
3140
 
3100
3141
  name = self._go2rtc_stream_name()
3101
3142
  base = self._go2rtc_url
3102
- if not base:
3103
- # No go2rtc to ask. In PUSH mode the fallback below is not merely
3104
- # unavailable, it is WRONG: the "serve port" is go2rtc's shared RTSP
3105
- # port (8554), where every camera's own publisher is connected, so the
3106
- # check reports a viewer for every camera forever and nothing ever
3107
- # releases. Answer "unknown" instead of a confident lie - the caller
3108
- # then holds the stream rather than tearing down a live view, and the
3109
- # honest way out is for the consumer to pass go2rtc_url.
3110
- if self._keepalive_rtsp_url and not str(
3111
- self._keepalive_rtsp_url).startswith("http"):
3112
- _LOGGER.debug(
3113
- "no go2rtc url and the serve is an RTSP push to a shared "
3114
- "port; cannot tell whether anyone is watching",
3115
- )
3116
- self._viewer_cache = (_now_mono, None)
3117
- return None
3143
+
3144
+ # In PUSH mode the "serve port" is go2rtc's SHARED RTSP port (8554),
3145
+ # where every camera's own publisher is connected - so the socket
3146
+ # fallback below is not merely unavailable, it is WRONG: it would report
3147
+ # a viewer for every camera forever and nothing would ever release.
3148
+ # Tracked separately from `base` because it must gate the fallback even
3149
+ # when go2rtc IS configured but the query fails.
3150
+ push_mode = bool(self._keepalive_rtsp_url) and not str(
3151
+ self._keepalive_rtsp_url).startswith("http")
3152
+
3153
+ # Ask go2rtc when we can. This is the only viewer signal that works in
3154
+ # push mode, which is why a consumer that registers its own stream
3155
+ # should still pass go2rtc_url with go2rtc_register=False rather than
3156
+ # withholding it (withholding it silently pins push-mode cameras to
3157
+ # "unknown", and unknown never releases).
3118
3158
  if base and name:
3119
3159
  try:
3120
3160
  import aiohttp
@@ -3128,6 +3168,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3128
3168
  return _answer
3129
3169
  except Exception as exc:
3130
3170
  _LOGGER.debug("go2rtc viewer check failed: %s", exc)
3171
+
3172
+ if push_mode:
3173
+ # Answer "unknown" instead of a confident lie - the caller then holds
3174
+ # the stream rather than tearing down a live view.
3175
+ _LOGGER.debug(
3176
+ "cannot tell whether anyone is watching: the serve is an RTSP "
3177
+ "push to a shared port and go2rtc %s",
3178
+ "did not answer" if base else "was not configured",
3179
+ )
3180
+ self._viewer_cache = (_now_mono, None)
3181
+ return None
3182
+
3131
3183
  _answer = self._sdes_serve_consumer_present(port)
3132
3184
  self._viewer_cache = (_now_mono, _answer)
3133
3185
  return _answer
@@ -181,6 +181,46 @@ def _bridge_should_break(rc, teardown_requested: bool) -> bool:
181
181
  return rc is not None and not teardown_requested
182
182
 
183
183
 
184
+ # Cap on peer-reflexive candidates learned from inbound probes. A camera has
185
+ # one working path; a handful of source addresses is normal churn (NAT rebind,
186
+ # audio vs video socket), anything beyond that is noise and must not be allowed
187
+ # to grow the nomination set without bound.
188
+ _MAX_PRFLX_CANDS = 4
189
+
190
+
191
+ def _record_peer_reflexive(known, discovered, observed, is_self=None):
192
+ """Learn the address a STUN probe actually arrived from.
193
+
194
+ ICE calls this a peer-reflexive candidate (RFC 8445 s7.3.1.3): the camera
195
+ is reachable at the source of its own connectivity check whether or not it
196
+ ever advertised that address. We were nominating only what the answer SDP
197
+ listed, which fails whenever the listed address is not reachable from here
198
+ - a camera on a foreign subnet advertises a private candidate we have no
199
+ route to, the nomination goes into a black hole, and the session sits in
200
+ ICE "Checking" while the camera's probes keep arriving from an address we
201
+ never nominate. Observed on the A001064 PTZ while it was on a separate
202
+ 192.168.100.0/24: it advertised 192.168.100.13 as its only candidate.
203
+
204
+ Returns the new discovered list, or ``discovered`` unchanged when there is
205
+ nothing to learn. Callers must REBIND rather than mutate in place - the
206
+ bridge thread iterates this list and must never see it change under it.
207
+
208
+ Pure function so the policy is unit-testable without a camera.
209
+ """
210
+ if not observed:
211
+ return discovered
212
+ _ip, _port = observed
213
+ if not _ip or not _port:
214
+ return discovered
215
+ if is_self is not None and is_self(_ip):
216
+ return discovered
217
+ if observed in known or observed in discovered:
218
+ return discovered
219
+ if len(discovered) >= _MAX_PRFLX_CANDS:
220
+ return discovered
221
+ return [*discovered, observed]
222
+
223
+
184
224
  class _SdesOpenMixin:
185
225
  async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
186
226
  """Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
@@ -2940,6 +2980,31 @@ class _SdesOpenMixin:
2940
2980
  f" {_bsrc[0]}:{_bsrc[1]}"
2941
2981
  f" attrs=[{', '.join(_attrs)}]"
2942
2982
  )
2983
+ # Learn where this probe actually came from. The
2984
+ # camera reached us from here, so this address is
2985
+ # known-good in a way an advertised one is not.
2986
+ # Relay-carried probes carry the camera's real
2987
+ # address in XOR-PEER-ADDRESS; _bsrc is the TURN
2988
+ # server and would be useless to nominate.
2989
+ _br_obs = (
2990
+ _br_cam_peer if _br_cam_peer
2991
+ else (None if _bsrc[0] == _hp_host else _bsrc)
2992
+ )
2993
+ _br_prflx_was = _bridge_uc_info["prflx"]
2994
+ _br_prflx_now = _record_peer_reflexive(
2995
+ _bridge_uc_info["cands"], _br_prflx_was,
2996
+ _br_obs, _is_self_peer_ip,
2997
+ )
2998
+ if _br_prflx_now is not _br_prflx_was:
2999
+ # Rebind, never append: the nomination tick
3000
+ # iterates this list.
3001
+ _bridge_uc_info["prflx"] = _br_prflx_now
3002
+ _status(
3003
+ f"ICE: learned peer-reflexive camera"
3004
+ f" candidate {_br_obs[0]}:{_br_obs[1]}"
3005
+ f" (not in the {len(_bridge_uc_info['cands'])}"
3006
+ f" advertised candidate(s)) - will nominate it"
3007
+ )
2943
3008
  try:
2944
3009
  if _br_turn_peer_ip is None and _bsrc[0] != _hp_host:
2945
3010
  _br_prefer_direct_stun[_bs] = True
@@ -3814,6 +3879,16 @@ class _SdesOpenMixin:
3814
3879
  # Fall back to the creds parsed late into _bridge_uc_info so this
3815
3880
  # ungated periodic tick still nominates them. [SDES-LATECREDS-FIX]
3816
3881
  _uc_cands = _cam_ice_cands or _bridge_uc_info.get("cands")
3882
+ # Union in anything learned from the camera's own probes.
3883
+ # Advertised candidates stay FIRST and are still nominated,
3884
+ # so a camera we can already reach directly is unaffected;
3885
+ # this only adds a path where there was none.
3886
+ _uc_prflx = _bridge_uc_info.get("prflx")
3887
+ if _uc_prflx:
3888
+ _uc_cands = [
3889
+ *(_uc_cands or []),
3890
+ *(c for c in _uc_prflx if c not in (_uc_cands or [])),
3891
+ ]
3817
3892
  _uc_cufrag = _cam_ice_ufrag or _bridge_uc_info.get("ufrag")
3818
3893
  _uc_cpwd = _cam_ice_pwd or _bridge_uc_info.get("pwd")
3819
3894
  if _uc_cands and _uc_cufrag and _uc_cpwd and (_br_now - _br_last_uc) >= 2.5:
@@ -3884,6 +3959,10 @@ class _SdesOpenMixin:
3884
3959
  "pwd": _cam_ice_pwd,
3885
3960
  "cands": list(_cam_ice_cands),
3886
3961
  "sent": bool(_cam_ice_cands), # True if already sent at setup
3962
+ # Addresses the camera's own probes arrived from, which may differ
3963
+ # from anything it advertised. Written by the bridge thread only;
3964
+ # read there too, so no cross-thread ordering to reason about.
3965
+ "prflx": [],
3887
3966
  }
3888
3967
 
3889
3968
  _bridge_thread = _threading_br.Thread(
@@ -335,7 +335,7 @@ class _WebRTCOpenMixin:
335
335
  # only the higher of the camera's consecutive [P, P+1] ICE ports (its
336
336
  # live DTLS socket). aioice's aggressive nomination of BOTH ports made
337
337
  # the camera latch the lower one and withhold DTLS - the dominant ~25%
338
- # failure. A/B on M3 Pro v2: high-port-only ~87% vs ~25% baseline vs
338
+ # failure. A/B on an A000088: high-port-only ~87% vs ~25% baseline vs
339
339
  # ~12% low-port. Self-gating no-op for non-A000088 peers.
340
340
  # See _install_highport_nomination_patch.
341
341
  _install_highport_nomination_patch()