python-aidot-cameras 0.7.33__tar.gz → 0.7.35__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 (64) hide show
  1. {python_aidot_cameras-0.7.33/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.35}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/README.md +1 -1
  3. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/client.py +133 -96
  5. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  6. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
  7. python_aidot_cameras-0.7.35/tests/test_sdes_serve_audio.py +90 -0
  8. python_aidot_cameras-0.7.35/tests/test_sdes_serve_cmd.py +99 -0
  9. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/LICENSE +0 -0
  10. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/MANIFEST.in +0 -0
  11. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/setup.cfg +0 -0
  12. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/__init__.py +0 -0
  13. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/aes_utils.py +0 -0
  14. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/__init__.py +0 -0
  15. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/constants.py +0 -0
  16. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/controls.py +0 -0
  17. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/go2rtc.py +0 -0
  18. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/lan_control.py +0 -0
  19. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/models.py +0 -0
  20. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/playback.py +0 -0
  21. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/protocol.py +0 -0
  22. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/sdes.py +0 -0
  23. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/tutk.py +0 -0
  24. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/webrtc.py +0 -0
  25. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/client.py +0 -0
  26. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/const.py +0 -0
  27. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/credentials.py +0 -0
  28. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/device_client.py +0 -0
  29. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/discover.py +0 -0
  30. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/exceptions.py +0 -0
  31. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/g711.py +0 -0
  32. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/login_const.py +0 -0
  33. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/__init__.py +0 -0
  34. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_client_model.py +0 -0
  35. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_model.py +0 -0
  36. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/discover_model.py +0 -0
  37. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/py.typed +0 -0
  38. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  39. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  40. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  41. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_alarm_event.py +0 -0
  42. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_backoff.py +0 -0
  43. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_device_login_guard.py +0 -0
  44. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_go2rtc.py +0 -0
  45. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_highport_nomination.py +0 -0
  46. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_lan_control.py +0 -0
  47. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_live_stream_param.py +0 -0
  48. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_motion_poll.py +0 -0
  49. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_no_undefined_names.py +0 -0
  50. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_persistent_mqtt.py +0 -0
  51. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_post_merge_hardening.py +0 -0
  52. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_adaptive.py +0 -0
  53. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_fast_liveplay.py +0 -0
  54. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_idle_release.py +0 -0
  55. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_sprop.py +0 -0
  56. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_talk.py +0 -0
  57. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_watchdog.py +0 -0
  58. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_serve_relay.py +0 -0
  59. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_speak.py +0 -0
  60. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_stream_cap.py +0 -0
  61. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_stream_idle.py +0 -0
  62. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_talk.py +0 -0
  63. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_terminal_ack.py +0 -0
  64. {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_token_refresh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.33
3
+ Version: 0.7.35
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
@@ -140,5 +140,5 @@ chosen to work out of the box; override only when tuning.
140
140
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
141
141
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
142
142
  | `AIDOT_AUDIO_GATE_DBFS` | Noise-gate threshold (dBFS) for two-way audio. | `-45` |
143
- | `AIDOT_SDES_SERVE_AUDIO` | Set to `1` to serve audio on SDES cameras (off by default). | `0` (off) |
143
+ | `AIDOT_SDES_SERVE_AUDIO` | Serve audio on SDES cameras (**on by default** — a silence-base mix keeps the audio encoder fed so battery-camera audio streams smoothly). Set to `0`/`false`/`no`/`off` to disable. | on |
144
144
  | `AIDOT_SDES_AUDIO_GAIN_DB` | Gain (dB) applied when SDES audio is served. | `-8` |
@@ -113,5 +113,5 @@ chosen to work out of the box; override only when tuning.
113
113
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
114
114
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
115
115
  | `AIDOT_AUDIO_GATE_DBFS` | Noise-gate threshold (dBFS) for two-way audio. | `-45` |
116
- | `AIDOT_SDES_SERVE_AUDIO` | Set to `1` to serve audio on SDES cameras (off by default). | `0` (off) |
116
+ | `AIDOT_SDES_SERVE_AUDIO` | Serve audio on SDES cameras (**on by default** — a silence-base mix keeps the audio encoder fed so battery-camera audio streams smoothly). Set to `0`/`false`/`no`/`off` to disable. | on |
117
117
  | `AIDOT_SDES_AUDIO_GAIN_DB` | Gain (dB) applied when SDES audio is served. | `-8` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-aidot-cameras"
7
- version = "0.7.33"
7
+ version = "0.7.35"
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"
@@ -187,6 +187,94 @@ def _save_frame_as_jpeg(image_data: Any, output_path: str) -> bool:
187
187
  return False
188
188
 
189
189
 
190
+ def _build_sdes_serve_cmd(
191
+ *,
192
+ sdp_path: str,
193
+ rtsp_push_url: Optional[str] = None,
194
+ output_path: Optional[str] = None,
195
+ max_seconds: Optional[float] = None,
196
+ sdes_audio: bool = False,
197
+ audio_gain_db: float = -8.0,
198
+ ) -> list:
199
+ """Build the ffmpeg argv for the SDES bridge serve.
200
+
201
+ Pure (no I/O - the caller creates any output directory and resolves
202
+ ``sdes_audio`` / ``audio_gain_db`` from opts/env). Four destinations:
203
+
204
+ - ``rtsp_push_url`` is http(s) -> PULL model: serve mpegts over an
205
+ ``-listen`` socket so go2rtc / HA's stream integration pull it (no go2rtc
206
+ pre-registration).
207
+ - ``rtsp_push_url`` is anything else -> legacy PUSH model: publish to an RTSP
208
+ server (``-f rtsp``), for callers running their own pre-registered go2rtc.
209
+ - ``output_path`` -> record to file.
210
+ - neither -> ``-f null`` (keep ffmpeg alive draining SRTP, write nothing).
211
+
212
+ AUDIO (the http-listen serve only): the mpegts mux writes its PAT/PMT once
213
+ every mapped stream has produced a packet. Video is ``-c:v copy`` (parameters
214
+ known from the SDP at once); the ``-c:a aac`` encoder needs PCM samples to emit
215
+ a frame, and battery cameras send PCMA sparsely. So we feed the encoder a
216
+ continuous ``anullsrc`` silence base and ``amix`` the real PCMA over it
217
+ (``normalize=0`` -> the 0-valued silence is a no-op whenever real audio is
218
+ present): the encoder is fed from t=0 so the PMT writes promptly and any gaps
219
+ are filled with silence. go2rtc/HA pulls this mpegts the same way as the
220
+ video-only serve, so the pull model + cold-start relay are unchanged.
221
+ ``volume`` is the stateless hot-mic trim (dynamic normalizers regressed the
222
+ pipe in testing). File recording (snapshots/diagnostics) is always -c copy."""
223
+ time_args = ["-t", str(int(max_seconds))] if max_seconds else []
224
+
225
+ if rtsp_push_url and rtsp_push_url.startswith("http"):
226
+ if sdes_audio:
227
+ dest_args = [
228
+ # Silence-base mix: a continuous a-law-rate anullsrc keeps the AAC
229
+ # encoder fed from t=0 so the mpegts PMT writes promptly on sparse
230
+ # battery PCMA; real audio mixes over the 0-valued silence
231
+ # (normalize=0 -> no-op when audio is present).
232
+ "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono",
233
+ "-filter_complex",
234
+ ("[0:a]aresample=async=1[a0];"
235
+ "[a0][1:a]amix=inputs=2:duration=longest:normalize=0,"
236
+ f"volume={audio_gain_db}dB[aout]"),
237
+ "-map", "0:v:0", "-map", "[aout]",
238
+ "-c:v", "copy", "-c:a", "aac", "-ar", "48000", "-b:a", "128k",
239
+ *time_args,
240
+ "-f", "mpegts", "-listen", "1",
241
+ rtsp_push_url,
242
+ ]
243
+ else:
244
+ dest_args = [
245
+ "-c:v", "copy", "-an",
246
+ *time_args,
247
+ "-f", "mpegts", "-listen", "1",
248
+ rtsp_push_url,
249
+ ]
250
+ elif rtsp_push_url:
251
+ dest_args = [
252
+ "-c", "copy",
253
+ *time_args,
254
+ "-f", "rtsp", "-rtsp_transport", "tcp",
255
+ rtsp_push_url,
256
+ ]
257
+ elif output_path:
258
+ # File recording (snapshots, diagnostics) is always a plain copy of the
259
+ # narrowed SDP - the audio mix is for the live serve, not file capture.
260
+ dest_args = ["-c", "copy", *time_args, output_path]
261
+ else:
262
+ dest_args = [*time_args, "-f", "null", "/dev/null"]
263
+ return [
264
+ "ffmpeg", "-y",
265
+ "-loglevel", "warning",
266
+ "-protocol_whitelist", "file,rtp,udp,srtp",
267
+ # 2 s analyzeduration: the camera sends SPS+PPS+IDR in the first burst;
268
+ # 15 s consumed nearly all packets during analysis. PLI re-arms an IDR
269
+ # every 5 s so parameters always re-arrive.
270
+ "-fflags", "+nobuffer+genpts",
271
+ "-analyzeduration", "2000000",
272
+ "-probesize", "500000",
273
+ "-i", sdp_path,
274
+ *dest_args,
275
+ ]
276
+
277
+
190
278
  # --------------------------------------------------------------------------- #
191
279
  # TCP binary framing helpers
192
280
  # --------------------------------------------------------------------------- #
@@ -1983,6 +2071,7 @@ class CameraMixin(_CameraControlsMixin):
1983
2071
  sdes_fast_liveplay: Optional[bool] = None,
1984
2072
  sdes_skip_turn: Optional[bool] = None,
1985
2073
  sdes_adaptive: Optional[bool] = None,
2074
+ sdes_audio_gain_db: Optional[float] = None,
1986
2075
  ) -> None:
