python-aidot-cameras 0.12.4__tar.gz → 0.12.7__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 (138) hide show
  1. {python_aidot_cameras-0.12.4/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.7}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/client.py +16 -18
  3. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/sdes_open.py +133 -20
  4. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_serve_audio.py +17 -18
  8. python_aidot_cameras-0.12.7/tests/test_sdp_payload_type_narrowing.py +83 -0
  9. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/README.md +0 -0
  12. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/__init__.py +0 -0
  48. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/constants.py +0 -0
  49. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/controls.py +0 -0
  50. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/go2rtc.py +0 -0
  51. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/lan_control.py +0 -0
  52. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/models.py +0 -0
  53. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/playback.py +0 -0
  54. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/protocol.py +0 -0
  55. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/sdes.py +0 -0
  56. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_keepalive_renew.py +0 -0
  92. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_keyframe_prompter.py +0 -0
  93. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_lan_control.py +0 -0
  94. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_light_active_color_mode.py +0 -0
  95. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_logging_caps.py +0 -0
  97. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_motion_poll.py +0 -0
  98. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_mqtt_credential_refetch.py +0 -0
  99. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_mqtt_credential_self_heal.py +0 -0
  100. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_mqtt_password_not_persisted.py +0 -0
  101. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_mqtt_session_reconnect.py +0 -0
  102. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_narrow_pc_ice.py +0 -0
  103. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_no_undefined_names.py +0 -0
  104. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_offline_keepalive_pause.py +0 -0
  105. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_open_fail_logger.py +0 -0
  106. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_open_gate_delay.py +0 -0
  107. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_persistent_mqtt.py +0 -0
  108. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_persistent_mqtt_robustness.py +0 -0
  109. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_playback_tls.py +0 -0
  110. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_post_merge_hardening.py +0 -0
  111. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_retry_policy.py +0 -0
  112. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_adaptive.py +0 -0
  113. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_bridge_break_gate.py +0 -0
  114. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_echo_wait_timeout.py +0 -0
  115. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_fast_liveplay.py +0 -0
  116. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  117. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_idle_release.py +0 -0
  118. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_open_cleanup.py +0 -0
  119. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_serve_cmd.py +0 -0
  120. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_sprop.py +0 -0
  121. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_talk.py +0 -0
  122. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_teardown_exit_log.py +0 -0
  123. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_sdes_watchdog.py +0 -0
  124. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_security_hardening.py +0 -0
  125. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_serializable_login_info.py +0 -0
  126. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_serve_relay.py +0 -0
  127. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_session_stats.py +0 -0
  128. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_speak.py +0 -0
  129. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_stream_cap.py +0 -0
  130. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_stream_drain_ownership.py +0 -0
  131. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_stream_idle.py +0 -0
  132. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_stream_teardown.py +0 -0
  133. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_talk.py +0 -0
  134. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_terminal_ack.py +0 -0
  135. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_token_file_robustness.py +0 -0
  136. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_token_refresh.py +0 -0
  137. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_upstream_compat.py +0 -0
  138. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.7}/tests/test_wait_or_event.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.4
3
+ Version: 0.12.7
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
@@ -3360,27 +3360,25 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
3360
3360
  def _resolve_sdes_serve_audio(self) -> bool:
