Skip to content

Commit 402ef35

Browse files
committed
.
1 parent deda726 commit 402ef35

6 files changed

Lines changed: 139 additions & 36 deletions

File tree

QUICK_START.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,50 @@
22

33
## 1. Install
44

5-
On Raspberry Pi OS, Ubuntu, or Debian:
5+
On Raspberry Pi OS, Ubuntu, or Debian, run as your normal user with sudo access:
66

77
```bash
8-
curl -sSL https://raw.githubusercontent.com/steveseguin/raspberry_ninja/main/install.sh | bash
8+
cd ~
9+
curl -fL https://raw.githubusercontent.com/steveseguin/raspberry_ninja/main/install.sh -o install-raspberry-ninja.sh && \
10+
bash install-raspberry-ninja.sh --non-interactive --runtime-only --skip-system-upgrade
911
cd ~/raspberry_ninja
1012
```
1113

12-
## 2. Choose what this Pi should do
14+
Continue only after the installer succeeds. This installs runtime dependencies
15+
without development headers or a full OS upgrade. If you already have a clone,
16+
run `bash install.sh --non-interactive --runtime-only --skip-system-upgrade`
17+
from that directory instead.
18+
19+
## 2. Set up a Raspberry Pi to start at boot
1320

1421
```bash
1522
sudo python3 tools/setup.py
1623
```
1724

18-
Choose **Show video on a TV** or **Send camera video**. Setup finds the camera,
19-
microphone, display mode, and safe video settings automatically, then makes the
20-
Pi start itself after a reboot.
25+
Choose **Show video on a TV** or **Send camera video**. Setup lists detected
26+
cameras and microphones, writes a configuration, and enables and starts a systemd
27+
service. Connect the camera or TV first. The selected video defaults may need
28+
adjustment for your camera's supported resolutions and frame rates.
2129

2230
Use the same stream name and password at both ends. That is all most Raspberry
2331
Pi setups need.
2432

33+
The guided service setup targets Raspberry Pi Linux systems with systemd. For
34+
Jetson, Orange Pi, desktops, or other platforms, use the
35+
[platform installation guides](installers/README.md) and manual commands.
36+
37+
To check a guided sender (use `raspberry-ninja-viewer` for a receiver):
38+
39+
```bash
40+
sudo systemctl status raspberry-ninja-sender
41+
sudo journalctl -u raspberry-ninja-sender -n 30 --no-pager
42+
```
43+
44+
Re-run setup to change the stream name, password, or source; it restarts the
45+
selected service. Advanced service options are documented by
46+
`python3 tools/install_unattended.py --help` and its `sender --help` or
47+
`receiver --help` subcommands.
48+
2549
## Optional one-time test
2650

2751
To publish a small test pattern without changing the saved setup:
@@ -35,5 +59,10 @@ python3 publish.py --test --h264 --noaudio \
3559
Open `https://vdo.ninja/?view=rn-test&password=false` and stop the test with
3660
Ctrl+C. Use a real password for anything beyond this first test.
3761

62+
For a local software encode/decode check without publishing a stream, run
63+
`python3 tools/media_self_test.py`. Missing codecs are reported as skipped;
64+
a failed probe or no passing probes returns a nonzero exit status. This does
65+
not test the camera, hardware encoders, HDMI output, or network connectivity.
66+
3867
If setup reports a problem, continue with [Troubleshooting](docs/troubleshooting.md).
3968
Advanced commands remain available in the [documentation index](docs/README.md).

docs/platform-compatibility.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ gst-inspect-1.0 libcamerasrc
6565

6666
Use `--rpicam` only when `rpicamsrc` is present and its pipeline has been tested. Use `--libcamera --rpi` for the libcamera GStreamer path. Do not remove a working legacy path merely because a newer OS uses another name.
6767