1987
2076
  """Start a persistent stream that keeps the camera session alive.
1988
2077
 
@@ -2025,6 +2114,8 @@ class CameraMixin(_CameraControlsMixin):
2025
2114
  self._fast_connect_opt = fast_connect
2026
2115
  if sdes_audio is not None:
2027
2116
  self._sdes_audio_opt = sdes_audio
2117
+ if sdes_audio_gain_db is not None:
2118
+ self._sdes_audio_gain_opt = sdes_audio_gain_db
2028
2119
  if live_stream_param is not None:
2029
2120
  self._live_stream_param_opt = live_stream_param
2030
2121
  if serve_relay is not None:
@@ -2632,6 +2723,33 @@ class CameraMixin(_CameraControlsMixin):
2632
2723
  return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() not in (
2633
2724
  "0", "false", "no", "off")
2634
2725
 
2726
+ def _resolve_sdes_serve_audio(self) -> bool:
2727
+ """Whether to include audio in the SDES camera serve.
2728
+
2729
+ **Default ON** for app-parity (the official app plays camera audio). The
2730
+ serve feeds the AAC encoder a continuous silence base (anullsrc + amix) so
2731
+ sparse battery PCMA streams cleanly through the mpegts mux. Per-camera
2732
+ ``sdes_audio`` (via ``start_keepalive``) wins; else the
2733
+ ``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
2734
+ opt = getattr(self, "_sdes_audio_opt", None)
2735
+ if opt is not None:
2736
+ return bool(opt)
2737
+ return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() not in (
2738
+ "0", "false", "no", "off")
2739
+
2740
+ def _resolve_sdes_audio_gain_db(self) -> float:
2741
+ """Gain (dB) applied to the served SDES audio (the camera mic runs hot).
2742
+
2743
+ Per-camera ``sdes_audio_gain_db`` (via ``start_keepalive``) wins; else the
2744
+ ``AIDOT_SDES_AUDIO_GAIN_DB`` env; else ``-8``. A bad value falls back to
2745
+ the default rather than raising."""
2746
+ opt = getattr(self, "_sdes_audio_gain_opt", None)
2747
+ src = opt if opt is not None else os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
2748
+ try:
2749
+ return float(src)
2750
+ except (ValueError, TypeError):
2751
+ return -8.0
2752
+
2635
2753
  def _resolve_sdes_skip_turn(self) -> bool:
2636
2754
  """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
2637
2755
  pre-allocation, for cameras reachable LAN-direct.
@@ -9939,106 +10057,25 @@ class CameraMixin(_CameraControlsMixin):
9939
10057
  rtsp_push_url = _rewrite_serve_port(rtsp_push_url, _ff_port)
9940
10058
  _relay.set_backend(_ff_port)
9941
10059
  # PULL model: SERVE the decrypted stream over an HTTP-listen socket so
9942
- # go2rtc / HA's stream integration pull it the standard way
9943
- # (streams.add) - no go2rtc pre-registration needed. ffmpeg -listen 1
9944
- # blocks on accept() until the consumer connects, which go2rtc/HA do
9945
- # promptly after stream_source() returns; PLI re-arms an IDR so a
9946
- # late consumer still gets decodable frames within ~5 s.
9947
- #
9948
- # VIDEO-ONLY BY DEFAULT (the AAC audio transcode was DEADLOCKING the
9949
- # serve under loss; root-caused live 2026-06-08). The mpegts muxer
9950
- # cannot write its PAT/PMT until every mapped output stream has
9951
- # produced a first packet. `-c:v copy` knows the H.264 params from
9952
- # the input SDP immediately, but `-c:a aac` must ENCODE a first AAC
9953
- # frame, which needs real PCMA samples. On a battery camera over a
9954
- # weak uplink the PCMA (PT=8) audio arrives sparse/late, the encoder
9955
- # yields "No filtered frames for output stream", the PMT is never
9956
- # written, and the consumer (go2rtc/HA) receives ZERO bytes -> the
9957
- # dashboard spins forever with no error and no timeout. Proven by a
9958
- # serve-path A/B: production AAC config -> 0 bytes; `-an` -> 3.8 MB /
9959
- # 408 frames / 10 IDRs in 25 s. (`-max_interleave_delta 0` does NOT
9960
- # rescue it - the block is the PMT, not interleaving.) So drop audio
9961
- # by default and let video flow; the H.264 is `-c:v copy` untouched.
9962
- #
9963
- # Opt back into audio with AIDOT_SDES_SERVE_AUDIO=1 (e.g. a strong-
9964
- # signal/mains camera where PCMA arrives densely enough to keep the
9965
- # AAC encoder fed). When enabled: transcode PCMA->AAC@48k with a
9966
- # STATELESS `volume` trim (the mic runs hot; default -8 dB, env-tunable
9967
- # via AIDOT_SDES_AUDIO_GAIN_DB). Dynamic normalizers (dynaudnorm/
9968
- # loudnorm) and `-max_interleave_delta 0` are avoided - both regressed
9969
- # the pipe (buffering / ~100 ms audio cutouts) in earlier live tests.
9970
- _sdes_audio = getattr(self, "_sdes_audio_opt", None)
9971
- if _sdes_audio is None:
9972
- _sdes_audio = os.environ.get("AIDOT_SDES_SERVE_AUDIO", "0") == "1"
9973
- if _sdes_audio:
9974
- try:
9975
- _sdes_gain_db = float(
9976
- os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8")
9977
- )
9978
- except (ValueError, TypeError):
9979
- _sdes_gain_db = -8.0
9980
- dest_args = [
9981
- "-c:v", "copy",
9982
- # aresample=async=1 fills timing gaps with silence so the AAC
9983
- # encoder always has a full 1024-sample frame to emit. Battery
9984
- # cameras send PCMA sparsely (radio duty-cycling + weak uplink);
9985
- # without this the encoder starves, the mpegts PAT/PMT is never
9986
- # written, and the consumer gets ZERO bytes - the whole stream
9987
- # dies. first_pts=0 anchors the clock so the first AAC frame
9988
- # (and thus the PMT) is produced promptly at stream start.
9989
- "-af", f"aresample=async=1:first_pts=0,volume={_sdes_gain_db}dB",
9990
- "-c:a", "aac", "-ar", "48000", "-b:a", "128k",
9991
- "-f", "mpegts", "-listen", "1",
9992
- rtsp_push_url,
9993
- ]
9994
- else:
9995
- dest_args = [
9996
- "-c:v", "copy", "-an",
9997
- "-f", "mpegts", "-listen", "1",
9998
- rtsp_push_url,
9999
- ]
10000
- elif rtsp_push_url:
10001
- # Legacy PUSH model: publish to an RTSP server (e.g. go2rtc at :8554).
10002
- # Requires the stream to be pre-registered in go2rtc; retained for
10003
- # non-HA callers that run their own go2rtc config. -rtsp_transport tcp
10004
- # avoids UDP fragmentation on loopback.
10005
- dest_args = [
10006
- "-c", "copy",
10007
- "-f", "rtsp", "-rtsp_transport", "tcp",
10008
- rtsp_push_url,
10009
- ]
10060
+ # go2rtc / HA's stream integration pull it the standard way - the only
10061
+ # per-camera side effect here is the cold-start serve-relay rewrite
10062
+ # above; the destination + audio args (and the PMT-stall rationale for
10063
+ # audio being opt-in) live in _build_sdes_serve_cmd, built once below.
10010
10064
  elif output_path:
10011
- # Ensure the output directory exists before ffmpeg tries to open the
10012
- # file. ffmpeg fails with "No such file or directory" if the parent
10013
- # directory is missing.
10065
+ # Ensure the output directory exists before ffmpeg opens the file
10066
+ # (ffmpeg fails with "No such file or directory" otherwise).
10014
10067
  out_dir = os.path.dirname(output_path)
10015
10068
  if out_dir:
10016
10069
  os.makedirs(out_dir, exist_ok=True)
10017
- dest_args = ["-c", "copy", output_path]
10018
- else:
10019
- # -c copy /dev/null fails: "Unable to find a suitable output format
10020
- # for '/dev/null'". Use the null muxer instead so ffmpeg stays
10021
- # alive and receives the SRTP stream without writing anything.
10022
- dest_args = ["-f", "null", "/dev/null"]
10023
- # Output-side duration limit: -t N stops ffmpeg after N seconds of
10024
- # encoded output. The bridge thread detects ffmpeg exit via proc.poll()
10025
- # and stops forwarding + sending keepalives automatically.
10026
- _time_args = ["-t", str(int(max_seconds))] if max_seconds else []
10027
- cmd = [
10028
- "ffmpeg", "-y",
10029
- "-loglevel", "warning",
10030
- "-protocol_whitelist", "file,rtp,udp,srtp",
10031
- # 2 s analyzeduration: the camera sends SPS+PPS+IDR in the very
10032
- # first burst (seq=0,1,2...). 15 s consumed nearly all packets
10033
- # during analysis, leaving only 1s of output in a 30s test.
10034
- # PLI forces a new IDR every 5 s, so parameters always re-arrive.
10035
- "-fflags", "+nobuffer+genpts",
10036
- "-analyzeduration", "2000000",
10037
- "-probesize", "500000",
10038
- "-i", sdp_path,
10039
- *_time_args,
10040
- *dest_args,
10041
- ]
10070
+ # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
10071
+ cmd = _build_sdes_serve_cmd(
10072
+ sdp_path=sdp_path,
10073
+ rtsp_push_url=rtsp_push_url,
10074
+ output_path=output_path,
10075
+ max_seconds=max_seconds,
10076
+ sdes_audio=self._resolve_sdes_serve_audio(),
10077
+ audio_gain_db=self._resolve_sdes_audio_gain_db(),
10078
+ )
10042
10079
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
10043
10080
  # The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
10044
10081
  # An m=video line listing both ("96 97") makes ffmpeg bind the RTP
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.33
3
+ Version: 0.7.35
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
@@ -140,5 +140,5 @@ chosen to work out of the box; override only when tuning.
140
140
  | `AIDOT_AUDIO_MAXGAIN_DB` | Maximum gain (dB) applied by the audio normalizer. | `30` |
141
141
  | `AIDOT_AUDIO_MINGAIN_DB` | Minimum gain (dB) applied by the audio normalizer. | `-12` |
142
142
  | `AIDOT_AUDIO_GATE_DBFS` | Noise-gate threshold (dBFS) for two-way audio. | `-45` |
143
- | `AIDOT_SDES_SERVE_AUDIO` | Set to `1` to serve audio on SDES cameras (off by default). | `0` (off) |
143
+ | `AIDOT_SDES_SERVE_AUDIO` | Serve audio on SDES cameras (**on by default** — a silence-base mix keeps the audio encoder fed so battery-camera audio streams smoothly). Set to `0`/`false`/`no`/`off` to disable. | on |
144
144
  | `AIDOT_SDES_AUDIO_GAIN_DB` | Gain (dB) applied when SDES audio is served. | `-8` |
@@ -48,6 +48,8 @@ tests/test_post_merge_hardening.py
48
48
  tests/test_sdes_adaptive.py
49
49
  tests/test_sdes_fast_liveplay.py
50
50
  tests/test_sdes_idle_release.py
51
+ tests/test_sdes_serve_audio.py
52
+ tests/test_sdes_serve_cmd.py
51
53
  tests/test_sdes_sprop.py
52
54
  tests/test_sdes_talk.py
53
55
  tests/test_sdes_watchdog.py
@@ -0,0 +1,90 @@
1
+ """Unit tests for _resolve_sdes_serve_audio - SDES serve audio (default ON).
2
+
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. The
5
+ silence-mix's real-world stability is covered by live soak.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
11
+
12
+ import aidot.camera.client as cc
13
+
14
+ _CAM = next(v for v in vars(cc).values()
15
+ if isinstance(v, type) and "_resolve_sdes_serve_audio" in v.__dict__)
16
+
17
+
18
+ def _cam():
19
+ return _CAM.__new__(_CAM)
20
+
21
+
22
+ def test_default_on(monkeypatch):
23
+ monkeypatch.delenv("AIDOT_SDES_SERVE_AUDIO", raising=False)
24
+ assert _cam()._resolve_sdes_serve_audio() is True
25
+
26
+
27
+ def test_env_disables(monkeypatch):
28
+ for val in ("0", "false", "no", "off", " Off "):
29
+ monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", val)
30
+ assert _cam()._resolve_sdes_serve_audio() is False, val
31
+
32
+
33
+ def test_env_truthy_or_unknown_stays_on(monkeypatch):
34
+ for val in ("1", "true", "yes", "on", "", "anything"):
35
+ monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", val)
36
+ assert _cam()._resolve_sdes_serve_audio() is True, val
37
+
38
+
39
+ def test_kwarg_option_wins_over_env(monkeypatch):
40
+ monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", "1")
41
+ cam = _cam()
42
+ cam._sdes_audio_opt = False # start_keepalive(sdes_audio=False)
43
+ assert cam._resolve_sdes_serve_audio() is False
44
+
45
+ cam2 = _cam()
46
+ monkeypatch.setenv("AIDOT_SDES_SERVE_AUDIO", "0")
47
+ cam2._sdes_audio_opt = True
48
+ assert cam2._resolve_sdes_serve_audio() is True
49
+
50
+
51
+ def test_gain_default(monkeypatch):
52
+ monkeypatch.delenv("AIDOT_SDES_AUDIO_GAIN_DB", raising=False)
53
+ assert _cam()._resolve_sdes_audio_gain_db() == -8.0
54
+
55
+
56
+ def test_gain_env(monkeypatch):
57
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "-3.5")
58
+ assert _cam()._resolve_sdes_audio_gain_db() == -3.5
59
+
60
+
61
+ def test_gain_opt_wins(monkeypatch):
62
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "-3")
63
+ cam = _cam()
64
+ cam._sdes_audio_gain_opt = 2.0
65
+ assert cam._resolve_sdes_audio_gain_db() == 2.0
66
+
67
+
68
+ def test_gain_bad_value_falls_back(monkeypatch):
69
+ monkeypatch.setenv("AIDOT_SDES_AUDIO_GAIN_DB", "loud")
70
+ assert _cam()._resolve_sdes_audio_gain_db() == -8.0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ import traceback
75
+
76
+ class _MP:
77
+ def setenv(self, k, v): os.environ[k] = v
78
+ def delenv(self, k, raising=False): os.environ.pop(k, None)
79
+
80
+ _fail = 0
81
+ for _k, _v in sorted(globals().items()):
82
+ if _k.startswith("test_"):
83
+ try:
84
+ _v(_MP()) if _v.__code__.co_argcount else _v()
85
+ print(f"PASS {_k}")
86
+ except Exception:
87
+ _fail += 1
88
+ print(f"FAIL {_k}")
89
+ traceback.print_exc()
90
+ raise SystemExit(1 if _fail else 0)
@@ -0,0 +1,99 @@
1
+ """Unit tests for _build_sdes_serve_cmd - the SDES bridge ffmpeg command builder.
2
+
3
+ This is the pure, side-effect-free seam extracted so every serve destination
4
+ (http-listen pull / RTSP push / file / null) and the audio trade-off can be
5
+ asserted without a live camera. The real-time PMT-stall behaviour on sparse
6
+ battery PCMA is validated by live soak, not here.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
12
+
13
+ from aidot.camera.client import _build_sdes_serve_cmd as build
14
+
15
+
16
+ def _idx(cmd, item):
17
+ return cmd.index(item)
18
+
19
+
20
+ def test_http_pull_video_only_default():
21
+ cmd = build(sdp_path="/x.sdp", rtsp_push_url="http://127.0.0.1:9000")
22
+ assert cmd[0] == "ffmpeg"
23
+ assert "-c:v" in cmd and cmd[_idx(cmd, "-c:v") + 1] == "copy"
24
+ assert "-an" in cmd # audio dropped by default
25
+ assert "-c:a" not in cmd # no AAC encoder
26
+ assert cmd[-3:] == ["-listen", "1", "http://127.0.0.1:9000"]
27
+
28
+
29
+ def test_http_pull_with_audio_silence_mix():
30
+ cmd = build(sdp_path="/x.sdp", rtsp_push_url="http://127.0.0.1:9000",
31
+ sdes_audio=True, audio_gain_db=-8.0)
32
+ assert "-an" not in cmd
33
+ assert cmd[_idx(cmd, "-c:a") + 1] == "aac"
34
+ # Continuous silence second input keeps the encoder fed from t=0.
35
+ assert "anullsrc=r=8000:cl=mono" in cmd
36
+ fc = cmd[_idx(cmd, "-filter_complex") + 1]
37
+ assert "amix=inputs=2:duration=longest:normalize=0" in fc # silence-base mix
38
+ assert "volume=-8.0dB" in fc
39
+ # explicit maps: copied video + mixed audio
40
+ assert "0:v:0" in cmd and "[aout]" in cmd
41
+ # output muxer is mpegts (the first -f is the lavfi silence input)
42
+ assert "mpegts" in cmd and cmd[cmd.index("mpegts") - 1] == "-f"
43
+ assert cmd[-4:] == ["mpegts", "-listen", "1", "http://127.0.0.1:9000"]
44
+
45
+
46
+ def test_audio_gain_is_parameterised():
47
+ cmd = build(sdp_path="/x.sdp", rtsp_push_url="http://h", sdes_audio=True, audio_gain_db=-3.5)
48
+ assert "volume=-3.5dB" in cmd[_idx(cmd, "-filter_complex") + 1]
49
+
50
+
51
+ def test_video_copy_present_even_with_audio():
52
+ # The whole point: video is always -c:v copy, never gated on audio, so an
53
+ # audio-side stall can't block the video PMT being known from the SDP.
54
+ for audio in (False, True):
55
+ cmd = build(sdp_path="/x.sdp", rtsp_push_url="http://h", sdes_audio=audio)
56
+ assert cmd[_idx(cmd, "-c:v") + 1] == "copy"
57
+
58
+
59
+ def test_file_recording_is_always_copy_even_with_audio():
60
+ # File output (snapshots/diagnostics) never transcodes audio - the silence
61
+ # mix is for the live serve only. Keeps snapshots a fast video-only copy and
62
+ # avoids referencing [0:a] on a video-narrowed SDP.
63
+ for ext in ("ts", "mkv", "mp4"):
64
+ cmd = build(sdp_path="/x.sdp", output_path=f"/tmp/rec.{ext}", sdes_audio=True, max_seconds=30)
65
+ assert "anullsrc" not in " ".join(cmd), ext
66
+ assert cmd[_idx(cmd, "-c") + 1] == "copy", ext
67
+ assert cmd[-1] == f"/tmp/rec.{ext}", ext
68
+
69
+
70
+ def test_rtsp_push_copies_both():
71
+ cmd = build(sdp_path="/x.sdp", rtsp_push_url="rtsp://127.0.0.1:8554/cam")
72
+ assert cmd[_idx(cmd, "-c") + 1] == "copy"
73
+ assert cmd[_idx(cmd, "-f") + 1] == "rtsp"
74
+ assert cmd[-1] == "rtsp://127.0.0.1:8554/cam"
75
+
76
+
77
+ def test_file_output():
78
+ cmd = build(sdp_path="/x.sdp", output_path="/tmp/o.ts")
79
+ assert cmd[-2:] == ["-c", "copy"][:1] + ["/tmp/o.ts"] or cmd[-1] == "/tmp/o.ts"
80
+ assert cmd[-1] == "/tmp/o.ts"
81
+
82
+
83
+ def test_null_when_no_destination():
84
+ cmd = build(sdp_path="/x.sdp")
85
+ assert cmd[-2:] == ["-f", "null"][:1] + ["/dev/null"] or cmd[-1] == "/dev/null"
86
+ assert "null" in cmd
87
+
88
+
89
+ def test_max_seconds_adds_t_flag():
90
+ cmd = build(sdp_path="/x.sdp", output_path="/tmp/o.ts", max_seconds=30)
91
+ assert cmd[_idx(cmd, "-t") + 1] == "30"
92
+ # no -t when unset
93
+ assert "-t" not in build(sdp_path="/x.sdp", output_path="/tmp/o.ts")
94
+
95
+
96
+ def test_sdp_input_always_present():
97
+ cmd = build(sdp_path="/path/to/session.sdp")
98
+ assert cmd[_idx(cmd, "-i") + 1] == "/path/to/session.sdp"
99
+ assert "file,rtp,udp,srtp" in cmd