3361
3361
  """Whether to include audio in the SDES camera serve.
3362
3362
 
3363
- **Default OFF**, because including it can cost the video entirely. The
3364
- mpegts mux writes its PAT/PMT only once EVERY mapped stream has produced a
3365
- packet, and ``amix`` does not emit until every one of its inputs has
3366
- delivered a frame - so on a camera that sends no PCMA, the AAC encoder
3367
- never produces, the PMT is never written, and a consumer gets an accepted
3368
- connection and then zero bytes. The continuous ``anullsrc`` silence base
3369
- was meant to prevent exactly that and does not: verified live on a battery
3370
- camera, audio on gave 0 bytes across 45 consecutive attach attempts while
3371
- audio off gave 303 KB of 1280x960 H.264 from the same session, and
3372
- reordering the mix so the silence drives it did not help.
3373
-
3374
- Missing audio is a worse experience than the app; NO VIDEO is a broken
3375
- integration, so video wins the tie. Turn audio back on per camera with
3376
- ``sdes_audio`` (via ``start_keepalive``), or globally with
3377
- ``AIDOT_SDES_SERVE_AUDIO=1`` - on a camera that does send audio steadily
3378
- it works, which is why this stays an option rather than being removed."""
3363
+ **Default ON** for app-parity (the official app plays camera audio).
3364
+
3365
+ This was briefly defaulted off, because mapping audio used to serve no
3366
+ video at all: the SDP advertises two payload types per media line and
3367
+ ffmpeg binds the first, so the synthesised PCMA (pt 8) was discarded in
3368
+ favour of the advertised PCMU (pt 0) - and since the mpegts mux withholds
3369
+ its PAT/PMT until every mapped stream has produced a packet, the consumer
3370
+ got zero bytes and lost the picture too. The audio line is now narrowed to
3371
+ the payload type actually in use, the same way the video line already was,
3372
+ and the serve falls back to video-only if the audio payload type cannot be
3373
+ characterised in time. So audio can no longer cost the video.
3374
+
3375
+ Per-camera ``sdes_audio`` (via ``start_keepalive``) wins; else the
3376
+ ``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
3379
3377
  opt = getattr(self, "_sdes_audio_opt", None)
3380
3378
  if opt is not None:
3381
3379
  return bool(opt)
3382
- return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() in (
3383
- "1", "true", "yes", "on")
3380
+ return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() not in (
3381
+ "0", "false", "no", "off")
3384
3382
 
3385
3383
  def _resolve_sdes_audio_gain_db(self) -> float:
3386
3384
  """Gain (dB) applied to the served SDES audio (the camera mic runs hot).
@@ -34,6 +34,62 @@ from .protocol import (
34
34
  _LOGGER = logging.getLogger(__name__)
35
35
 
36
36
 
37
+ # Payload types the ffmpeg SDP advertises per media line, and their alternates.
38
+ # The SDP offers both because the camera picks one per session.
39
+ _SDP_ALTERNATE_PT = {96: 97, 97: 96, 0: 8, 8: 0}
40
+
41
+ # How long to wait for the FIRST media of a session before launching the serve.
42
+ # Nothing useful can happen before then: the payload types are unknown, so the SDP
43
+ # cannot be narrowed, and ffmpeg would bind the wrong depacketizers. Sized to the
44
+ # documented cold-start window for a battery camera (25-70s). Measured: a cold
45
+ # session started media at ~21s, which the previous 15s deadline missed entirely -
46
+ # it launched blind with BOTH payload types still unknown, which is what left the
47
+ # audio line advertising PCMU on a PCMA camera.
48
+ _FIRST_MEDIA_WAIT_S = 45.0
49
+
50
+ # Extra time for the audio payload type once video is known. Measured on an
51
+ # A001513: audio follows video by 40-70ms, because the camera answers BUNDLE and
52
+ # both share one 5-tuple - so audio is never "late" and this only absorbs jitter.
53
+ # Kept tight so a camera that genuinely sends no audio barely delays the picture.
54
+ _AUDIO_PT_GRACE_S = 1.0
55
+
56
+
57
+ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
58
+ """Rewrite an SDP to advertise a single payload type per media line.
59
+
60
+ ffmpeg binds each RTP depacketizer to the FIRST payload type on the m-line and
61
+ silently discards packets carrying any other one. The SDP written for the
62
+ bridge lists both candidates per line (``m=video ... 96 97`` for H.264/H.265,
63
+ ``m=audio ... 0 8`` for PCMU/PCMA) because which one the camera uses varies per
64
+ session, so whichever it actually sends has to be promoted before launch.
65
+
66
+ Getting this wrong on the VIDEO line costs the picture. Getting it wrong on the
67
+ AUDIO line costs the picture too, and less obviously: the mpegts mux withholds
68
+ its PAT/PMT until every mapped stream has produced a packet, so an audio stream
69
+ whose packets are all discarded means the consumer receives zero bytes - video
70
+ included - while every other signal looks healthy.
71
+
72
+ ``keep_video`` / ``keep_audio`` of None leaves that line alone.
73
+ """
74
+ drop = {_SDP_ALTERNATE_PT[k] for k in (keep_video, keep_audio)
75
+ if k in _SDP_ALTERNATE_PT}
76
+ kept = []
77
+ for line in sdp_text.splitlines(keepends=True):
78
+ stripped = line.lstrip()
79
+ if stripped.startswith("m=video") and keep_video is not None:
80
+ line = line.replace(" 96 97", f" {keep_video}")
81
+ elif stripped.startswith("m=audio") and keep_audio is not None:
82
+ line = line.replace(" 0 8", f" {keep_audio}")
83
+ elif any(
84
+ stripped.startswith(f"a={attr}:{pt}{sep}")
85
+ for pt in drop
86
+ for attr, sep in (("rtpmap", " "), ("fmtp", " "), ("fmtp", ";"))
87
+ ):
88
+ continue
89
+ kept.append(line)
90
+ return "".join(kept)
91
+
92
+
37
93
  def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
38
94
  """Seconds to block on the camera's webrtcReq echo before proceeding.
