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