python-aidot-cameras 0.7.32__tar.gz → 0.7.34__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.32/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.34}/PKG-INFO +2 -2
  2. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/README.md +1 -1
  3. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/pyproject.toml +1 -1
  4. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/client.py +122 -96
  5. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/sdes.py +11 -3
  6. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/device_client.py +23 -2
  7. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
  8. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
  9. python_aidot_cameras-0.7.34/tests/test_device_login_guard.py +95 -0
  10. python_aidot_cameras-0.7.34/tests/test_sdes_serve_audio.py +68 -0
  11. python_aidot_cameras-0.7.34/tests/test_sdes_serve_cmd.py +99 -0
  12. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/LICENSE +0 -0
  13. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/MANIFEST.in +0 -0
  14. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/setup.cfg +0 -0
  15. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/__init__.py +0 -0
  16. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/aes_utils.py +0 -0
  17. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/__init__.py +0 -0
  18. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/constants.py +0 -0
  19. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/controls.py +0 -0
  20. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/go2rtc.py +0 -0
  21. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/lan_control.py +0 -0
  22. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/models.py +0 -0
  23. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/playback.py +0 -0
  24. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/protocol.py +0 -0
  25. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/tutk.py +0 -0
  26. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/camera/webrtc.py +0 -0
  27. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/client.py +0 -0
  28. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/const.py +0 -0
  29. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/credentials.py +0 -0
  30. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/discover.py +0 -0
  31. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/exceptions.py +0 -0
  32. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/g711.py +0 -0
  33. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/login_const.py +0 -0
  34. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/models/__init__.py +0 -0
  35. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/models/device_client_model.py +0 -0
  36. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/models/device_model.py +0 -0
  37. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/models/discover_model.py +0 -0
  38. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/aidot/py.typed +0 -0
  39. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  40. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  41. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  42. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_alarm_event.py +0 -0
  43. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_backoff.py +0 -0
  44. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_go2rtc.py +0 -0
  45. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_highport_nomination.py +0 -0
  46. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_lan_control.py +0 -0
  47. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_live_stream_param.py +0 -0
  48. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_motion_poll.py +0 -0
  49. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_no_undefined_names.py +0 -0
  50. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_persistent_mqtt.py +0 -0
  51. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_post_merge_hardening.py +0 -0
  52. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_adaptive.py +0 -0
  53. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_fast_liveplay.py +0 -0
  54. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_idle_release.py +0 -0
  55. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_sprop.py +0 -0
  56. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_talk.py +0 -0
  57. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_sdes_watchdog.py +0 -0
  58. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_serve_relay.py +0 -0
  59. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_speak.py +0 -0
  60. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_stream_cap.py +0 -0
  61. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_stream_idle.py +0 -0
  62. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_talk.py +0 -0
  63. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/tests/test_terminal_ack.py +0 -0
  64. {python_aidot_cameras-0.7.32 → python_aidot_cameras-0.7.34}/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.32
3
+ Version: 0.7.34
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.32"
7
+ version = "0.7.34"
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
  # --------------------------------------------------------------------------- #
@@ -2632,6 +2720,20 @@ class CameraMixin(_CameraControlsMixin):
2632
2720
  return os.environ.get("AIDOT_SDES_FAST_LIVEPLAY", "").strip().lower() not in (
2633
2721
  "0", "false", "no", "off")
2634
2722
 
2723
+ def _resolve_sdes_serve_audio(self) -> bool:
2724
+ """Whether to include audio in the SDES camera serve.
2725
+
2726
+ **Default ON** for app-parity (the official app plays camera audio). The
2727
+ serve feeds the AAC encoder a continuous silence base (anullsrc + amix) so
2728
+ sparse battery PCMA streams cleanly through the mpegts mux. Per-camera
2729
+ ``sdes_audio`` (via ``start_keepalive``) wins; else the
2730
+ ``AIDOT_SDES_SERVE_AUDIO`` env (falsy = 0/false/no/off disables)."""
2731
+ opt = getattr(self, "_sdes_audio_opt", None)
2732
+ if opt is not None:
2733
+ return bool(opt)
2734
+ return os.environ.get("AIDOT_SDES_SERVE_AUDIO", "").strip().lower() not in (
2735
+ "0", "false", "no", "off")
2736
+
2635
2737
  def _resolve_sdes_skip_turn(self) -> bool:
2636
2738
  """EXPERIMENTAL (opt-in, default off): skip the blocking SDES TURN relay
2637
2739
  pre-allocation, for cameras reachable LAN-direct.
@@ -9939,106 +10041,30 @@ class CameraMixin(_CameraControlsMixin):
9939
10041
  rtsp_push_url = _rewrite_serve_port(rtsp_push_url, _ff_port)
9940
10042
  _relay.set_backend(_ff_port)
9941
10043
  # 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
- ]
10044
+ # go2rtc / HA's stream integration pull it the standard way - the only
10045
+ # per-camera side effect here is the cold-start serve-relay rewrite
10046
+ # above; the destination + audio args (and the PMT-stall rationale for
10047
+ # audio being opt-in) live in _build_sdes_serve_cmd, built once below.
10010
10048
  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.
10049
+ # Ensure the output directory exists before ffmpeg opens the file
10050
+ # (ffmpeg fails with "No such file or directory" otherwise).
10014
10051
  out_dir = os.path.dirname(output_path)
10015
10052
  if out_dir:
10016
10053
  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
- ]
10054
+ # Build the ffmpeg command (single source of truth: _build_sdes_serve_cmd).
10055
+ _sdes_audio = self._resolve_sdes_serve_audio()
10056
+ try:
10057
+ _sdes_gain_db = float(os.environ.get("AIDOT_SDES_AUDIO_GAIN_DB", "-8"))
10058
+ except (ValueError, TypeError):
10059
+ _sdes_gain_db = -8.0
10060
+ cmd = _build_sdes_serve_cmd(
10061
+ sdp_path=sdp_path,
10062
+ rtsp_push_url=rtsp_push_url,
10063
+ output_path=output_path,
10064
+ max_seconds=max_seconds,
10065
+ sdes_audio=bool(_sdes_audio),
10066
+ audio_gain_db=_sdes_gain_db,
10067
+ )
10042
10068
  # --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
10043
10069
  # The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
10044
10070
  # An m=video line listing both ("96 97") makes ffmpeg bind the RTP
@@ -181,15 +181,23 @@ class SdesSession:
181
181
  self._talk_state["stop"] = True
182
182
  self._talk_state["provider"] = None
183
183
  self._proc.terminate()
184
+ _stop_loop = asyncio.get_running_loop()
184
185
  try:
185
- _stop_loop = asyncio.get_running_loop()
186
186
  await _stop_loop.run_in_executor(None, lambda: self._proc.wait(5))
187
187
  except Exception:
188
188
  self._proc.kill()
189
+ # Read drained stderr in the executor with a hard timeout: proc.stderr.read()
190
+ # blocks until EOF, which never arrives if the killed process is still a
191
+ # zombie / stuck in uninterruptible I/O - doing it inline would hang the
192
+ # whole event loop (and thus all of teardown). Bound it so a wedged ffmpeg
193
+ # can't stall the close; we lose only the diagnostic stderr in that case.
189
194
  stderr_bytes = b""
190
195
  try:
191
- stderr_bytes = self._proc.stderr.read()
192
- except Exception:
196
+ stderr_bytes = await asyncio.wait_for(
197
+ _stop_loop.run_in_executor(None, self._proc.stderr.read),
198
+ timeout=2.0,
199
+ )
200
+ except Exception: # incl. asyncio.TimeoutError - never let teardown hang here
193
201
  _LOGGER.debug("camera %s: swallowed exception", 'stop', exc_info=True)
194
202
  if stderr_bytes:
195
203
  _LOGGER.warning("ffmpeg SDES stderr:\n%s", stderr_bytes.decode(errors="replace"))
@@ -229,14 +229,35 @@ class DeviceClient(CameraMixin):
229
229
  if ip is None:
230
230
  return
231
231
  self._ip_address = ip
232
- if self._connecting is not True and self._connect_and_login is not True:
233
- self._login_task = asyncio.create_task(self.async_login())
232
+ if self._connecting is True or self._connect_and_login is True:
233
+ return
234
+ # Throttle (shared 30s window with the reconnect chain): a repeated
235
+ # discovery - or a camera that slipped the _is_camera gate because its
236
+ # model was momentarily unknown - must not spawn a fresh TCP:10000 login
237
+ # every broadcast tick. That hammers a device whose control port refuses
238
+ # (e.g. a battery camera) and spams "login read status error". A device
239
+ # that genuinely needs to connect still does so within 30s.
240
+ now = time.monotonic()
241
+ if now - self._last_login_attempt < 30:
242
+ return
243
+ self._last_login_attempt = now
244
+ self._login_task = asyncio.create_task(self.async_login())
234
245
 
235
246
 
236
247
  async def async_login(self) -> None:
237
248
  """Connect and log in using the last-known IP if not already connected."""
238
249
  if self._ip_address is None:
239
250
  return
251
+ # The base TCP:10000 control channel is the LIGHT protocol. Cameras never
252
+ # use it - their local control is the separate CameraLanClient
253
+ # (camera/lan_control.py) and their LAN IP comes from WebRTC signaling. A
254
+ # camera reaching here means a discovered IP slipped the _is_camera gate;
255
+ # logging in would hammer a port the camera doesn't serve and spam
256
+ # "login read status error" (and never connect). This is the single
257
+ # chokepoint for both the discovery and reconnect-chain login paths.
258
+ model = getattr(getattr(self, "info", None), "model_id", "") or ""
259
+ if "IPC" in model:
260
+ return
240
261
  if self._connecting is not True and self._connect_and_login is not True:
241
262
  await self.connect(self._ip_address)
242
263
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.32
3
+ Version: 0.7.34
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` |
@@ -36,6 +36,7 @@ src/python_aidot_cameras.egg-info/requires.txt
36
36
  src/python_aidot_cameras.egg-info/top_level.txt
37
37
  tests/test_alarm_event.py
38
38
  tests/test_backoff.py
39
+ tests/test_device_login_guard.py
39
40
  tests/test_go2rtc.py
40
41
  tests/test_highport_nomination.py
41
42
  tests/test_lan_control.py
@@ -47,6 +48,8 @@ tests/test_post_merge_hardening.py
47
48
  tests/test_sdes_adaptive.py
48
49
  tests/test_sdes_fast_liveplay.py
49
50
  tests/test_sdes_idle_release.py
51
+ tests/test_sdes_serve_audio.py
52
+ tests/test_sdes_serve_cmd.py
50
53
  tests/test_sdes_sprop.py
51
54
  tests/test_sdes_talk.py
52
55
  tests/test_sdes_watchdog.py
@@ -0,0 +1,95 @@
1
+ """Unit tests for the base-DeviceClient local-login guard + throttle.
2
+
3
+ The TCP:10000 control channel is the LIGHT protocol; cameras must never use it
4
+ (they use the separate CameraLanClient + WebRTC signaling for their LAN IP). A
5
+ camera reaching async_login would hammer a refusing port and spam
6
+ "login read status error". These tests lock the camera-exclusion and the
7
+ re-login throttle without a real socket.
8
+ """
9
+ import asyncio
10
+ import os
11
+ import sys
12
+ from types import SimpleNamespace
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
15
+
16
+ import aidot.device_client as dc_mod
17
+
18
+ DeviceClient = dc_mod.DeviceClient
19
+
20
+
21
+ def _client(model_id):
22
+ c = DeviceClient.__new__(DeviceClient)
23
+ c.info = SimpleNamespace(model_id=model_id)
24
+ c._ip_address = "192.168.1.50"
25
+ c._connecting = False
26
+ c._connect_and_login = False
27
+ c._last_login_attempt = 0.0
28
+ c._connected = [] # record connect() calls
29
+ async def _fake_connect(ip):
30
+ c._connected.append(ip)
31
+ c.connect = _fake_connect
32
+ return c
33
+
34
+
35
+ def test_camera_never_does_base_login():
36
+ cam = _client("LK.IPC.A001513") # battery SDES camera
37
+ asyncio.run(cam.async_login())
38
+ assert cam._connected == [] # camera excluded - no TCP:10000 login
39
+
40
+
41
+ def test_ptz_camera_excluded():
42
+ cam = _client("LK.IPC.A001064")
43
+ asyncio.run(cam.async_login())
44
+ assert cam._connected == []
45
+
46
+
47
+ def test_light_still_logs_in():
48
+ light = _client("lk.WIFI-RGBWLight-D0006")
49
+ asyncio.run(light.async_login())
50
+ assert light._connected == ["192.168.1.50"] # lights keep the base login
51
+
52
+
53
+ def test_unknown_model_logs_in():
54
+ # No "IPC" in model -> treated as a (light) device that uses the base channel.
55
+ dev = _client("")
56
+ asyncio.run(dev.async_login())
57
+ assert dev._connected == ["192.168.1.50"]
58
+
59
+
60
+ def test_update_ip_address_throttles_relogin():
61
+ light = _client("lk.WIFI-RGBWLight-D0006")
62
+ spawned = []
63
+ # update_ip_address schedules async_login via create_task; capture the calls
64
+ # by counting how many times the throttle lets a login attempt through.
65
+ light._login_task = None
66
+ orig = dc_mod.asyncio.create_task
67
+
68
+ def _count_task(coro):
69
+ spawned.append(1)
70
+ coro.close() # don't actually run the login
71
+ return SimpleNamespace(done=lambda: True, cancel=lambda: None)
72
+
73
+ dc_mod.asyncio.create_task = _count_task
74
+ try:
75
+ light.update_ip_address("192.168.1.50") # first: allowed
76
+ light.update_ip_address("192.168.1.50") # immediate repeat: throttled
77
+ light.update_ip_address("192.168.1.50") # still throttled
78
+ finally:
79
+ dc_mod.asyncio.create_task = orig
80
+ assert len(spawned) == 1, f"expected 1 login within the 30s window, got {len(spawned)}"
81
+
82
+
83
+ if __name__ == "__main__":
84
+ import traceback
85
+ _fail = 0
86
+ for _k, _v in sorted(globals().items()):
87
+ if _k.startswith("test_"):
88
+ try:
89
+ _v()
90
+ print(f"PASS {_k}")
91
+ except Exception:
92
+ _fail += 1
93
+ print(f"FAIL {_k}")
94
+ traceback.print_exc()
95
+ raise SystemExit(1 if _fail else 0)
@@ -0,0 +1,68 @@
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
+ if __name__ == "__main__":
52
+ import traceback
53
+
54
+ class _MP:
55
+ def setenv(self, k, v): os.environ[k] = v
56
+ def delenv(self, k, raising=False): os.environ.pop(k, None)
57
+
58
+ _fail = 0
59
+ for _k, _v in sorted(globals().items()):
60
+ if _k.startswith("test_"):
61
+ try:
62
+ _v(_MP()) if _v.__code__.co_argcount else _v()
63
+ print(f"PASS {_k}")
64
+ except Exception:
65
+ _fail += 1
66
+ print(f"FAIL {_k}")
67
+ traceback.print_exc()
68
+ 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