39
95
 
@@ -2141,6 +2197,11 @@ class _SdesOpenMixin:
2141
2197
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2142
2198
  # to the matching single codec before ffmpeg is launched.
2143
2199
  _first_video_pt: list = [None]
2200
+ # Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
2201
+ # and ffmpeg binds the depacketizer to the first one listed - so the line
2202
+ # has to be narrowed to the payload type the camera actually sends, the
2203
+ # same way the video line already is.
2204
+ _first_audio_pt: list = [None]
2144
2205
  # Media-liveness: bridge sets [0] = time.monotonic() on every forwarded
2145
2206
  # media packet; the keepalive watchdog reads it via SdesSession to
2146
2207
  # restart a session the camera silently stopped feeding.
@@ -3095,10 +3156,21 @@ class _SdesOpenMixin:
3095
3156
  )
3096
3157
  if not _br_first_audio_logged:
3097
3158
  _br_first_audio_logged = True
3159
+ # Record the payload type this path
3160
+ # SYNTHESISES (_tk_pt, PCMA) so the
3161
+ # SDP can be narrowed to it. Without
3162
+ # this the m=audio line keeps
3163
+ # advertising "0 8", ffmpeg binds
3164
+ # PCMU and discards every packet, and
3165
+ # the mpegts PMT is never written - so
3166
+ # the consumer gets zero bytes and the
3167
+ # VIDEO is lost with the audio.
3168
+ _first_audio_pt[0] = _tk_pt
3098
3169
  _status(
3099
3170
  f"bridge: first SDES audio -> ffmpeg"
3100
3171
  f" loopback:{_lo_audio_port}"
3101
3172
  f" ({len(_pd_plain)}B PCMA)"
3173
+ f" pt={_tk_pt}"
3102
3174
  )
3103
3175
  except Exception:
3104
3176
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_enc_c8_sctp', exc_info=True)
@@ -3190,6 +3262,7 @@ class _SdesOpenMixin:
3190
3262
  )
3191
3263
  if _kind == "audio" and not _br_first_audio_logged:
3192
3264
  _br_first_audio_logged = True
3265
+ _first_audio_pt[0] = _pt
3193
3266
  _status(f"bridge: first audio RTP pt={_pt}")
3194
3267
  # Capture the camera's audio return address so the talk
