python-aidot-cameras 0.13.2__tar.gz → 0.14.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 (162) hide show
  1. {python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.14.0}/PKG-INFO +1 -1
  2. python_aidot_cameras-0.14.0/aidot_cameras/ble_gateway.py +564 -0
  3. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/sdes_open.py +84 -2
  4. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  7. python_aidot_cameras-0.14.0/tests/test_answer_sdp_video_pt.py +181 -0
  8. python_aidot_cameras-0.14.0/tests/test_ble_gateway.py +393 -0
  9. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/LICENSE +0 -0
  10. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/README.md +0 -0
  12. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_upstream.py +0 -0
  15. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/__init__.py +0 -0
  16. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  17. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  18. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  19. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  20. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  21. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  22. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  23. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  24. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  25. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  26. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  27. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  28. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  29. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  30. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  31. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  32. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  33. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  34. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  35. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  36. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  37. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  38. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  39. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  40. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  41. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  42. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  43. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  44. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  45. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  46. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  47. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  48. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/__init__.py +0 -0
  49. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/client.py +0 -0
  50. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/constants.py +0 -0
  51. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/controls.py +0 -0
  52. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/go2rtc.py +0 -0
  53. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/lan_control.py +0 -0
  54. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/models.py +0 -0
  55. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/playback.py +0 -0
  56. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/protocol.py +0 -0
  57. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/sdes.py +0 -0
  58. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/tutk.py +0 -0
  59. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/webrtc.py +0 -0
  60. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/webrtc_open.py +0 -0
  61. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/client.py +0 -0
  62. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/const.py +0 -0
  63. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/credentials.py +0 -0
  64. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/crypto.py +0 -0
  65. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/device_client.py +0 -0
  66. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/discover.py +0 -0
  67. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/exceptions.py +0 -0
  68. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/g711.py +0 -0
  69. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/py.typed +0 -0
  70. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  71. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  72. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
  73. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  74. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/setup.cfg +0 -0
  75. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_aioice_compat.py +0 -0
  76. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_alarm_event.py +0 -0
  77. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_backoff.py +0 -0
  78. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_detection.py +0 -0
  79. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_evidence_corroboration.py +0 -0
  80. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_skip_turn.py +0 -0
  81. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_constants_imports_resolve.py +0 -0
  82. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_deferred_hardening.py +0 -0
  83. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_device_login_guard.py +0 -0
  84. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_device_user_info_cache.py +0 -0
  85. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dormant_camera_cleanup.py +0 -0
  86. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_mux_terminal_write.py +0 -0
  87. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_not_ready_burst.py +0 -0
  88. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_pinning.py +0 -0
  89. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_serve_decode_skip.py +0 -0
  90. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_serve_open_timeout.py +0 -0
  91. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
  92. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_slow_probe.py +0 -0
  93. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_egress_guard.py +0 -0
  94. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_go2rtc.py +0 -0
  95. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_go2rtc_cli.py +0 -0
  96. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_h264_decode_rate_limit.py +0 -0
  97. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_highport_nomination.py +0 -0
  98. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_ice_config_cache.py +0 -0
  99. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_ice_peer_reflexive.py +0 -0
  100. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_idle_release_both_loops.py +0 -0
  101. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_keepalive_renew.py +0 -0
  102. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_key_restart_sdp.py +0 -0
  103. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_keyframe_prompter.py +0 -0
  104. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_lan_control.py +0 -0
  105. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_light_active_color_mode.py +0 -0
  106. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_live_stream_param.py +0 -0
  107. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_live_validate_policy.py +0 -0
  108. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_logging_caps.py +0 -0
  109. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_motion_poll.py +0 -0
  110. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_credential_refetch.py +0 -0
  111. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_credential_self_heal.py +0 -0
  112. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_password_not_persisted.py +0 -0
  113. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_session_reconnect.py +0 -0
  114. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_narrow_pc_ice.py +0 -0
  115. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_no_self_referential_go2rtc.py +0 -0
  116. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_no_undefined_names.py +0 -0
  117. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_offline_keepalive_pause.py +0 -0
  118. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_open_fail_logger.py +0 -0
  119. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_open_gate_delay.py +0 -0
  120. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_peerid_reuse.py +0 -0
  121. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_persistent_mqtt.py +0 -0
  122. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_persistent_mqtt_robustness.py +0 -0
  123. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_playback_tls.py +0 -0
  124. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_post_merge_hardening.py +0 -0
  125. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_retry_policy.py +0 -0
  126. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_adaptive.py +0 -0
  127. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_bridge_break_gate.py +0 -0
  128. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
  129. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_fast_liveplay.py +0 -0
  130. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  131. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_idle_release.py +0 -0
  132. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_open_cleanup.py +0 -0
  133. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_serve_audio.py +0 -0
  134. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_serve_cmd.py +0 -0
  135. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_sprop.py +0 -0
  136. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_talk.py +0 -0
  137. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_teardown_exit_log.py +0 -0
  138. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_terminal_ack.py +0 -0
  139. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_watchdog.py +0 -0
  140. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
  141. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_security_hardening.py +0 -0
  142. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_serializable_login_info.py +0 -0
  143. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_serve_relay.py +0 -0
  144. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_session_stats.py +0 -0
  145. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_shared_house_seam.py +0 -0
  146. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_speak.py +0 -0
  147. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_cap.py +0 -0
  148. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_drain_ownership.py +0 -0
  149. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_idle.py +0 -0
  150. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_slot_cap.py +0 -0
  151. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_teardown.py +0 -0
  152. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_talk.py +0 -0
  153. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_terminal_ack.py +0 -0
  154. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_token_file_robustness.py +0 -0
  155. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_token_refresh.py +0 -0
  156. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_turn_create_permission.py +0 -0
  157. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_upstream_compat.py +0 -0
  158. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_check_throttle.py +0 -0
  159. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_presence.py +0 -0
  160. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_present_push_mode.py +0 -0
  161. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_wait_or_event.py +0 -0
  162. {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.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.14.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))