68+
The guided setup checks both camera-tool names and prefers `libcamerasrc` when
69+
available, including with `rpicam-hello`. The application rename does not imply
70+
that the GStreamer source was renamed to `rpicamsrc`; that is a separate backend.
71+
6872
Official background: [Raspberry Pi camera software](https://www.raspberrypi.com/documentation/computers/camera_software.html).
6973

7074
## GStreamer version differences

tests/test_easy_setup.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,50 @@ def test_csi_discovery_requires_camera_and_working_gstreamer_source(self):
4646
]
4747
)
4848
camera = setup.discover_csi_camera(runner=lambda *_a, **_k: next(responses))
49-
self.assertEqual(camera, ("csi:rpicam", "imx219"))
49+
self.assertEqual(camera, ("csi:libcamera", "imx219"))
50+
51+
def test_csi_discovery_supports_tool_and_plugin_combinations(self):
52+
for camera_tool in ("rpicam-hello", "libcamera-hello"):
53+
for backend in ("libcamera", "rpicam"):
54+
with self.subTest(tool=camera_tool, backend=backend):
55+
def runner(command, **_kwargs):
56+
if command == [camera_tool, "--list-cameras"]:
57+
return subprocess.CompletedProcess(
58+
command, 0, stdout="", stderr="0 : imx219 [sensor]"
59+
)
60+
if command == ["gst-inspect-1.0", backend + "src"]:
61+
return subprocess.CompletedProcess(command, 0)
62+
raise FileNotFoundError(command[0])
63+
64+
self.assertEqual(
65+
setup.discover_csi_camera(runner=runner),
66+
("csi:" + backend, "imx219"),
67+
)
68+
69+
def test_csi_discovery_rejects_failed_listing_and_missing_plugins(self):
70+
for listing_code in (0, 1):
71+
with self.subTest(listing_code=listing_code):
72+
def runner(command, **_kwargs):
73+
if command[0] == "gst-inspect-1.0":
74+
self.assertEqual(listing_code, 0)
75+
return subprocess.CompletedProcess(command, 1)
76+
return subprocess.CompletedProcess(
77+
command, listing_code, stdout="0 : imx219 [sensor]", stderr=""
78+
)
79+
80+
self.assertIsNone(setup.discover_csi_camera(runner=runner))
81+
82+
def test_passwords_survive_argument_parsing_for_both_roles(self):
83+
for password in ("--secret", " leading and trailing ", "a&b=c"):
84+
for arguments in (
85+
setup.build_receiver_arguments(Path("/opt/rn"), "test", password),
86+
setup.build_sender_arguments(
87+
Path("/opt/rn"), "test", password, "csi:libcamera", None, None
88+
),
89+
):
90+
with self.subTest(password=password, arguments=arguments):
91+
parsed = setup.install_unattended.create_parser().parse_args(arguments)
92+
self.assertEqual(parsed.password, password)
5093