3195
3268
  # pump (SdesSession.async_start_talk) can send outbound
@@ -3454,12 +3527,13 @@ class _SdesOpenMixin:
3454
3527
  if out_dir:
3455
3528
  os.makedirs(out_dir, exist_ok=True)
3456
3529
  # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
3530
+ _serve_audio = self._resolve_sdes_serve_audio()
3457
3531
  cmd = _build_sdes_serve_cmd(
3458
3532
  sdp_path=sdp_path,
3459
3533
  rtsp_push_url=rtsp_push_url,
3460
3534
  output_path=output_path,
3461
3535
  max_seconds=max_seconds,
3462
- sdes_audio=self._resolve_sdes_serve_audio(),
3536
+ sdes_audio=_serve_audio,
3463
3537
  audio_gain_db=self._resolve_sdes_audio_gain_db(),
3464
3538
  )
3465
3539
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
@@ -3470,36 +3544,75 @@ class _SdesOpenMixin:
3470
3544
  # video pt, then rewrite the SDP to that single codec before launch.
3471
3545
  # ffmpeg recovers on the next periodic keyframe, so the small spawn delay
3472
3546
  # is harmless. Falls back to the dual-codec SDP if no video is seen.
3473
- _vpt_deadline = time.monotonic() + 15.0
3474
- while _first_video_pt[0] is None and time.monotonic() < _vpt_deadline:
3547
+ # When audio is being served the same wait has to cover the AUDIO payload
3548
+ # type, and for a stricter reason: an unnarrowed video line merely risks the
3549
+ # picture, while an unnarrowed audio line guarantees the mux never writes its
3550
+ # PAT/PMT, which loses the picture AND the audio. Wait for both, then take
3551
+ # whatever has been observed by the deadline.
3552
+ # Wait for the session's first media, then briefly for audio.
3553
+ #
3554
+ # Measured on an A001513: a cold session's media starts at ~21s, and its
3555
+ # video and audio payload types then arrive 40-70ms apart because the camera
3556
+ # answers BUNDLE. So audio is never "late" relative to video - the old 15s
3557
+ # video deadline simply expired BEFORE any media at all, and the serve
3558
+ # launched with both payload types unknown. Waiting for first media costs
3559
+ # no picture latency: ffmpeg cannot produce before media exists, and
3560
+ # launching earlier only binds the wrong depacketizers.
3561
+ _media_deadline = time.monotonic() + _FIRST_MEDIA_WAIT_S
3562
+ while _first_video_pt[0] is None and time.monotonic() < _media_deadline:
3475
3563
  await asyncio.sleep(0.1)
3564
+ if _serve_audio and _first_audio_pt[0] is None:
3565
+ _apt_deadline = time.monotonic() + _AUDIO_PT_GRACE_S
3566
+ while _first_audio_pt[0] is None and time.monotonic() < _apt_deadline:
3567
+ await asyncio.sleep(0.1)
3476
3568
  _vpt = _first_video_pt[0]
