python-aidot-cameras 0.12.4__tar.gz → 0.12.6__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.6}/PKG-INFO +1 -1
  2. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/client.py +16 -18
  3. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes_open.py +119 -18
  4. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/pyproject.toml +1 -1
  5. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  6. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/SOURCES.txt +1 -0
  7. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_audio.py +17 -18
  8. python_aidot_cameras-0.12.6/tests/test_sdp_payload_type_narrowing.py +83 -0
  9. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/LICENSE +0 -0
  10. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/README.md +0 -0
  12. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/__init__.py +0 -0
  13. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/__main__.py +0 -0
  14. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/__init__.py +0 -0
  15. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
  16. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
  17. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
  18. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
  19. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
  20. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
  21. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
  22. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
  23. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
  24. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
  25. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
  26. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
  27. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/events.py +0 -0
  28. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
  29. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
  30. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
  31. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
  32. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
  33. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
  34. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
  35. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
  36. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
  37. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
  38. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
  39. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
  40. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
  41. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
  42. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
  43. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
  44. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
  45. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
  46. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
  47. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/__init__.py +0 -0
  48. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/constants.py +0 -0
  49. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/controls.py +0 -0
  50. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/go2rtc.py +0 -0
  51. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/lan_control.py +0 -0
  52. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/models.py +0 -0
  53. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/playback.py +0 -0
  54. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/protocol.py +0 -0
  55. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/sdes.py +0 -0
  56. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/tutk.py +0 -0
  57. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc.py +0 -0
  58. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/camera/webrtc_open.py +0 -0
  59. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/client.py +0 -0
  60. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/const.py +0 -0
  61. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/credentials.py +0 -0
  62. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/crypto.py +0 -0
  63. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/device_client.py +0 -0
  64. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/discover.py +0 -0
  65. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/exceptions.py +0 -0
  66. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/g711.py +0 -0
  67. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/aidot_cameras/py.typed +0 -0
  68. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  69. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
  70. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/requires.txt +0 -0
  71. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/python_aidot_cameras.egg-info/top_level.txt +0 -0
  72. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/setup.cfg +0 -0
  73. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_aioice_compat.py +0 -0
  74. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_alarm_event.py +0 -0
  75. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_backoff.py +0 -0
  76. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_deferred_hardening.py +0 -0
  77. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_device_login_guard.py +0 -0
  78. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_device_user_info_cache.py +0 -0
  79. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_not_ready_burst.py +0 -0
  80. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_pinning.py +0 -0
  81. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_decode_skip.py +0 -0
  82. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_serve_open_timeout.py +0 -0
  83. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_skip_signaling_wait.py +0 -0
  84. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_dtls_slow_probe.py +0 -0
  85. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_egress_guard.py +0 -0
  86. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_go2rtc.py +0 -0
  87. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_go2rtc_cli.py +0 -0
  88. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_h264_decode_rate_limit.py +0 -0
  89. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_highport_nomination.py +0 -0
  90. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_ice_config_cache.py +0 -0
  91. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_keepalive_renew.py +0 -0
  92. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_keyframe_prompter.py +0 -0
  93. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_lan_control.py +0 -0
  94. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_light_active_color_mode.py +0 -0
  95. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_live_stream_param.py +0 -0
  96. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_logging_caps.py +0 -0
  97. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_motion_poll.py +0 -0
  98. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_mqtt_credential_refetch.py +0 -0
  99. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_mqtt_credential_self_heal.py +0 -0
  100. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_mqtt_password_not_persisted.py +0 -0
  101. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_mqtt_session_reconnect.py +0 -0
  102. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_narrow_pc_ice.py +0 -0
  103. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_no_undefined_names.py +0 -0
  104. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_offline_keepalive_pause.py +0 -0
  105. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_open_fail_logger.py +0 -0
  106. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_open_gate_delay.py +0 -0
  107. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt.py +0 -0
  108. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_persistent_mqtt_robustness.py +0 -0
  109. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_playback_tls.py +0 -0
  110. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_post_merge_hardening.py +0 -0
  111. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_retry_policy.py +0 -0
  112. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_adaptive.py +0 -0
  113. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_bridge_break_gate.py +0 -0
  114. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_echo_wait_timeout.py +0 -0
  115. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_fast_liveplay.py +0 -0
  116. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
  117. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_idle_release.py +0 -0
  118. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_open_cleanup.py +0 -0
  119. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_serve_cmd.py +0 -0
  120. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_sprop.py +0 -0
  121. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_talk.py +0 -0
  122. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_teardown_exit_log.py +0 -0
  123. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_sdes_watchdog.py +0 -0
  124. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_security_hardening.py +0 -0
  125. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_serializable_login_info.py +0 -0
  126. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_serve_relay.py +0 -0
  127. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_session_stats.py +0 -0
  128. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_speak.py +0 -0
  129. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_stream_cap.py +0 -0
  130. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_stream_drain_ownership.py +0 -0
  131. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_stream_idle.py +0 -0
  132. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_stream_teardown.py +0 -0
  133. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_talk.py +0 -0
  134. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_terminal_ack.py +0 -0
  135. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_token_file_robustness.py +0 -0
  136. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_token_refresh.py +0 -0
  137. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/tests/test_upstream_compat.py +0 -0
  138. {python_aidot_cameras-0.12.4 → python_aidot_cameras-0.12.6}/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.6
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,54 @@ 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
+ # Extra time, on top of the video payload-type wait, to characterise the audio
42
+ # payload type before the serve launches. Short by design: audio that has not
43
+ # appeared by then is served on a later open (every ffmpeg restart re-runs this
44
+ # wait, and by then the camera has been streaming for a while), which is cheaper
45
+ # than delaying the picture for every silent camera.
46
+ _AUDIO_PT_GRACE_S = 4.0
47
+
48
+
49
+ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
50
+ """Rewrite an SDP to advertise a single payload type per media line.
51
+
52
+ ffmpeg binds each RTP depacketizer to the FIRST payload type on the m-line and
53
+ silently discards packets carrying any other one. The SDP written for the
54
+ bridge lists both candidates per line (``m=video ... 96 97`` for H.264/H.265,
55
+ ``m=audio ... 0 8`` for PCMU/PCMA) because which one the camera uses varies per
56
+ session, so whichever it actually sends has to be promoted before launch.
57
+
58
+ Getting this wrong on the VIDEO line costs the picture. Getting it wrong on the
59
+ AUDIO line costs the picture too, and less obviously: the mpegts mux withholds
60
+ its PAT/PMT until every mapped stream has produced a packet, so an audio stream
61
+ whose packets are all discarded means the consumer receives zero bytes - video
62
+ included - while every other signal looks healthy.
63
+
64
+ ``keep_video`` / ``keep_audio`` of None leaves that line alone.
65
+ """
66
+ drop = {_SDP_ALTERNATE_PT[k] for k in (keep_video, keep_audio)
67
+ if k in _SDP_ALTERNATE_PT}
68
+ kept = []
69
+ for line in sdp_text.splitlines(keepends=True):
70
+ stripped = line.lstrip()
71
+ if stripped.startswith("m=video") and keep_video is not None:
72
+ line = line.replace(" 96 97", f" {keep_video}")
73
+ elif stripped.startswith("m=audio") and keep_audio is not None:
74
+ line = line.replace(" 0 8", f" {keep_audio}")
75
+ elif any(
76
+ stripped.startswith(f"a={attr}:{pt}{sep}")
77
+ for pt in drop
78
+ for attr, sep in (("rtpmap", " "), ("fmtp", " "), ("fmtp", ";"))
79
+ ):
80
+ continue
81
+ kept.append(line)
82
+ return "".join(kept)
83
+
84
+
37
85
  def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