5194
def test_camera_probe_recognizes_common_driver_names(self):
5295
completed = subprocess.CompletedProcess(
@@ -119,7 +162,19 @@ def test_receiver_flow_installs_with_three_simple_answers(self, install, _displa
119162
arguments = install.call_args.args[0]
120163
self.assertIn("receiver", arguments)
121164
self.assertIn("living-room", arguments)
122-
self.assertIn("secret", arguments)
165+
self.assertIn("--password=secret", arguments)
166+
167+
@patch("tools.setup.display_connected", return_value=True)
168+
@patch("tools.setup.install_unattended.main", return_value=0)
169+
def test_receiver_flow_preserves_password_whitespace(self, install, _display):
170+
answers = iter(["1", "living-room"])
171+
with patch("builtins.print"):
172+
setup.main(
173+
input_fn=lambda _prompt: next(answers),
174+
password_fn=lambda _prompt: " secret ",
175+
)
176+
parsed = setup.install_unattended.create_parser().parse_args(install.call_args.args[0])
177+
self.assertEqual(parsed.password, " secret ")
123178

124179
@patch("tools.setup.display_connected", return_value=True)
125180
@patch("tools.setup.install_unattended.main", side_effect=RuntimeError("service failed"))

tests/test_install_unattended.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import argparse
22
import tempfile
33
import unittest
4-
from pathlib import Path
4+
from pathlib import Path, PurePosixPath
55
from types import SimpleNamespace
66
from unittest.mock import Mock
77

@@ -22,9 +22,9 @@ def test_receiver_config_keeps_credentials_out_of_unit(self):
2222
service_name=args.service_name,
2323
description="Receiver",
2424
user="steve",
25-
repo_dir=Path("/home/steve/raspberry ninja"),
26-
config_path=Path("/etc/raspberry-ninja/viewer.json"),
27-
python_path=Path("/usr/bin/python3"),
25+
repo_dir=PurePosixPath("/home/steve/raspberry ninja"),
26+
config_path=PurePosixPath("/etc/raspberry-ninja/viewer.json"),
27+
python_path=PurePosixPath("/usr/bin/python3"),
2828
)
2929

3030
self.assertEqual(config["view"], "illinois-tv")
@@ -90,6 +90,18 @@ def test_raw_sender_config_is_preserved(self):
9090
self.assertTrue(config["raw"])
9191
self.assertEqual(config["format"], "YUY2")
9292

93+
def test_raw_format_selects_raw_capture_without_hidden_flag(self):
94+
for capture_format in ("YUYV", "YUY2"):
95+
with self.subTest(capture_format=capture_format):
96+
args = self.parse(
97+
"sender", "--stream-id", "raw-camera",
98+
"--camera", "/dev/video0", "--allow-missing-device",
99+
"--format", capture_format,
100+
)
101+
config = install_unattended.build_config(args)
102+
self.assertTrue(config["raw"])
103+
self.assertEqual(config["format"], "YUY2")
104+
93105
def test_csi_sender_config_uses_selected_camera_stack(self):
94106
args = self.parse(
95107
"sender",

tools/install_unattended.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,10 @@ def build_config(args: argparse.Namespace) -> Dict[str, Any]:
117117
config["rpicam"] = True
118118
else:
119119
config["v4l2"] = args.camera
120-
if args.raw:
120+
if args.raw or args.format in {"YUYV", "YUY2"}:
121121
config["raw"] = True
122122
if args.format:
123-
config["format"] = args.format
123+
config["format"] = "YUY2" if args.format == "YUYV" else args.format
124124
if args.audio_device:
125125
config["alsa"] = args.audio_device
126126
config["audiobitrate"] = args.audio_bitrate

tools/setup.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,9 @@ def discover_csi_camera(
8888
*,
8989
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
9090
) -> Optional[Tuple[str, str]]:
91-
backends = (
92-
("rpicam", "rpicam-hello", "rpicamsrc"),
93-
("libcamera", "libcamera-hello", "libcamerasrc"),
94-
)
95-
for backend, camera_tool, source_element in backends:
91+
# The camera applications were renamed; the libcamera GStreamer element
92+
# was not. Probe the tools independently of the source plugin names.
93+
for camera_tool in ("rpicam-hello", "libcamera-hello"):
9694
try:
9795
cameras = runner(
9896
[camera_tool, "--list-cameras"],
@@ -103,20 +101,27 @@ def discover_csi_camera(
103101
)
104102
output = f"{cameras.stdout}\n{cameras.stderr}"
105103
camera_match = CSI_CAMERA_RE.search(output)
106-
if not camera_match:
104+
if cameras.returncode != 0 or not camera_match:
107105
continue
108-
plugin = runner(
109-
["gst-inspect-1.0", source_element],
110-
check=False,
111-
stdout=subprocess.DEVNULL,
112-
stderr=subprocess.DEVNULL,
113-
timeout=8,
114-
)
115106
except (OSError, subprocess.TimeoutExpired):
116107
continue
117-
if plugin.returncode == 0:
118-
label = camera_match.group("label").split("[")[0].strip()
119-
return (f"{CSI_SOURCE_PREFIX}{backend}", label or "Raspberry Pi Camera")
108+
for backend, source_element in (
109+
("libcamera", "libcamerasrc"),
110+
("rpicam", "rpicamsrc"),
111+
):
112+
try:
113+
plugin = runner(
114+
["gst-inspect-1.0", source_element],
115+
check=False,
116+
stdout=subprocess.DEVNULL,
117+
stderr=subprocess.DEVNULL,
118+
timeout=8,
119+
)
120+
except (OSError, subprocess.TimeoutExpired):
121+
continue
122+
if plugin.returncode == 0:
123+
label = camera_match.group("label").split("[")[0].strip()
124+
return (f"{CSI_SOURCE_PREFIX}{backend}", label or "Raspberry Pi Camera")
120125
return None
121126

122127

@@ -193,8 +198,7 @@ def build_receiver_arguments(repo: Path, stream_id: str, password: str) -> List[
193198
return [
194199
"--repo",
195200
str(repo),
196-
"--password",
197-
password,
201+
f"--password={password}",
198202
"receiver",
199203
"--stream-id",
200204
stream_id,
@@ -219,8 +223,7 @@ def build_sender_arguments(
219223
arguments = [
220224
"--repo",
221225
str(repo),
222-
"--password",
223-
password,
226+
f"--password={password}",
224227
"sender",
225228
"--stream-id",
226229
stream_id,
@@ -248,7 +251,7 @@ def main(
248251
input_fn: Callable[[str], str] = input,
249252
password_fn: Callable[[str], str] = getpass.getpass,
250253
) -> int:
251-
parser = argparse.ArgumentParser(add_help=False)
254+
parser = argparse.ArgumentParser(description=__doc__)
252255
parser.add_argument("--inventory", action="store_true", help=argparse.SUPPRESS)
253256
options = parser.parse_args(list(argv or []))
254257
if options.inventory:
@@ -272,7 +275,7 @@ def main(
272275
input_fn=input_fn,
273276
)
274277
stream_id = ask_stream_id(input_fn=input_fn)
275-
password = password_fn("Stream password: ").strip()
278+
password = password_fn("Stream password: ")
276279
if not password:
277280
print("A password is required.", file=sys.stderr)
278281
return 2

0 commit comments

Comments
 (0)