Skip to content

Commit b295ec7

Browse files
committed
Fix media pipelines, recording lifecycle, signaling, and service installation
Preserve cross-platform capture and encoder fallbacks, validate configuration and signaling inputs, clean up failed recording setup, and improve operational guides. Fix portable HLS serving and room NDI codec dispatch. Validated with 149 application tests, 218 external regressions, and focused Pi 3/Pi 5 checks.
1 parent 402ef35 commit b295ec7

13 files changed

Lines changed: 1449 additions & 644 deletions

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,14 @@ When changing installers, device discovery, camera handling, media pipelines, or
2424
- Add focused regression coverage for detection, selection, fallback, and version-specific behavior whenever practical.
2525

2626
Treat broad support across these variations as a core product requirement rather than an optional enhancement.
27+
28+
## Hardware and regression testing
29+
30+
- Physical Raspberry Pi boards are available on the network. Use the sibling
31+
`../raspberry_ninja_qa` repository's local inventory and SSH configuration to
32+
discover and test reachable boards; do not assume hardware is unavailable.
33+
- Keep new hardware harnesses, integration regressions, scaffolding, and generated
34+
reports in that QA repository. Keep the application repository lean; retain
35+
existing focused tests and avoid duplicating the external harness here.
36+
- Read the QA repository's instructions before changing it. Keep credentials and
37+
local device addresses out of committed documentation and reports.

QUICK_START.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ selected service. Advanced service options are documented by
4848

4949
## Optional one-time test
5050