38
86
  """Seconds to block on the camera's webrtcReq echo before proceeding.
39
87
 
@@ -2141,6 +2189,11 @@ class _SdesOpenMixin:
2141
2189
  # bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
2142
2190
  # to the matching single codec before ffmpeg is launched.
2143
2191
  _first_video_pt: list = [None]
2192
+ # Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
2193
+ # and ffmpeg binds the depacketizer to the first one listed - so the line
2194
+ # has to be narrowed to the payload type the camera actually sends, the
2195
+ # same way the video line already is.
2196
+ _first_audio_pt: list = [None]
2144
2197
  # Media-liveness: bridge sets [0] = time.monotonic() on every forwarded
2145
2198
  # media packet; the keepalive watchdog reads it via SdesSession to
2146
2199
  # restart a session the camera silently stopped feeding.
@@ -3095,10 +3148,21 @@ class _SdesOpenMixin:
3095
3148
  )
3096
3149
  if not _br_first_audio_logged:
3097
3150
  _br_first_audio_logged = True
3151
+ # Record the payload type this path
3152
+ # SYNTHESISES (_tk_pt, PCMA) so the
3153
+ # SDP can be narrowed to it. Without
3154
+ # this the m=audio line keeps
3155
+ # advertising "0 8", ffmpeg binds
3156
+ # PCMU and discards every packet, and
3157
+ # the mpegts PMT is never written - so
3158
+ # the consumer gets zero bytes and the
3159
+ # VIDEO is lost with the audio.
3160
+ _first_audio_pt[0] = _tk_pt
3098
3161
  _status(
3099
3162
  f"bridge: first SDES audio -> ffmpeg"
3100
3163
  f" loopback:{_lo_audio_port}"
3101
3164
  f" ({len(_pd_plain)}B PCMA)"
3165
+ f" pt={_tk_pt}"
3102
3166
  )
3103
3167
  except Exception:
3104
3168
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_enc_c8_sctp', exc_info=True)
@@ -3190,6 +3254,7 @@ class _SdesOpenMixin:
3190
3254
  )
3191
3255
  if _kind == "audio" and not _br_first_audio_logged:
3192
3256
  _br_first_audio_logged = True
3257
+ _first_audio_pt[0] = _pt
3193
3258
  _status(f"bridge: first audio RTP pt={_pt}")
3194
3259
  # Capture the camera's audio return address so the talk
3195
3260
  # pump (SdesSession.async_start_talk) can send outbound
@@ -3454,12 +3519,13 @@ class _SdesOpenMixin:
3454
3519
  if out_dir:
3455
3520
  os.makedirs(out_dir, exist_ok=True)
3456
3521
  # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
3522
+ _serve_audio = self._resolve_sdes_serve_audio()
3457
3523
  cmd = _build_sdes_serve_cmd(
3458
3524
  sdp_path=sdp_path,
3459
3525
  rtsp_push_url=rtsp_push_url,
3460
3526
  output_path=output_path,
3461
3527
  max_seconds=max_seconds,
3462
- sdes_audio=self._resolve_sdes_serve_audio(),
3528
+ sdes_audio=_serve_audio,
3463
3529
  audio_gain_db=self._resolve_sdes_audio_gain_db(),
3464
3530
  )
3465
3531
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
@@ -3470,36 +3536,71 @@ class _SdesOpenMixin:
3470
3536
  # video pt, then rewrite the SDP to that single codec before launch.
3471
3537
  # ffmpeg recovers on the next periodic keyframe, so the small spawn delay
3472
3538
  # is harmless. Falls back to the dual-codec SDP if no video is seen.
3539
+ # When audio is being served the same wait has to cover the AUDIO payload
3540
+ # type, and for a stricter reason: an unnarrowed video line merely risks the
3541
+ # picture, while an unnarrowed audio line guarantees the mux never writes its
3542
+ # PAT/PMT, which loses the picture AND the audio. Wait for both, then take
3543
+ # whatever has been observed by the deadline.
3544
+ # Two separate budgets on purpose. Video keeps its original 15s, because
3545
+ # the picture is what the viewer is waiting for. Audio gets only a short
3546
+ # extra grace on top: sharing one deadline would make a camera that never
3547
+ # sends audio delay the picture from a couple of seconds to the full 15,
3548
+ # which trades the bug for a worse one.
3473
3549
  _vpt_deadline = time.monotonic() + 15.0
3474
3550
  while _first_video_pt[0] is None and time.monotonic() < _vpt_deadline:
3475
3551
  await asyncio.sleep(0.1)
3552
+ if _serve_audio and _first_audio_pt[0] is None:
3553
+ _apt_deadline = time.monotonic() + _AUDIO_PT_GRACE_S
3554
+ while _first_audio_pt[0] is None and time.monotonic() < _apt_deadline:
3555
+ await asyncio.sleep(0.1)
3476
3556
  _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)
3557
+ _apt = _first_audio_pt[0]
3558
+ if _serve_audio and _apt is None:
3559
+ # No audio observed in the window, so the line cannot be narrowed and
3560
+ # mapping audio would stall the mux. Serve video only rather than
3561
+ # serving nothing, and say so.
3562
+ _LOGGER.warning(
3563
+ "camera %s: no audio observed before the serve launched, so the "
3564
+ "audio payload type is unknown; mapping it would stall the mpegts "
3565
+ "mux and serve no video. Continuing without audio.",
3566
+ getattr(self, "device_id", "?"),
3567
+ )
3568
+ _serve_audio = False
3569
+ cmd = _build_sdes_serve_cmd(
3570
+ sdp_path=sdp_path,
3571
+ rtsp_push_url=rtsp_push_url,
3572
+ output_path=output_path,
3573
+ max_seconds=max_seconds,
3574
+ sdes_audio=False,
3575
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
3576
+ )
3577
+ # Audio matters even more than video here. A multi-PT m-line makes ffmpeg
3578
+ # bind the depacketizer to the FIRST payload type and silently discard the
3579
+ # rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
3580
+ # has produced a packet - so an audio line advertising "0 8" (PCMU first)
3581
+ # on a camera that sends PCMA discards every audio packet and the consumer
3582
+ # receives zero bytes. That takes the video down with it, which is why
3583
+ # enabling serve audio appeared to break streaming outright.
3584
+ if _vpt in (96, 97) or (_serve_audio and _apt in (0, 8)):
3491
3585
  def _read_sdp_file() -> str:
3492
3586
  with open(sdp_path, encoding="utf-8") as _f_sdp:
3493
3587
  return _f_sdp.read()
3494
3588
  try:
3589
+ _keep_v = int(_vpt) if _vpt in (96, 97) else None
3590
+ _keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
3495
3591
  _cur_sdp = await asyncio.get_running_loop().run_in_executor(
3496
3592
  None, _read_sdp_file)
3497
3593
  await asyncio.get_running_loop().run_in_executor(
3498
3594
  None, _write_text_file, sdp_path,
3499
- _narrow_sdp_to_pt(_cur_sdp, int(_vpt)))
3595
+ narrow_sdp_payload_types(_cur_sdp, _keep_v, _keep_a))
3500
3596
  _status(
3501
- f"SDES: narrowed ffmpeg SDP to video pt={_vpt}"
3502
- f" ({'H265' if int(_vpt) == 97 else 'H264'})")
3597
+ "SDES: narrowed ffmpeg SDP to"
3598
+ + (f" video pt={_keep_v}"
3599
+ f" ({'H265' if _keep_v == 97 else 'H264'})"
3600
+ if _keep_v is not None else "")
3601
+ + (f" audio pt={_keep_a}"
3602
+ f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
3603
+ if _keep_a is not None else ""))
3503
3604
  except Exception:
3504
3605
  _LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_open_sdes_stream', exc_info=True)
3505
3606
  _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.6"
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.6
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