@@ -44,6 +44,12 @@ _SDES_BACKGROUND_TASKS: set = set()
44
44
  _SDP_VIDEO_PTS = (96, 97, 98)
45
45
  _SDP_AUDIO_PTS = (0, 8)
46
46
 
47
+ # Which payload type OUR ffmpeg SDP carries for each video codec. The template
48
+ # writes 96=H.264 and 97=H.265, so an answer that negotiates H.265 narrows to 97
49
+ # even when the camera numbers that codec differently on the wire - it is our own
50
+ # SDP being rewritten, not the camera's.
51
+ _SDP_VIDEO_PT_BY_CODEC = {"H264": 96, "H265": 97}
52
+
47
53
  # How long to wait for the FIRST media of a session before launching the serve.
48
54
  # Nothing useful can happen before then: the payload types are unknown, so the SDP
49
55
  # cannot be narrowed, and ffmpeg would bind the wrong depacketizers. Sized to the
@@ -70,6 +76,50 @@ _PRE_LAUNCH_ANSWER_WAIT_S = 8.0
70
76
  _AUDIO_PT_GRACE_S = 1.0
71
77
 
72
78
 
79
+ def video_pt_from_answer_sdp(sdp_text: str) -> Optional[int]:
80
+ """Which video payload type to narrow to, taken from the camera's answer SDP.
81
+
82
+ This is the fallback for the case where no video RTP packet arrives before
83
+ the serve launches. Without it the ffmpeg SDP keeps advertising both codecs,
84
+ and :func:`narrow_sdp_payload_types` documents what that costs: ffmpeg binds
85
+ its depacketizer to the FIRST payload type and silently discards the rest,
86
+ and the RTSP-push ANNOUNCE carries a parameterless H.265 stream that go2rtc
87
+ rejects - so the publisher never attaches and every viewer gets a 404.
88
+
89
+ The answer already states which codec the camera agreed to send, so this is a
90
+ negotiated fact rather than a guess. Only the *first* video m-line is read,
91
+ and only ``a=rtpmap`` lines inside that section, so a payload number reused in
92
+ the audio section cannot leak into the answer.
93
+
94
+ Returns the payload type **our** template uses for that codec (96/97) - the
95
+ camera's own numbering need not agree - or None when the answer names no video
96
+ codec we write, which leaves the existing behaviour untouched.
97
+ """
98
+ pts: list = []
99
+ rtpmap: dict = {}
100
+ section = None
101
+ for raw in (sdp_text or "").splitlines():
102
+ line = raw.strip()
103
+ if line.startswith("m="):
104
+ if section == "video":
105
+ break # the first video section has been read in full
106
+ section = "video" if line.startswith("m=video") else "other"
107
+ if section == "video":
108
+ # "m=video <port> <proto> <pt> [<pt> ...]"
109
+ pts = line.split()[3:]
110
+ continue
111
+ if section == "video" and line.startswith("a=rtpmap:"):
112
+ body = line[len("a=rtpmap:"):]
113
+ pt, _, enc = body.partition(" ")
114
+ if pt and enc:
115
+ rtpmap[pt.strip()] = enc.strip().split("/", 1)[0].upper()
116
+ for pt in pts:
117
+ codec = rtpmap.get(pt)
118
+ if codec in _SDP_VIDEO_PT_BY_CODEC:
119
+ return _SDP_VIDEO_PT_BY_CODEC[codec]
120
+ return None
121
+
122
+
73
123
  def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