3477
- if _vpt in (96, 97):
3478
- def _narrow_sdp_to_pt(_sdp_text: str, _keep: int) -> str:
3479
- _drop = 96 if _keep == 97 else 97
3480
- _kept = []
3481
- for _ln in _sdp_text.splitlines(keepends=True):
3482
- _s = _ln.lstrip()
3483
- if _s.startswith("m=video"):
3484
- _ln = _ln.replace(" 96 97", f" {_keep}")
3485
- elif (_s.startswith(f"a=rtpmap:{_drop} ")
3486
- or _s.startswith(f"a=fmtp:{_drop} ")
3487
- or _s.startswith(f"a=fmtp:{_drop};")):
3488
- continue
3489
- _kept.append(_ln)
3490
- return "".join(_kept)
3569
+ _apt = _first_audio_pt[0]
3570
+ if _serve_audio and _apt is None:
3571
+ # No audio observed in the window, so the line cannot be narrowed and
3572
+ # mapping audio would stall the mux. Serve video only rather than
3573
+ # serving nothing, and say so.
3574
+ _LOGGER.warning(
3575
+ "camera %s: no audio observed before the serve launched, so the "
3576
+ "audio payload type is unknown; mapping it would stall the mpegts "
3577
+ "mux and serve no video. Continuing without audio.",
3578
+ getattr(self, "device_id", "?"),
3579
+ )
3580
+ _serve_audio = False
3581
+ cmd = _build_sdes_serve_cmd(
3582
+ sdp_path=sdp_path,
3583
+ rtsp_push_url=rtsp_push_url,
3584
+ output_path=output_path,
3585
+ max_seconds=max_seconds,
3586
+ sdes_audio=False,
3587
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
3588
+ )
3589
+ # Audio matters even more than video here. A multi-PT m-line makes ffmpeg
3590
+ # bind the depacketizer to the FIRST payload type and silently discard the
3591
+ # rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
3592
+ # has produced a packet - so an audio line advertising "0 8" (PCMU first)
3593
+ # on a camera that sends PCMA discards every audio packet and the consumer
3594
+ # receives zero bytes. That takes the video down with it, which is why
3595
+ # enabling serve audio appeared to break streaming outright.
3596
+ if _vpt in (96, 97) or (_serve_audio and _apt in (0, 8)):
3491
3597
  def _read_sdp_file() -> str:
3492
3598
  with open(sdp_path, encoding="utf-8") as _f_sdp:
3493
3599
  return _f_sdp.read()
3494
3600
  try:
3601
+ _keep_v = int(_vpt) if _vpt in (96, 97) else None
3602
+ _keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
3495
3603
  _cur_sdp = await asyncio.get_running_loop().run_in_executor(
3496
3604
  None, _read_sdp_file)
3497
3605
  await asyncio.get_running_loop().run_in_executor(
3498
3606
  None, _write_text_file, sdp_path,
3499
- _narrow_sdp_to_pt(_cur_sdp, int(_vpt)))
3607
+ narrow_sdp_payload_types(_cur_sdp, _keep_v, _keep_a))
3500
3608
  _status(
3501
- f"SDES: narrowed ffmpeg SDP to video pt={_vpt}"
3502
- f" ({'H265' if int(_vpt) == 97 else 'H264'})")
3609
+ "SDES: narrowed ffmpeg SDP to"
3610
+ + (f" video pt={_keep_v}"
3611
+ f" ({'H265' if _keep_v == 97 else 'H264'})"
3612
+ if _keep_v is not None else "")
3613
+ + (f" audio pt={_keep_a}"
3614
+ f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
3615
+ if _keep_a is not None else ""))
3503
3616
  except Exception:
3504
3617
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_open_sdes_stream', exc_info=True)
3505
3618
  _LOGGER.info("SDES ffmpeg cmd: %s", " ".join(cmd))
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.12.4"
7
+ version = "0.12.7"
8
8
  description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.12.4
3
+ Version: 0.12.7
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
@@ -118,6 +118,7 @@ tests/test_sdes_sprop.py
118
118
  tests/test_sdes_talk.py
119
119
  tests/test_sdes_teardown_exit_log.py
120
120
  tests/test_sdes_watchdog.py
121
+ tests/test_sdp_payload_type_narrowing.py
121
122
  tests/test_security_hardening.py
122
123
  tests/test_serializable_login_info.py
123
124
  tests/test_serve_relay.py
@@ -1,13 +1,14 @@
1
- """Unit tests for _resolve_sdes_serve_audio - SDES serve audio (default OFF).
1
+ """Unit tests for _resolve_sdes_serve_audio - SDES serve audio (default ON).
2
2
 
