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.
- {python_aidot_cameras-0.7.33/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.35}/PKG-INFO +2 -2
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/README.md +1 -1
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/pyproject.toml +1 -1
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/client.py +133 -96
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35/src/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.7.35/tests/test_sdes_serve_audio.py +90 -0
- python_aidot_cameras-0.7.35/tests/test_sdes_serve_cmd.py +99 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/LICENSE +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/playback.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/protocol.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/client.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/const.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/discover.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/py.typed +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_terminal_ack.py +0 -0
- {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.
|
|
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` |
|
|
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` |
|
|
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.
|
|
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
|
-
#
|
|
9944
|
-
#
|
|
9945
|
-
#
|
|
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
|
|
10012
|
-
#
|
|
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
|
-
|
|
10018
|
-
|
|
10019
|
-
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
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.
|
|
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` |
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_client_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/device_model.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.7.33 → python_aidot_cameras-0.7.35}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|