74
124
  """Rewrite an SDP to advertise a single payload type per media line.
75
125
 
@@ -2549,6 +2599,11 @@ class _SdesOpenMixin:
2549
2599
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2550
2600
  # to the matching single codec before ffmpeg is launched.
2551
2601
  _first_video_pt: list = [None]
2602
+ # Fallback for the above, filled from the camera's negotiated answer SDP
2603
+ # when the wait below expires without a single video packet. Kept in a
2604
+ # list for the same reason as _first_video_pt: the serve-restart path
2605
+ # reads it from a nested scope.
2606
+ _answer_video_pt: list = [None]
2552
2607
  # Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
2553
2608
  # and ffmpeg binds the depacketizer to the first one listed - so the line
2554
2609
  # has to be narrowed to the payload type the camera actually sends, the
@@ -4159,7 +4214,29 @@ class _SdesOpenMixin:
4159
4214
  # Which single payload type (if any) each line can be narrowed to. Audio
4160
4215
  # is usable only when its type was actually observed; otherwise the "0 8"
4161
4216
  # line cannot be narrowed.
4162
- _keep_v = int(_vpt) if _vpt in _SDP_VIDEO_PTS else None
4217
+ if _vpt not in _SDP_VIDEO_PTS and answer_fut is not None and answer_fut.done():
4218
+ # No video packet arrived inside the window, but the camera's answer
4219
+ # still names the codec it agreed to send. Narrowing on that beats
4220
+ # leaving both codecs advertised: an unnarrowed video line makes
4221
+ # ffmpeg bind the wrong depacketizer, and makes the RTSP-push
4222
+ # ANNOUNCE carry a parameterless H.265 stream that go2rtc rejects
4223
+ # outright - no publisher, and every viewer sees a 404.
4224
+ try:
4225
+ if answer_fut.exception() is None:
4226
+ _answer_video_pt[0] = video_pt_from_answer_sdp(
4227
+ (answer_fut.result() or {}).get("sdp", "") or "")
4228
+ except Exception:
4229
+ _LOGGER.debug("camera %s: swallowed exception in %s",
4230
+ getattr(self, "device_id", "?"),
4231
+ 'video_pt_from_answer_sdp', exc_info=True)
4232
+ if _answer_video_pt[0] is not None:
4233
+ _LOGGER.info(
4234
+ "camera %s: no video observed before the serve launched; "
4235
+ "narrowing the SDP to payload type %d from the camera's "
4236
+ "negotiated answer instead of advertising both codecs.",
4237
+ getattr(self, "device_id", "?"), _answer_video_pt[0],
4238
+ )
4239
+ _keep_v = int(_vpt) if _vpt in _SDP_VIDEO_PTS else _answer_video_pt[0]
4163
4240
  _keep_a = int(_apt) if _apt in _SDP_AUDIO_PTS else None
4164
4241
  if _serve_audio and _keep_a is None:
4165
4242
  # No audio observed in the window, so the line cannot be narrowed and
@@ -4490,8 +4567,13 @@ class _SdesOpenMixin:
4490
4567
  )
4491
4568
  _new_sdp = narrow_sdp_payload_types(
4492
4569
  _new_sdp,
4570
+ # Same answer-SDP fallback as the pre-launch narrowing:
4571
+ # this restart rebuilds the dual-codec template, so
4572
+ # without it a session that never saw a video packet
4573
+ # reproduces the unnarrowed SDP on every watchdog cycle.
4493
4574
  keep_video=(_first_video_pt[0]
4494
- if _first_video_pt[0] in _SDP_VIDEO_PTS else None),
4575
+ if _first_video_pt[0] in _SDP_VIDEO_PTS
4576
+ else _answer_video_pt[0]),
4495
4577
  keep_audio=(_first_audio_pt[0]
4496
4578
  if _first_audio_pt[0] in (0, 8) else None),
4497
4579
  )