3
- Locks the opt/env precedence (per-camera kwarg wins over AIDOT_SDES_SERVE_AUDIO)
4
- so the wiring is verifiable without a camera.
3
+ Locks the opt/env precedence (per-camera kwarg wins over AIDOT_SDES_SERVE_AUDIO,
4
+ default on for app-parity) so the wiring is verifiable without a camera.
5
5
 
6
- The default is OFF because mapping audio into the pull serve can cost the video
7
- entirely: mpegts writes its PAT/PMT only once every mapped stream has produced a
8
- packet, and amix does not emit until every input has delivered a frame, so a
9
- camera that sends no PCMA yields an accepted connection and zero bytes. Verified
10
- live - see the docstring on the method for the measurements.
6
+ The default was briefly OFF because mapping audio served no video at all - the
7
+ audio m-line advertised PCMU while the bridge sends PCMA, so ffmpeg discarded
8
+ every audio packet and the mpegts mux never wrote its PAT/PMT. The audio payload
9
+ type is narrowed now, with a video-only fallback when it cannot be characterised,
10
+ so audio can no longer cost the picture and the default is back on. The narrowing
11
+ itself is covered by test_sdp_payload_type_narrowing.py.
11
12
  """
12
13
  import os
13
14
  import sys
@@ -24,23 +25,21 @@ def _cam():
24
25
  return _CAM.__new__(_CAM)
25
26
 
26
27
 
27
- def test_default_off_so_a_silent_camera_still_serves_video(monkeypatch):
28
+ def test_default_on_for_app_parity(monkeypatch):
28
29
  monkeypatch.delenv("AIDOT_SDES_SERVE_AUDIO", raising=False)
29
- assert _cam()._resolve_sdes_serve_audio() is False
30
+ assert _cam()._resolve_sdes_serve_audio() is True
30
31
 
31
32
 
32
- def test_env_enables(monkeypatch):
33
- for val in ("1", "true", "yes", "on", " On "):
33
+ def test_env_disables(monkeypatch):
34
+ for val in ("0", "false", "no", "off", " Off "):
34
35
  monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", val)
35
- assert _cam()._resolve_sdes_serve_audio() is True, val
36
+ assert _cam()._resolve_sdes_serve_audio() is False, val
36
37
 
37
38
 
38
- def test_env_falsy_or_unknown_stays_off(monkeypatch):
39
- # Anything that is not an explicit opt-in leaves video protected: an
40
- # unrecognised value must not silently re-enable the mux-stalling path.
41
- for val in ("0", "false", "no", "off", "", "anything"):
39
+ def test_env_truthy_or_unknown_stays_on(monkeypatch):
40
+ for val in ("1", "true", "yes", "on", "", "anything"):
42
41
  monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", val)
43
- assert _cam()._resolve_sdes_serve_audio() is False, val
42
+ assert _cam()._resolve_sdes_serve_audio() is True, val
44
43
 
45
44
 
46
45
  def test_kwarg_option_wins_over_env(monkeypatch):
@@ -0,0 +1,83 @@
1
+ """The ffmpeg SDP must advertise ONE payload type per media line.
2
+
3
+ ffmpeg binds each RTP depacketizer to the first payload type on the m-line and
4
+ silently discards packets carrying any other one. The SDP lists both candidates
5
+ per line because the camera picks one per session, so whichever it actually sends
6
+ has to be promoted before launch.
7
+
8
+ On the video line, getting this wrong costs the picture - that was the known
9
+ H.265 bug. On the audio line it also costs the picture, and far less obviously:
10
+ the mpegts mux withholds its PAT/PMT until every mapped stream has produced a
11
+ packet, so an audio stream whose packets are all discarded leaves the consumer
12
+ with zero bytes, video included, while signaling looks perfectly healthy. That is
13
+ what made enabling serve audio look like it broke streaming outright.
14
+ """
15
+ from aidot_cameras.camera.sdes_open import narrow_sdp_payload_types
16
+
17
+ _SDP = (
18
+ "v=0\r\n"
19
+ "o=- 1 1 IN IP4 0.0.0.0\r\n"
20
+ "s=aidot-sdes-rx\r\n"
21
+ "t=0 0\r\n"
22
+ "m=audio 34162 RTP/AVP 0 8\r\n"
23
+ "c=IN IP4 127.0.0.1\r\n"
24
+ "a=rtpmap:0 PCMU/8000\r\n"
25
+ "a=rtpmap:8 PCMA/8000\r\n"
26
+ "m=video 38738 RTP/AVP 96 97\r\n"
27
+ "c=IN IP4 127.0.0.1\r\n"
28
+ "a=rtpmap:96 H264/90000\r\n"
29
+ "a=rtpmap:97 H265/90000\r\n"
30
+ "a=fmtp:96 packetization-mode=1\r\n"
31
+ )
32
+
33
+
34
+ def test_pcma_camera_gets_an_audio_line_advertising_only_pcma():
35
+ # The live failure: the camera sends PCMA (8) but PCMU (0) is listed first, so
36
+ # ffmpeg bound PCMU and discarded every audio packet.
37
+ out = narrow_sdp_payload_types(_SDP, keep_video=96, keep_audio=8)
38
+ assert "m=audio 34162 RTP/AVP 8\r\n" in out
39
+ assert "a=rtpmap:8 PCMA/8000" in out
40
+ assert "a=rtpmap:0 PCMU/8000" not in out
41
+
42
+
43
+ def test_pcmu_camera_gets_the_other_one():
44
+ out = narrow_sdp_payload_types(_SDP, keep_video=96, keep_audio=0)
45
+ assert "m=audio 34162 RTP/AVP 0\r\n" in out
46
+ assert "a=rtpmap:0 PCMU/8000" in out
47
+ assert "a=rtpmap:8 PCMA/8000" not in out
48
+
49
+
50
+ def test_h265_video_narrowing_still_works():
51
+ # The pre-existing behaviour this generalises; it must not regress.
52
+ out = narrow_sdp_payload_types(_SDP, keep_video=97, keep_audio=8)
53
+ assert "m=video 38738 RTP/AVP 97\r\n" in out
54
+ assert "a=rtpmap:97 H265/90000" in out
55
+ assert "a=rtpmap:96 H264/90000" not in out
56
+ assert "a=fmtp:96 " not in out # the dropped codec's fmtp goes too
57
+
58
+
59
+ def test_h264_keeps_its_fmtp():
60
+ out = narrow_sdp_payload_types(_SDP, keep_video=96, keep_audio=8)
61
+ assert "a=fmtp:96 packetization-mode=1" in out
62
+
63
+
64
+ def test_none_leaves_a_line_untouched():
65
+ # Video observed, audio not (or audio not being served): the audio line must
66
+ # be left exactly as it was rather than half-rewritten.
67
+ out = narrow_sdp_payload_types(_SDP, keep_video=96, keep_audio=None)
68
+ assert "m=audio 34162 RTP/AVP 0 8\r\n" in out
69
+ assert "a=rtpmap:0 PCMU/8000" in out
70
+ assert "a=rtpmap:8 PCMA/8000" in out
71
+ assert "m=video 38738 RTP/AVP 96\r\n" in out
72
+
73
+
74
+ def test_both_none_is_an_exact_passthrough():
75
+ assert narrow_sdp_payload_types(_SDP) == _SDP
76
+
77
+
78
+ def test_line_endings_and_ordering_survive():
79
+ # ffmpeg is strict about SDP framing; the rewrite must not reflow it.
80
+ out = narrow_sdp_payload_types(_SDP, keep_video=96, keep_audio=8)
81
+ assert out.endswith("\r\n")
82
+ assert "\n\n" not in out
83
+ assert out.index("m=audio") < out.index("m=video") # stream order preserved