51+
For reusable manual commands, see
52+
[JSON configuration and command-line overrides](docs/operations-guide.md#save-settings-in-a-json-configuration).
53+
5154
To publish a small test pattern without changing the saved setup:
5255

5356
```bash

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,10 @@ python3 publish.py --view STREAMIDHERE --v4l2sink 0 \
811811

812812
Notes:
813813
- `--v4l2sink` accepts a numeric index (`0`) or a full path (`/dev/video2`).
814+
- Select a writable video output such as a configured `v4l2loopback` device.
815+
Raspberry Pi ISP and codec nodes are internal processing devices, not virtual
816+
cameras, and are excluded from output discovery. If no suitable output exists,
817+
configure a loopback device first and check it with `v4l2-ctl -d /dev/video2 -D`.
814818
- If the specified device is not writable, the first writable `/dev/video*` is used.
815819
- When no remote video is available, a blue frame is output to keep the device alive.
816820
- The V4L2 sink path drops upstream allocation queries before `v4l2sink` to avoid buffer-pool issues with some `v4l2loopback` versions. The default V4L2 sink I/O mode is GStreamer's auto mode (`--v4l2sink-io-mode 0`); use `--v4l2sink-io-mode 1` to force read/write mode.

config_loader.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22

33
from argparse import ArgumentParser, Namespace
44
import json
5+
import math
56
from pathlib import Path
67
from typing import Any, Dict, Iterable, Optional, Set
78

89

910
IGNORED_CONFIG_KEYS = {"platform", "auto_start", "custom_video_pipeline", "video_device", "video_format"}
1011
CONFIG_ARG_ALIASES = {"stream_id": "streamid"}
12+
AUDIO_SOURCE_OVERRIDE_ATTRS = {"alsa", "pulse", "audio_pipeline", "noaudio"}
13+
VIDEO_CODEC_OVERRIDE_ATTRS = {
14+
"h264", "x264", "openh264", "omx", "vp8", "vp9",
15+
"h265", "hevc", "x265", "av1", "aom", "rav1e", "qsv",
16+
}
1117
VIDEO_SOURCE_OVERRIDE_ATTRS = (
1218
"test",
1319
"hdmi",
@@ -31,7 +37,7 @@
3137
def load_config_file(path: str) -> Dict[str, Any]:
3238
"""Load one JSON configuration file or raise an actionable exception."""
3339
config_path = Path(path).expanduser()
34-
with config_path.open("r", encoding="utf-8") as config_file:
40+
with config_path.open("r", encoding="utf-8-sig") as config_file:
3541
config = json.load(config_file)
3642
if not isinstance(config, dict):
3743
raise ValueError("configuration root must be a JSON object")
@@ -47,10 +53,18 @@ def _explicit_cli_destinations(parser: ArgumentParser, cli_argv: Iterable[str])
4753
}
4854
destinations: Set[str] = set()
4955
for token in cli_argv:
56+
if token == "--":
57+
break
5058
if not token.startswith("-"):
5159
continue
5260
option = token.split("=", 1)[0]
5361
destination = option_destinations.get(option)
62+
if destination is None and parser.allow_abbrev and option.startswith("--"):
63+
# argparse accepts unique long-option prefixes. Preserve explicit
64+
# values even when they happen to equal the parser's default.
65+
matches = [name for name in option_destinations if name.startswith(option)]
66+
if len(matches) == 1:
67+
destination = option_destinations[matches[0]]
5468
if destination:
5569
destinations.add(destination)
5670
return destinations
@@ -92,16 +106,49 @@ def _apply_video_source_override(
92106
if _video_source_has_cli_override(args, parser, explicit_cli_args):
93107
return
94108

109+
if value not in ("test", "libcamera", "v4l2", "custom"):
110+
parser.error("configuration key 'video_source' must be one of: test, libcamera, v4l2, custom")
111+
95112
if value == "test" and _arg_is_default(args, parser, "test", explicit_cli_args):
96113
args.test = True
97114
elif value == "libcamera" and _arg_is_default(args, parser, "libcamera", explicit_cli_args):
98115
args.libcamera = True
99116
elif value == "v4l2" and _arg_is_default(args, parser, "v4l2", explicit_cli_args):
100-
args.v4l2 = config.get("video_device", "/dev/video0")
117+
device = config.get("video_device", "/dev/video0")
118+
if not isinstance(device, str) or not device.strip():
119+
parser.error("configuration key 'video_device' must be a non-empty JSON string")
120+
args.v4l2 = device
101121
elif value == "custom" and _arg_is_default(args, parser, "video_pipeline", explicit_cli_args):
102122
custom_pipeline = config.get("custom_video_pipeline")
103-
if custom_pipeline:
104-
args.video_pipeline = custom_pipeline
123+
if not isinstance(custom_pipeline, str) or not custom_pipeline.strip():
124+
parser.error("configuration key 'custom_video_pipeline' must be a non-empty JSON string when video_source is custom")
125+
args.video_pipeline = custom_pipeline
126+
127+
128+
def _validate_config_value(parser: ArgumentParser, action, key: str, value: Any) -> None:
129+
"""Check typed settings before bypassing argparse via setattr."""
130+
if value is None and action.default is None:
131+
return
132+
if action.nargs == 0 and isinstance(action.const, bool):
133+
if not isinstance(value, bool):
134+
parser.error(f"configuration key '{key}' must be a JSON boolean (true or false)")
135+
elif action.type is int:
136+
if type(value) is not int:
137+
parser.error(f"configuration key '{key}' must be a JSON integer")
138+
elif action.type is str:
139+
if not isinstance(value, str):
140+
parser.error(f"configuration key '{key}' must be a JSON string")
141+
elif action.type is float:
142+
if type(value) not in (int, float):
143+
parser.error(f"configuration key '{key}' must be a finite JSON number")
144+
try:
145+
finite = math.isfinite(value)
146+
except OverflowError:
147+
finite = False
148+
if not finite:
149+
parser.error(f"configuration key '{key}' must be a finite JSON number")
150+
if action.choices is not None and value not in action.choices:
151+
parser.error(f"configuration key '{key}' must be one of: " + ", ".join(map(str, action.choices)))
105152

106153

107154
def apply_config_overrides(
@@ -111,22 +158,45 @@ def apply_config_overrides(
111158
cli_argv: Optional[Iterable[str]] = None,
112159
) -> Namespace:
113160
explicit_cli_args = _explicit_cli_destinations(parser, cli_argv or ())
161+
# Snapshot before applying config values, which must not be mistaken for
162+
# command-line source selections on later iterations.
163+
cli_video_source = _video_source_has_cli_override(args, parser, explicit_cli_args)
164+
cli_video_codec = bool(explicit_cli_args & VIDEO_CODEC_OVERRIDE_ATTRS)
165+
cli_audio_source = bool(explicit_cli_args & AUDIO_SOURCE_OVERRIDE_ATTRS)
166+
actions = {action.dest: action for action in parser._actions}
114167

115168
for key, value in config.items():
116169
if key in IGNORED_CONFIG_KEYS:
117170
continue
118171

119172
if key == "audio_enabled":
120-
if value is False and _arg_is_default(args, parser, "noaudio", explicit_cli_args):
121-
args.noaudio = True
173+
if cli_audio_source or "noaudio" in config:
174+
continue
175+
if _arg_is_default(args, parser, "noaudio", explicit_cli_args):
176+
if not isinstance(value, bool):
177+
parser.error("configuration key 'audio_enabled' must be a JSON boolean (true or false)")
178+
if value is False:
179+
args.noaudio = True
122180
continue
123181

124182
if key == "video_source":
125183
_apply_video_source_override(args, parser, value, config, explicit_cli_args)
126184
continue
127185

128186
target_key = CONFIG_ARG_ALIASES.get(key, key)
187+
# Prefer current names over legacy aliases independently of JSON order.
188+
if target_key != key and target_key in config:
189+
continue
190+
if cli_audio_source and target_key in AUDIO_SOURCE_OVERRIDE_ATTRS:
191+
continue
192+
if cli_video_codec and target_key in VIDEO_CODEC_OVERRIDE_ATTRS:
193+
continue
194+
if cli_video_source and target_key in VIDEO_SOURCE_OVERRIDE_ATTRS:
195+
continue
129196
if _arg_is_default(args, parser, target_key, explicit_cli_args):
197+
action = actions.get(target_key)
198+
if action is not None:
199+
_validate_config_value(parser, action, key, value)
130200
setattr(args, target_key, value)
131201

132202
return args

docs/operations-guide.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ python3 -u publish.py \
5858

5959
VP9 is software-heavy on small boards. Validate it at a low resolution before increasing load.
6060

61+
## Multiple viewers and stalled connections
62+
63+
Use `--multiviewer` to share one encoded stream with multiple viewers. Each
64+
viewer has bounded audio and video queues. If one viewer stops accepting media,
65+
its queues drop old packets when full so the other viewers can keep receiving.
66+
The affected viewer may have audio gaps or need the next video keyframe when it
67+
recovers. This does not increase the publisher's available upload bandwidth;
68+
choose a bitrate that leaves room for all viewers.
69+
6170
## Publish a USB camera or HDMI capture device
6271

6372
List devices and the modes of the intended capture node:
@@ -131,6 +140,80 @@ The default preserves aspect ratio on whatever mode the display advertises. Add
131140

132141
The receiver remains available while the sender is absent. Automatic retry defaults to a short sequence followed by a longer interval. Use `--no-auto-retry` only for a supervised diagnostic run.
133142

143+
After a detected disconnect, `--viewer-retry-initial` waits 15 seconds by default
144+
before the first play request. The next request waits `--viewer-retry-short`
145+
(45 seconds), and later requests use `--viewer-retry-long` (180 seconds).
146+
Set `--viewer-retry-initial 0` for an immediate first retry. These intervals govern
147+
viewer play requests, separately from reconnecting to the signaling server.
148+
If a request cannot be scheduled because the signaling loop is unavailable, it
149+
does not advance the retry count; another attempt is scheduled after the long
150+
delay. Successful peer creation resets the retry sequence.
151+
152+
## Save settings in a JSON configuration
153+
154+
Create `sender.json` with JSON booleans and numbers (without quotes):
155+
156+
```json
157+
{
158+
"streamid": "my-camera",
159+
"password": "replace-with-your-shared-password",
160+
"test": true,
161+
"h264": true,
162+
"noaudio": true,
163+
"width": 640,
164+
"height": 360,
165+
"framerate": 15,
166+
"bitrate": 500
167+
}
168+
```
169+
170+
Run `python3 publish.py --config sender.json`. Use the same stream ID and
171+
password in the viewer. Protect files containing passwords with
172+
`chmod 600 sender.json`; do not post them in bug reports.
173+
174+
Keys normally use argument destination names: `streamid`, `noaudio`, and
175+
`video_pipeline`, for example. Installer-style `stream_id` is also accepted.
176+
If both names are present, `streamid` takes precedence over `stream_id` regardless
177+
of JSON key order. Likewise, `noaudio` takes precedence over legacy
178+
`audio_enabled`. Explicit command-line options still take precedence over the file.
179+
Legacy `video_source` accepts `test`, `libcamera`, `v4l2`, or `custom`.
180+
`custom` requires a non-empty `custom_video_pipeline`; `v4l2` uses `/dev/video0`
181+
when `video_device` is omitted, but rejects an explicitly empty or null device.
182+
Invalid source selections stop startup instead of silently falling back to a camera.
183+
Save as UTF-8; files with a UTF-8 byte-order mark are supported. A missing file,
184+
invalid JSON, or a root value other than an object stops startup with an error.
185+
Boolean flags require `true` or `false`; integer settings such as `bitrate`
186+
require integers; floating-point settings require finite numbers. Numeric strings
187+
such as `"500"` and boolean strings such as `"false"` are rejected rather than
188+
interpreted as flags or passed into media setup. Options with a fixed set of
189+
choices, such as `ice_transport_policy`, use the same choices as the CLI.
190+
Text settings, including stream IDs, passwords, device paths, and custom
191+
pipelines, require JSON strings. To disable the password, use `"password": "false"`
192+
(a string); `"noaudio": false` is a boolean flag. Optional text settings whose
193+
default is unset also accept `null`.
194+
195+
Explicit command-line values override saved settings, even if the value equals
196+
the built-in default. For example, `--config sender.json --bitrate 2500` uses
197+
2500 kbps. Unique long-option abbreviations follow the same rule, but use full
198+
option names in scripts so future options cannot make an abbreviation ambiguous.
199+
200+
An explicit codec or encoder flag also replaces saved codec-selection flags.
201+
For example, `--config sender.json --x264` selects H.264 even if the file enables
202+
VP8 or AV1. Other saved settings, including bitrate and platform hints, still
203+
apply. Without an explicit codec flag, the saved codec selection is used.
204+
205+
Likewise, `--alsa`, `--pulse`, `--audio-pipeline`, or `--noaudio` replaces saved
206+
audio-source and audio-enable settings. For example, an explicit `--alsa DEVICE`
207+
enables that source even if the file contains `"noaudio": true`. Saved audio
208+
bitrate and other unrelated options still apply.
209+
210+
Choosing a video source on the command line also suppresses saved video-source
211+
flags. For example, `--config sender.json --v4l2 /dev/video2` replaces the saved
212+
test source with that camera. Conversely, `--test` replaces a saved camera source.
213+
Other settings, including resolution, codec, bitrate, and password, still apply;
214+
ensure they are suitable for the replacement source. This changes only the current
215+
invocation, not the JSON file or an already-running service.
216+
134217
## Run unattended with systemd
135218

136219
First prove the exact command interactively. Then use `tools/install_unattended.py` to create a validated receiver or sender unit whose user and working directory match the installed clone. Complete examples are in the [Pi Zero 2 W guide](pi-zero-2-w-unattended-webrtc.md#8-make-the-receiver-start-on-boot).
@@ -146,6 +229,60 @@ journalctl -u raspberry-ninja-viewer.service -f
146229

147230
The helper uses `Restart=always`, a small `RestartSec`, unbuffered Python output, and `network-online.target`. It stores credentials in a restricted JSON config instead of the unit command. Running the installer again validates the replacement unit and restarts the existing service so new settings take effect.
148231

232+
`--service-name` accepts up to 247 letters, digits, underscores, dots, hyphens,
233+
or `@` characters before the generated `.service` suffix. It must not start with
234+
`-` or `@`. Use a concrete instance such as `camera@front`, rather than `camera@`.
235+
236+
For senders, omit `--audio-device` to disable audio; an empty value is invalid.
237+
`--camera` requires a non-empty device path. `--allow-missing-device` permits an
238+
unplugged device but does not allow an empty path or a directory.
239+
240+
Configuration writes use a private temporary file in the destination directory,
241+
then replace the destination after setting its permissions and ownership. The shared
242+
config directory is root-owned with mode `0711` (traversal without listing), and
243+
each config uses mode `0640` with its service user's group. Installing another
244+
service under a different user therefore preserves access to existing configs. If that
245+
write fails, the temporary file is removed and the previous destination remains
246+
intact. This protects the file update; it does not guarantee that newly selected
247+
camera or network settings will work. Check the service status and journal after
248+
each reconfiguration. `--dry-run` prints the proposed configuration, including its
249+
password, so keep that output private.
250+
251+
If writing or verifying the generated files fails before systemd is reloaded,
252+
the installer restores replaced files atomically, including their previous
253+
permissions and ownership, and restores the config directory's metadata.
254+
An incomplete rollback is reported explicitly. This rollback does not cover
255+
failures during the later service reload, enable, or restart steps.
256+
257+
Relative `--python` and `--camera` paths become absolute from the installer's working
258+
directory without resolving virtual-environment or stable device symlinks. The generated service treats paths
259+
literally, including spaces, percent signs, and dollar signs. Paths containing line breaks or NUL
260+
bytes are rejected. Use `--dry-run` to inspect paths before installation.
261+
262+
## Record while publishing to RTMP
263+
264+
For RTMP publishing, `--save` also writes a local timestamped `.mkv` recording.
265+
The RTMP output stays connected to its muxer even if `--multiviewer` is present;
266+
that flag's dynamic viewer branches apply to WebRTC publishing. Verify recording
267+
output and available disk space before leaving a sender unattended.
268+
269+
For a V4L2 camera that advertises H.264, `--v4l2 /dev/videoN --format H264`
270+
can send the camera's encoded video directly to RTMP without decoding and
271+
re-encoding. RTMP receives parsed H.264; WebRTC uses RTP packetization.
272+
Confirm the camera's advertised modes with `v4l2-ctl --list-formats-ext`.
273+
274+
RTMP audio is converted and resampled to 48 kHz mono before AAC encoding, so a
275+
44.1 kHz source can be used. A custom `--audio-pipeline` supplies its own source
276+
and does not require a detected microphone; `--noaudio` still disables audio.
277+
278+
Automatic audio selection uses devices that advertise an ALSA card, preferring
279+
a valid default device. If none is found, audio is disabled with a diagnostic.
280+
Use `--alsa DEVICE` or `--pulse DEVICE` when the desired source is not represented
281+
by an ALSA card in device discovery; these explicit selections bypass discovery.
282+
Pass a device name containing spaces as one shell-quoted argument. The app
283+
preserves the name when constructing the ALSA or PulseAudio source, including
284+
literal quotes and backslashes; do not add GStreamer property syntax yourself.
285+
149286
## Conservative performance profiles
150287

151288
These are starting points, not guaranteed limits:
@@ -181,3 +318,19 @@ During a soak, confirm:
181318

182319
See [Troubleshooting](troubleshooting.md) for diagnostic commands and [Recording](recording-guide.md) for file validation.
183320
For a repeatable deployment gate, use the [unattended validation checklist](unattended-validation-checklist.md).
321+
322+
## Serving existing HLS files
323+
324+
To serve an existing playlist and its segments locally:
325+
326+
```bash
327+
python3 tools/serve_hls.py --directory /path/to/hls --bind 127.0.0.1 --port 8089
328+
```
329+
330+
Open the playlist at `http://127.0.0.1:8089/PLAYLIST.m3u8` in an HLS-compatible
331+
player. The helper serves files; it does not generate the stream. It supports
332+
concurrent requests and CORS preflight. Playlist responses disable caching so live
333+
updates remain visible even when the playlist changes within one second.
334+
Without options it serves the repository
335+
root on port 8089 on all interfaces. Use `--directory` to select the files to expose
336+
and `--bind` to choose the listening address. Stop with Ctrl+C.

0 commit comments

Comments
 (0)