pycentauri 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {pycentauri-0.3.1 → pycentauri-0.4.0}/CHANGELOG.md +21 -0
  2. {pycentauri-0.3.1 → pycentauri-0.4.0}/PKG-INFO +32 -4
  3. {pycentauri-0.3.1 → pycentauri-0.4.0}/README.md +31 -3
  4. {pycentauri-0.3.1 → pycentauri-0.4.0}/pyproject.toml +1 -1
  5. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/cli.py +81 -0
  7. pycentauri-0.4.0/src/pycentauri/rtsp.py +259 -0
  8. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/server.py +141 -2
  9. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/app.js +118 -0
  10. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/index.html +21 -0
  11. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/styles.css +90 -0
  12. pycentauri-0.4.0/tests/test_rtsp.py +113 -0
  13. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_server.py +100 -0
  14. {pycentauri-0.3.1 → pycentauri-0.4.0}/.gitignore +0 -0
  15. {pycentauri-0.3.1 → pycentauri-0.4.0}/LICENSE +0 -0
  16. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/camera.py +0 -0
  17. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/client.py +0 -0
  18. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/discovery.py +0 -0
  19. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/__init__.py +0 -0
  20. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/__main__.py +0 -0
  21. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/server.py +0 -0
  22. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/models.py +0 -0
  23. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/py.typed +0 -0
  24. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/sdcp.py +0 -0
  25. {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/__init__.py +0 -0
  26. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/__init__.py +0 -0
  27. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/integration/__init__.py +0 -0
  28. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_client.py +0 -0
  29. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_discovery.py +0 -0
  30. {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_sdcp.py +0 -0
@@ -6,6 +6,27 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-04-22
10
+
11
+ ### Added
12
+ - **RTSP bridge.** Re-stream the printer's MJPEG webcam as RTSP/H.264
13
+ so VLC, Home Assistant, Jellyfin, Frigate, Synology Surveillance, and
14
+ any other RTSP client can consume it. Powered by `MediaMTX` +
15
+ on-demand `ffmpeg` transcode — the transcoder only runs while at least
16
+ one client is connected, so idle cost is zero. Two ways to drive it:
17
+ - **Standalone**: `centauri rtsp --host <printer>` runs in the
18
+ foreground until Ctrl-C. Flags: `--port`, `--bind`, `--path`,
19
+ `--fps`, `--bitrate`, `--preset`, `--webrtc/--no-webrtc`,
20
+ `--hls/--no-hls`, and `--mediamtx-path` / `--ffmpeg-path`
21
+ overrides.
22
+ - **Integrated with `centauri server --rtsp`**: adds a "STREAM" panel
23
+ to the built-in web UI with start/stop buttons, a copy-URL button,
24
+ and live status. New endpoints: `GET /api/rtsp`,
25
+ `POST /api/rtsp/start`, `POST /api/rtsp/stop`.
26
+ - Requires `mediamtx` and `ffmpeg` on `$PATH` — clear install hints
27
+ surface in the API response and the UI panel if either is missing,
28
+ and nothing changes for users who don't enable the feature.
29
+
9
30
  ## [0.3.1] - 2026-04-22
10
31
 
11
32
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Python client and MCP server for Elegoo Centauri Carbon 3D printers
5
5
  Project-URL: Homepage, https://github.com/bjan/pycentauri
6
6
  Project-URL: Repository, https://github.com/bjan/pycentauri
@@ -52,7 +52,7 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
52
52
  Carbon](https://www.elegoo.com/) 3D printer.
53
53
 
54
54
  `pycentauri` speaks the printer's native SDCP v3 protocol over its local
55
- WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
55
+ WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
56
56
 
57
57
  1. An **async Python library** for direct integration.
58
58
  2. A **`centauri` CLI** for quick status checks, snapshots, and control.
@@ -60,8 +60,10 @@ WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
60
60
  MCP-compatible client) can monitor and drive the printer as a tool.
61
61
  4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
62
62
  anything that wants a plain REST API.
63
- 5. A **built-in web UI** (dark theme, mobile-friendly) served at `/ui/` by
64
- the HTTP server live webcam, progress, temperatures, and control.
63
+ 5. A **built-in web UI** (industrial instrument-panel theme, mobile-friendly)
64
+ served at `/ui/` by the HTTP server.
65
+ 6. An **RTSP bridge** that re-streams the printer's MJPEG webcam as
66
+ H.264/RTSP for Home Assistant, Jellyfin, VLC, Frigate, and NVRs.
65
67
 
66
68
  > **Status:** alpha. The protocol has been reverse-engineered from the official
67
69
  > [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
@@ -155,6 +157,32 @@ The server holds a single long-lived WebSocket to the printer and reuses
155
157
  it for every request — no per-request reconnect, and it won't bump into
156
158
  the firmware's 5-slot limit.
157
159
 
160
+ ## Quick start — RTSP bridge
161
+
162
+ ```sh
163
+ # Install dependencies first:
164
+ # mediamtx https://github.com/bluenviron/mediamtx/releases (or `brew install mediamtx`)
165
+ # ffmpeg `brew install ffmpeg` or `apt install ffmpeg`
166
+
167
+ # Standalone: foreground, Ctrl-C to stop
168
+ centauri rtsp --host 192.168.1.209
169
+ # → rtsp://<this-host>:8554/printer
170
+
171
+ # Or, integrated with the HTTP server + web UI
172
+ centauri server --host 192.168.1.209 --rtsp --bind 0.0.0.0
173
+ # Web UI gains a STREAM panel with a toggle, a copy-URL button, and live status
174
+ # at http://<this-host>:8787/ui/
175
+ ```
176
+
177
+ Open that URL in **VLC** (Media → Open Network Stream), point **Home Assistant**
178
+ at it via the Generic Camera integration, or feed it to **Frigate** / **Jellyfin**
179
+ / **Synology Surveillance** for NVR recording and motion detection.
180
+
181
+ MediaMTX runs the ffmpeg transcode only while at least one client is actually
182
+ connected, so idle cost is zero. Tunable: `--fps`, `--bitrate`, `--preset`,
183
+ `--path`, `--port`, `--bind`. Run `centauri rtsp --help` (or
184
+ `centauri server --help`) for the full list.
185
+
158
186
  ## Quick start — MCP
159
187
 
160
188
  Register the server with your agent. With Claude Code:
@@ -4,7 +4,7 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
4
4
  Carbon](https://www.elegoo.com/) 3D printer.
5
5
 
6
6
  `pycentauri` speaks the printer's native SDCP v3 protocol over its local
7
- WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
7
+ WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
8
8
 
9
9
  1. An **async Python library** for direct integration.
10
10
  2. A **`centauri` CLI** for quick status checks, snapshots, and control.
@@ -12,8 +12,10 @@ WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
12
12
  MCP-compatible client) can monitor and drive the printer as a tool.
13
13
  4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
14
14
  anything that wants a plain REST API.
15
- 5. A **built-in web UI** (dark theme, mobile-friendly) served at `/ui/` by
16
- the HTTP server live webcam, progress, temperatures, and control.
15
+ 5. A **built-in web UI** (industrial instrument-panel theme, mobile-friendly)
16
+ served at `/ui/` by the HTTP server.
17
+ 6. An **RTSP bridge** that re-streams the printer's MJPEG webcam as
18
+ H.264/RTSP for Home Assistant, Jellyfin, VLC, Frigate, and NVRs.
17
19
 
18
20
  > **Status:** alpha. The protocol has been reverse-engineered from the official
19
21
  > [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
@@ -107,6 +109,32 @@ The server holds a single long-lived WebSocket to the printer and reuses
107
109
  it for every request — no per-request reconnect, and it won't bump into
108
110
  the firmware's 5-slot limit.
109
111
 
112
+ ## Quick start — RTSP bridge
113
+
114
+ ```sh
115
+ # Install dependencies first:
116
+ # mediamtx https://github.com/bluenviron/mediamtx/releases (or `brew install mediamtx`)
117
+ # ffmpeg `brew install ffmpeg` or `apt install ffmpeg`
118
+
119
+ # Standalone: foreground, Ctrl-C to stop
120
+ centauri rtsp --host 192.168.1.209
121
+ # → rtsp://<this-host>:8554/printer
122
+
123
+ # Or, integrated with the HTTP server + web UI
124
+ centauri server --host 192.168.1.209 --rtsp --bind 0.0.0.0
125
+ # Web UI gains a STREAM panel with a toggle, a copy-URL button, and live status
126
+ # at http://<this-host>:8787/ui/
127
+ ```
128
+
129
+ Open that URL in **VLC** (Media → Open Network Stream), point **Home Assistant**
130
+ at it via the Generic Camera integration, or feed it to **Frigate** / **Jellyfin**
131
+ / **Synology Surveillance** for NVR recording and motion detection.
132
+
133
+ MediaMTX runs the ffmpeg transcode only while at least one client is actually
134
+ connected, so idle cost is zero. Tunable: `--fps`, `--bitrate`, `--preset`,
135
+ `--path`, `--port`, `--bind`. Run `centauri rtsp --help` (or
136
+ `centauri server --help`) for the full list.
137
+
110
138
  ## Quick start — MCP
111
139
 
112
140
  Register the server with your agent. With Claude Code:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycentauri"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Python client and MCP server for Elegoo Centauri Carbon 3D printers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "discover",
16
16
  ]
17
17
 
18
- __version__ = "0.3.1"
18
+ __version__ = "0.4.0"
@@ -345,6 +345,18 @@ def cmd_server(
345
345
  port: int = typer.Option(8787, "--port", "-p"),
346
346
  enable_control: ControlOpt = False,
347
347
  log_level: str = typer.Option("info", "--log-level"),
348
+ rtsp: bool = typer.Option(
349
+ False,
350
+ "--rtsp/--no-rtsp",
351
+ help="Enable /api/rtsp/* endpoints and the STREAM panel in the web UI.",
352
+ ),
353
+ rtsp_port: int = typer.Option(8554, "--rtsp-port", help="RTSP port (when --rtsp)."),
354
+ rtsp_path: str = typer.Option("printer", "--rtsp-path", help="RTSP URL path."),
355
+ rtsp_bind: str = typer.Option(
356
+ "0.0.0.0", "--rtsp-bind", help="Interface MediaMTX binds to for RTSP."
357
+ ),
358
+ rtsp_fps: int = typer.Option(15, "--rtsp-fps"),
359
+ rtsp_bitrate: str = typer.Option("2M", "--rtsp-bitrate"),
348
360
  ) -> None:
349
361
  """Run the HTTP + SSE server (requires `pip install 'pycentauri[server]'`)."""
350
362
  try:
@@ -358,6 +370,20 @@ def cmd_server(
358
370
  return await _resolve_target(host)
359
371
 
360
372
  h, mid = asyncio.run(resolve())
373
+
374
+ rtsp_cfg = None
375
+ if rtsp:
376
+ from pycentauri.rtsp import RtspConfig
377
+
378
+ rtsp_cfg = RtspConfig(
379
+ printer_host=h,
380
+ rtsp_port=rtsp_port,
381
+ bind=rtsp_bind,
382
+ path=rtsp_path,
383
+ fps=rtsp_fps,
384
+ bitrate=rtsp_bitrate,
385
+ )
386
+
361
387
  run_server(
362
388
  h,
363
389
  bind=bind,
@@ -365,7 +391,62 @@ def cmd_server(
365
391
  enable_control=enable_control,
366
392
  mainboard_id=mid,
367
393
  log_level=log_level,
394
+ rtsp_config=rtsp_cfg,
395
+ )
396
+
397
+
398
+ @app.command("rtsp")
399
+ def cmd_rtsp(
400
+ host: HostOpt = None,
401
+ port: int = typer.Option(8554, "--port", "-p", help="RTSP TCP port."),
402
+ bind: str = typer.Option("0.0.0.0", "--bind", help="Interface to bind MediaMTX on."),
403
+ stream_path: str = typer.Option("printer", "--path", help="RTSP path, e.g. rtsp://.../<path>"),
404
+ fps: int = typer.Option(15, "--fps", help="Re-encode frame rate cap."),
405
+ bitrate: str = typer.Option("2M", "--bitrate", help="ffmpeg video bitrate (e.g. 2M, 4M)."),
406
+ preset: str = typer.Option("veryfast", "--preset", help="libx264 preset."),
407
+ mediamtx_path: str = typer.Option(
408
+ None, "--mediamtx-path", help="Override mediamtx binary path."
409
+ ),
410
+ ffmpeg_path: str = typer.Option(None, "--ffmpeg-path", help="Override ffmpeg binary path."),
411
+ enable_webrtc: bool = typer.Option(
412
+ False, "--webrtc/--no-webrtc", help="Also serve WebRTC (MediaMTX defaults)."
413
+ ),
414
+ enable_hls: bool = typer.Option(False, "--hls/--no-hls", help="Also serve HLS."),
415
+ ) -> None:
416
+ """Re-stream the printer's MJPEG webcam as RTSP/H.264 via MediaMTX.
417
+
418
+ Requires ``mediamtx`` and ``ffmpeg`` on $PATH (or supply --*-path).
419
+ Transcoding only happens while a client is actually connected to the
420
+ RTSP URL, so idle cost is zero.
421
+ """
422
+ from pycentauri.rtsp import RtspConfig, RtspError, run
423
+
424
+ async def resolve() -> tuple[str, str | None]:
425
+ return await _resolve_target(host)
426
+
427
+ h, _mid = asyncio.run(resolve())
428
+
429
+ cfg = RtspConfig(
430
+ printer_host=h,
431
+ rtsp_port=port,
432
+ bind=bind,
433
+ path=stream_path,
434
+ fps=fps,
435
+ bitrate=bitrate,
436
+ preset=preset,
437
+ mediamtx_path=mediamtx_path,
438
+ ffmpeg_path=ffmpeg_path,
439
+ enable_webrtc=enable_webrtc,
440
+ enable_hls=enable_hls,
368
441
  )
442
+ try:
443
+ exit_code = run(cfg)
444
+ except RtspError as err:
445
+ _echo_err(str(err))
446
+ raise typer.Exit(code=1) from err
447
+ except KeyboardInterrupt:
448
+ exit_code = 0
449
+ raise typer.Exit(code=exit_code)
369
450
 
370
451
 
371
452
  @app.command("mcp")
@@ -0,0 +1,259 @@
1
+ """Expose the printer's MJPEG webcam as an RTSP stream.
2
+
3
+ The Centauri Carbon serves MJPEG on ``http://<host>:3031/video``. Most
4
+ consumer video clients (VLC, Home Assistant, Jellyfin, Frigate, NVRs,
5
+ Synology Surveillance, most Android Shield/STB camera apps) prefer
6
+ RTSP/H.264. This module launches `MediaMTX`_ as a subprocess with an
7
+ on-demand transcode pipeline::
8
+
9
+ printer MJPEG → ffmpeg (MJPEG → H.264) → MediaMTX (RTSP server)
10
+
11
+ MediaMTX only spawns the ffmpeg process while at least one client is
12
+ connected to the RTSP path, so idle cost is zero.
13
+
14
+ External binaries required at runtime:
15
+
16
+ * ``mediamtx`` — https://github.com/bluenviron/mediamtx/releases
17
+ * ``ffmpeg`` — any recent build on ``$PATH``
18
+
19
+ .. _MediaMTX: https://github.com/bluenviron/mediamtx
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import os
26
+ import shutil
27
+ import signal
28
+ import subprocess
29
+ import tempfile
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+
34
+ class RtspError(RuntimeError):
35
+ """Raised for setup failures — missing binaries, etc."""
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class RtspConfig:
40
+ """Parameters for the RTSP bridge."""
41
+
42
+ printer_host: str
43
+ rtsp_port: int = 8554
44
+ bind: str = "0.0.0.0"
45
+ path: str = "printer"
46
+ camera_port: int = 3031
47
+ camera_path: str = "/video"
48
+ # ffmpeg encode tuning. ``veryfast`` + ``zerolatency`` keeps CPU low
49
+ # and glass-to-glass latency around 500 ms to 1 s on modest hardware.
50
+ preset: str = "veryfast"
51
+ tune: str = "zerolatency"
52
+ bitrate: str = "2M"
53
+ fps: int = 15
54
+ # Override binary paths if they're not on ``$PATH``.
55
+ mediamtx_path: str | None = None
56
+ ffmpeg_path: str | None = None
57
+ # If set, also serve WebRTC / HLS on default ports; otherwise RTSP-only.
58
+ enable_webrtc: bool = False
59
+ enable_hls: bool = False
60
+ log_level: str = "info"
61
+
62
+
63
+ INSTALL_HINT = (
64
+ "MediaMTX is required and was not found on $PATH.\n"
65
+ "Install it:\n"
66
+ " macOS: brew install mediamtx\n"
67
+ " Linux: download the latest linux_amd64 binary from\n"
68
+ " https://github.com/bluenviron/mediamtx/releases\n"
69
+ " and move it to /usr/local/bin/mediamtx (chmod +x)\n"
70
+ " Or pass --mediamtx-path /full/path/to/mediamtx."
71
+ )
72
+
73
+ FFMPEG_HINT = (
74
+ "ffmpeg is required and was not found on $PATH.\n"
75
+ " macOS: brew install ffmpeg\n"
76
+ " Linux: sudo apt install ffmpeg (or your distro's equivalent)\n"
77
+ " Or pass --ffmpeg-path /full/path/to/ffmpeg."
78
+ )
79
+
80
+
81
+ def find_binary(name: str, override: str | None = None) -> str | None:
82
+ """Locate a binary by name, respecting an explicit override."""
83
+ if override:
84
+ if Path(override).is_file() and os.access(override, os.X_OK):
85
+ return override
86
+ return None
87
+ return shutil.which(name)
88
+
89
+
90
+ def _mediamtx_yaml(cfg: RtspConfig, ffmpeg_bin: str) -> str:
91
+ """Render a MediaMTX YAML config for the requested parameters.
92
+
93
+ ``runOnDemand`` keeps the ffmpeg process idle until the first RTSP
94
+ reader connects; ``runOnDemandRestart`` restarts it if it exits while
95
+ readers are still attached (printer reboots, network blips).
96
+ """
97
+ cam_url = f"http://{cfg.printer_host}:{cfg.camera_port}{cfg.camera_path}"
98
+ rtsp_address = f"{cfg.bind}:{cfg.rtsp_port}" if cfg.bind != "0.0.0.0" else f":{cfg.rtsp_port}"
99
+ webrtc_line = "" if cfg.enable_webrtc else "webrtc: no\n"
100
+ hls_line = "" if cfg.enable_hls else "hls: no\n"
101
+
102
+ # ffmpeg: take the MJPEG multipart stream, re-encode to H.264 baseline,
103
+ # cap fps, push it to MediaMTX on localhost as a relay source. MediaMTX
104
+ # substitutes $RTSP_PORT and $MTX_PATH when it executes this command.
105
+ ffmpeg_cmd = (
106
+ f"{ffmpeg_bin} -hide_banner -loglevel warning "
107
+ f"-fflags nobuffer -flags low_delay "
108
+ f"-f mjpeg -use_wallclock_as_timestamps 1 -i {cam_url} "
109
+ f"-r {cfg.fps} "
110
+ f"-c:v libx264 -preset {cfg.preset} -tune {cfg.tune} "
111
+ f"-profile:v baseline -pix_fmt yuv420p -g {cfg.fps * 2} "
112
+ f"-b:v {cfg.bitrate} -maxrate {cfg.bitrate} -bufsize {cfg.bitrate} "
113
+ f"-an -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:$RTSP_PORT/$MTX_PATH"
114
+ )
115
+
116
+ return (
117
+ f"# pycentauri-generated MediaMTX config — safe to delete.\n"
118
+ f"logLevel: {cfg.log_level}\n"
119
+ f"logDestinations: [stdout]\n"
120
+ f"rtspAddress: {rtsp_address}\n"
121
+ f"rtmp: no\n"
122
+ f"{hls_line}"
123
+ f"{webrtc_line}"
124
+ f"srt: no\n"
125
+ f"paths:\n"
126
+ f" {cfg.path}:\n"
127
+ f" runOnDemand: {ffmpeg_cmd}\n"
128
+ f" runOnDemandRestart: yes\n"
129
+ f" runOnDemandCloseAfter: 30s\n"
130
+ )
131
+
132
+
133
+ def render_config(cfg: RtspConfig, ffmpeg_bin: str | None = None) -> str:
134
+ """Public entry point for tests — render the YAML config as a string."""
135
+ ffmpeg = ffmpeg_bin or find_binary("ffmpeg", cfg.ffmpeg_path) or "ffmpeg"
136
+ return _mediamtx_yaml(cfg, ffmpeg)
137
+
138
+
139
+ def ensure_binaries(cfg: RtspConfig) -> tuple[str, str]:
140
+ """Return ``(mediamtx_bin, ffmpeg_bin)`` or raise :class:`RtspError`."""
141
+ mediamtx = find_binary("mediamtx", cfg.mediamtx_path)
142
+ if mediamtx is None:
143
+ raise RtspError(INSTALL_HINT)
144
+ ffmpeg = find_binary("ffmpeg", cfg.ffmpeg_path)
145
+ if ffmpeg is None:
146
+ raise RtspError(FFMPEG_HINT)
147
+ return mediamtx, ffmpeg
148
+
149
+
150
+ def build_urls(cfg: RtspConfig, *, advertised_host: str | None = None) -> list[str]:
151
+ """Return the RTSP URLs a client should use to connect."""
152
+ host = advertised_host or cfg.bind
153
+ if host in ("0.0.0.0", "::", ""):
154
+ host = "<this-host>"
155
+ return [f"rtsp://{host}:{cfg.rtsp_port}/{cfg.path}"]
156
+
157
+
158
+ def run(cfg: RtspConfig) -> int:
159
+ """Launch MediaMTX in the foreground. Returns its exit code.
160
+
161
+ The caller owns the lifecycle — we block until MediaMTX exits or we get
162
+ SIGINT/SIGTERM, then propagate the signal into the child and wait for
163
+ it to clean up.
164
+ """
165
+ mediamtx_bin, ffmpeg_bin = ensure_binaries(cfg)
166
+
167
+ yaml_text = _mediamtx_yaml(cfg, ffmpeg_bin)
168
+
169
+ # Write the config to a tempfile that outlives this scope only until
170
+ # MediaMTX has read it (which happens immediately at startup).
171
+ fd, cfg_path = tempfile.mkstemp(prefix="pycentauri-mediamtx-", suffix=".yml", text=True)
172
+ try:
173
+ with os.fdopen(fd, "w") as f:
174
+ f.write(yaml_text)
175
+
176
+ print(f"pycentauri: launching MediaMTX ({mediamtx_bin})")
177
+ print(f" printer MJPEG → {cfg.printer_host}:{cfg.camera_port}{cfg.camera_path}")
178
+ print(" RTSP URLs (share with VLC / HASS / Jellyfin / Frigate):")
179
+ for url in build_urls(cfg):
180
+ print(f" {url}")
181
+ print(f" config: {cfg_path}")
182
+ print(" (Ctrl-C to stop)")
183
+
184
+ proc = subprocess.Popen(
185
+ [mediamtx_bin, cfg_path],
186
+ stdout=None,
187
+ stderr=None,
188
+ start_new_session=True,
189
+ )
190
+
191
+ def _forward(signum: int, _frame: object) -> None:
192
+ proc.send_signal(signum)
193
+
194
+ old_int = signal.signal(signal.SIGINT, _forward)
195
+ old_term = signal.signal(signal.SIGTERM, _forward)
196
+ try:
197
+ return proc.wait()
198
+ finally:
199
+ signal.signal(signal.SIGINT, old_int)
200
+ signal.signal(signal.SIGTERM, old_term)
201
+ finally:
202
+ with contextlib.suppress(OSError):
203
+ os.unlink(cfg_path)
204
+
205
+
206
+ def start_detached(cfg: RtspConfig) -> tuple[subprocess.Popen[bytes], str]:
207
+ """Spawn MediaMTX as a detached subprocess for supervised lifetimes.
208
+
209
+ Returns ``(process, config_path)``. The caller owns cleanup:
210
+ :func:`stop_detached` terminates the process and removes the config file.
211
+ Useful when the HTTP server manages RTSP toggling from the web UI.
212
+ """
213
+ mediamtx_bin, ffmpeg_bin = ensure_binaries(cfg)
214
+ yaml_text = _mediamtx_yaml(cfg, ffmpeg_bin)
215
+ fd, cfg_path = tempfile.mkstemp(prefix="pycentauri-mediamtx-", suffix=".yml", text=True)
216
+ with os.fdopen(fd, "w") as f:
217
+ f.write(yaml_text)
218
+ proc = subprocess.Popen(
219
+ [mediamtx_bin, cfg_path],
220
+ stdout=subprocess.DEVNULL,
221
+ stderr=subprocess.DEVNULL,
222
+ start_new_session=True,
223
+ )
224
+ return proc, cfg_path
225
+
226
+
227
+ def stop_detached(
228
+ proc: subprocess.Popen[bytes] | None,
229
+ cfg_path: str | None,
230
+ *,
231
+ timeout: float = 5.0,
232
+ ) -> None:
233
+ """Clean up a :func:`start_detached` pair. Idempotent."""
234
+ if proc is not None and proc.poll() is None:
235
+ proc.terminate()
236
+ try:
237
+ proc.wait(timeout=timeout)
238
+ except subprocess.TimeoutExpired:
239
+ proc.kill()
240
+ with contextlib.suppress(Exception):
241
+ proc.wait(timeout=2.0)
242
+ if cfg_path:
243
+ with contextlib.suppress(OSError):
244
+ os.unlink(cfg_path)
245
+
246
+
247
+ __all__ = [
248
+ "FFMPEG_HINT",
249
+ "INSTALL_HINT",
250
+ "RtspConfig",
251
+ "RtspError",
252
+ "build_urls",
253
+ "ensure_binaries",
254
+ "find_binary",
255
+ "render_config",
256
+ "run",
257
+ "start_detached",
258
+ "stop_detached",
259
+ ]
@@ -28,6 +28,7 @@ import asyncio
28
28
  import contextlib
29
29
  import json
30
30
  import logging
31
+ import subprocess
31
32
  from collections.abc import AsyncIterator
32
33
  from contextlib import asynccontextmanager
33
34
  from importlib.resources import files as resource_files
@@ -41,6 +42,7 @@ from pydantic import BaseModel, Field
41
42
  from sse_starlette.sse import EventSourceResponse
42
43
 
43
44
  from pycentauri import __version__
45
+ from pycentauri import rtsp as rtsp_module
44
46
  from pycentauri.camera import CAMERA_PATH, CAMERA_PORT
45
47
  from pycentauri.client import ControlDisabledError, Printer, PrinterError
46
48
  from pycentauri.discovery import DiscoveredPrinter
@@ -143,6 +145,75 @@ class PrinterManager:
143
145
  backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX)
144
146
 
145
147
 
148
+ class RtspController:
149
+ """Start / stop a MediaMTX subprocess on demand from the HTTP server.
150
+
151
+ Designed to be held on ``app.state`` alongside the PrinterManager and
152
+ driven by the web UI. Idempotent: multiple start calls while already
153
+ running are a no-op; stop on a stopped server is a no-op.
154
+ """
155
+
156
+ def __init__(self, cfg: rtsp_module.RtspConfig) -> None:
157
+ self.cfg = cfg
158
+ self._proc: subprocess.Popen[bytes] | None = None
159
+ self._cfg_path: str | None = None
160
+ self._last_error: str | None = None
161
+ self._lock = asyncio.Lock()
162
+
163
+ @property
164
+ def running(self) -> bool:
165
+ return self._proc is not None and self._proc.poll() is None
166
+
167
+ @property
168
+ def last_error(self) -> str | None:
169
+ return self._last_error
170
+
171
+ def urls(self, advertised_host: str | None = None) -> list[str]:
172
+ return rtsp_module.build_urls(self.cfg, advertised_host=advertised_host)
173
+
174
+ def available(self) -> bool:
175
+ try:
176
+ rtsp_module.ensure_binaries(self.cfg)
177
+ return True
178
+ except rtsp_module.RtspError:
179
+ return False
180
+
181
+ def unavailable_reason(self) -> str | None:
182
+ try:
183
+ rtsp_module.ensure_binaries(self.cfg)
184
+ return None
185
+ except rtsp_module.RtspError as err:
186
+ return str(err)
187
+
188
+ async def start(self) -> None:
189
+ async with self._lock:
190
+ if self.running:
191
+ return
192
+ # The subprocess spawn itself is fast but blocks briefly; run it
193
+ # in a thread so we don't stall the event loop.
194
+ loop = asyncio.get_running_loop()
195
+ try:
196
+ proc, cfg_path = await loop.run_in_executor(
197
+ None, rtsp_module.start_detached, self.cfg
198
+ )
199
+ except rtsp_module.RtspError as err:
200
+ self._last_error = str(err)
201
+ raise
202
+ self._proc = proc
203
+ self._cfg_path = cfg_path
204
+ self._last_error = None
205
+
206
+ async def stop(self) -> None:
207
+ async with self._lock:
208
+ proc, cfg_path = self._proc, self._cfg_path
209
+ self._proc = None
210
+ self._cfg_path = None
211
+ if proc is None and cfg_path is None:
212
+ return
213
+ loop = asyncio.get_running_loop()
214
+ await loop.run_in_executor(None, rtsp_module.stop_detached, proc, cfg_path)
215
+
216
+
146
217
  # --- Pydantic request bodies ------------------------------------------------
147
218
 
148
219
 
@@ -183,22 +254,28 @@ def create_app(
183
254
  *,
184
255
  enable_control: bool = False,
185
256
  mainboard_id: str | None = None,
257
+ rtsp_config: rtsp_module.RtspConfig | None = None,
186
258
  ) -> FastAPI:
187
259
  """Build the FastAPI app. ``host`` is the printer's IP/hostname.
188
260
 
189
261
  The app runs a single background :class:`PrinterManager` that owns the
190
262
  WebSocket lifecycle. Control endpoints are registered only when
191
- ``enable_control`` is ``True``.
263
+ ``enable_control`` is ``True``. ``rtsp_config`` enables the
264
+ ``/api/rtsp/*`` endpoints and the "STREAM" panel in the web UI.
192
265
  """
193
266
 
194
267
  @asynccontextmanager
195
268
  async def lifespan(app: FastAPI) -> AsyncIterator[None]:
196
269
  manager = PrinterManager(host, enable_control=enable_control, mainboard_id=mainboard_id)
197
270
  app.state.manager = manager
271
+ app.state.rtsp = RtspController(rtsp_config) if rtsp_config is not None else None
198
272
  await manager.start()
199
273
  try:
200
274
  yield
201
275
  finally:
276
+ if app.state.rtsp is not None:
277
+ with contextlib.suppress(Exception):
278
+ await app.state.rtsp.stop()
202
279
  await manager.stop()
203
280
 
204
281
  app = FastAPI(
@@ -342,6 +419,62 @@ def create_app(
342
419
 
343
420
  return EventSourceResponse(gen())
344
421
 
422
+ # --- RTSP bridge --------------------------------------------------------
423
+
424
+ def _rtsp_state(request: Request) -> dict[str, Any]:
425
+ controller: RtspController | None = getattr(request.app.state, "rtsp", None)
426
+ if controller is None:
427
+ return {
428
+ "enabled": False,
429
+ "available": False,
430
+ "running": False,
431
+ "urls": [],
432
+ "advertised_urls": [],
433
+ "reason": "RTSP feature not enabled on server; pass --rtsp on centauri server",
434
+ }
435
+ advertised_host = request.url.hostname
436
+ return {
437
+ "enabled": True,
438
+ "available": controller.available(),
439
+ "running": controller.running,
440
+ "port": controller.cfg.rtsp_port,
441
+ "path": controller.cfg.path,
442
+ "fps": controller.cfg.fps,
443
+ "bitrate": controller.cfg.bitrate,
444
+ "urls": controller.urls(),
445
+ "advertised_urls": controller.urls(advertised_host=advertised_host),
446
+ "reason": controller.unavailable_reason() or controller.last_error,
447
+ }
448
+
449
+ @app.get("/api/rtsp", tags=["rtsp"])
450
+ async def rtsp_status(request: Request) -> dict[str, Any]:
451
+ return _rtsp_state(request)
452
+
453
+ @app.post("/api/rtsp/start", tags=["rtsp"])
454
+ async def rtsp_start(request: Request) -> dict[str, Any]:
455
+ controller: RtspController | None = getattr(request.app.state, "rtsp", None)
456
+ if controller is None:
457
+ raise HTTPException(
458
+ status_code=404,
459
+ detail="RTSP feature not enabled. Launch server with --rtsp.",
460
+ )
461
+ try:
462
+ await controller.start()
463
+ except rtsp_module.RtspError as err:
464
+ raise HTTPException(status_code=503, detail=str(err)) from err
465
+ return _rtsp_state(request)
466
+
467
+ @app.post("/api/rtsp/stop", tags=["rtsp"])
468
+ async def rtsp_stop(request: Request) -> dict[str, Any]:
469
+ controller: RtspController | None = getattr(request.app.state, "rtsp", None)
470
+ if controller is None:
471
+ raise HTTPException(
472
+ status_code=404,
473
+ detail="RTSP feature not enabled. Launch server with --rtsp.",
474
+ )
475
+ await controller.stop()
476
+ return _rtsp_state(request)
477
+
345
478
  # --- Meta / health ------------------------------------------------------
346
479
 
347
480
  @app.get("/api/info", tags=["meta"])
@@ -444,6 +577,7 @@ def run(
444
577
  enable_control: bool = False,
445
578
  mainboard_id: str | None = None,
446
579
  log_level: str = "info",
580
+ rtsp_config: rtsp_module.RtspConfig | None = None,
447
581
  ) -> None:
448
582
  """Launch the server with uvicorn (blocks).
449
583
 
@@ -453,7 +587,12 @@ def run(
453
587
  """
454
588
  import uvicorn
455
589
 
456
- app = create_app(host, enable_control=enable_control, mainboard_id=mainboard_id)
590
+ app = create_app(
591
+ host,
592
+ enable_control=enable_control,
593
+ mainboard_id=mainboard_id,
594
+ rtsp_config=rtsp_config,
595
+ )
457
596
  uvicorn.run(app, host=bind, port=port, log_level=log_level)
458
597
 
459
598
 
@@ -251,6 +251,120 @@ function wireControls() {
251
251
  });
252
252
  }
253
253
 
254
+ // ---------------------------------------------------------------------------
255
+ // RTSP bridge
256
+
257
+ function setRtspMsg(text, kind) {
258
+ const el = $("rtsp-msg");
259
+ el.classList.remove("ok", "err", "warn");
260
+ if (kind) el.classList.add(kind);
261
+ el.textContent = text || "";
262
+ }
263
+
264
+ function renderRtsp(info) {
265
+ const panel = $("rtsp-panel");
266
+ if (!info || !info.enabled) {
267
+ panel.hidden = true;
268
+ return;
269
+ }
270
+ panel.hidden = false;
271
+
272
+ const state = $("rtsp-state");
273
+ const btn = $("btn-rtsp-toggle");
274
+ const lbl = $("btn-rtsp-label");
275
+ const url = $("rtsp-url");
276
+ const meta = $("rtsp-meta");
277
+
278
+ // URL — prefer the advertised URL that uses the hostname the user hit us with.
279
+ const shown = (info.advertised_urls && info.advertised_urls[0]) ||
280
+ (info.urls && info.urls[0]) ||
281
+ "rtsp://—";
282
+ url.textContent = shown;
283
+ url.classList.toggle("dim", !info.running);
284
+
285
+ // Meta: port, path, fps, bitrate
286
+ const parts = [];
287
+ if (info.port) parts.push(`PORT ${info.port}`);
288
+ if (info.path) parts.push(`PATH /${info.path}`);
289
+ if (info.fps) parts.push(`FPS ${info.fps}`);
290
+ if (info.bitrate) parts.push(`BR ${info.bitrate}`);
291
+ meta.textContent = parts.join(" · ");
292
+
293
+ // State + button
294
+ state.classList.remove("on", "off", "err", "na");
295
+ if (!info.available) {
296
+ state.textContent = "N/A";
297
+ state.classList.add("na");
298
+ lbl.textContent = "N/A";
299
+ btn.disabled = true;
300
+ setRtspMsg(info.reason || "MediaMTX or ffmpeg not available.", "err");
301
+ return;
302
+ }
303
+ if (info.running) {
304
+ state.textContent = "ONLINE";
305
+ state.classList.add("on");
306
+ lbl.textContent = "STOP";
307
+ } else {
308
+ state.textContent = "OFFLINE";
309
+ state.classList.add("off");
310
+ lbl.textContent = "START";
311
+ }
312
+ btn.disabled = false;
313
+
314
+ if (info.reason && !info.running) {
315
+ setRtspMsg(info.reason, "warn");
316
+ } else if (info.running) {
317
+ setRtspMsg("MediaMTX running · ffmpeg transcode is on-demand", "ok");
318
+ } else {
319
+ setRtspMsg("", null);
320
+ }
321
+ }
322
+
323
+ async function loadRtsp() {
324
+ try {
325
+ const r = await fetch("/api/rtsp");
326
+ if (!r.ok) { $("rtsp-panel").hidden = true; return; }
327
+ renderRtsp(await r.json());
328
+ } catch (e) {
329
+ $("rtsp-panel").hidden = true;
330
+ }
331
+ }
332
+
333
+ async function toggleRtsp() {
334
+ const btn = $("btn-rtsp-toggle");
335
+ const isRunning = $("rtsp-state").classList.contains("on");
336
+ const path = isRunning ? "/api/rtsp/stop" : "/api/rtsp/start";
337
+ btn.disabled = true;
338
+ setRtspMsg(isRunning ? "stopping MediaMTX…" : "starting MediaMTX…");
339
+ try {
340
+ const r = await fetch(path, { method: "POST" });
341
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
342
+ renderRtsp(await r.json());
343
+ } catch (e) {
344
+ setRtspMsg(`${isRunning ? "stop" : "start"} failed — ${e.message}`, "err");
345
+ btn.disabled = false;
346
+ }
347
+ }
348
+
349
+ function wireRtsp() {
350
+ $("btn-rtsp-toggle")?.addEventListener("click", toggleRtsp);
351
+ $("btn-rtsp-copy")?.addEventListener("click", async () => {
352
+ const url = $("rtsp-url").textContent;
353
+ const btn = $("btn-rtsp-copy");
354
+ try {
355
+ await navigator.clipboard.writeText(url);
356
+ btn.classList.add("done");
357
+ btn.textContent = "OK";
358
+ setTimeout(() => {
359
+ btn.classList.remove("done");
360
+ btn.textContent = "COPY";
361
+ }, 1200);
362
+ } catch (_) {
363
+ setRtspMsg("clipboard blocked — select the URL and copy manually", "warn");
364
+ }
365
+ });
366
+ }
367
+
254
368
  // ---------------------------------------------------------------------------
255
369
  // Webcam resilience — if the MJPEG stream stalls, reload it.
256
370
 
@@ -274,9 +388,13 @@ function wireWebcamKeepalive() {
274
388
 
275
389
  (async function main() {
276
390
  wireControls();
391
+ wireRtsp();
277
392
  wireWebcamKeepalive();
278
393
  await loadInfo();
394
+ await loadRtsp();
279
395
  await pollOnce();
280
396
  connectSSE();
281
397
  setInterval(loadInfo, 60000);
398
+ // RTSP state changes are external, poll occasionally.
399
+ setInterval(loadRtsp, 8000);
282
400
  })();
@@ -167,6 +167,27 @@
167
167
  </div>
168
168
  </div>
169
169
 
170
+ <!-- RTSP stream control -->
171
+ <div class="panel rtsp-panel" id="rtsp-panel" hidden>
172
+ <header class="panel-header">
173
+ <span class="panel-tag">STREAM</span>
174
+ <span class="panel-rule"></span>
175
+ <span class="panel-idx" id="rtsp-state-idx">06</span>
176
+ </header>
177
+ <div class="rtsp-row">
178
+ <span class="rtsp-state" id="rtsp-state">—</span>
179
+ <button id="btn-rtsp-toggle" class="kbd-btn rtsp-toggle" type="button" disabled>
180
+ <span class="kbd-label" id="btn-rtsp-label">—</span>
181
+ </button>
182
+ </div>
183
+ <div class="rtsp-url-wrap">
184
+ <code class="rtsp-url" id="rtsp-url">rtsp://—</code>
185
+ <button id="btn-rtsp-copy" class="copy-btn" type="button" title="Copy URL">COPY</button>
186
+ </div>
187
+ <div class="rtsp-meta" id="rtsp-meta"></div>
188
+ <div class="rtsp-msg" id="rtsp-msg" aria-live="polite"></div>
189
+ </div>
190
+
170
191
  <!-- Kinematics + fans -->
171
192
  <div class="panel tele-panel">
172
193
  <header class="panel-header">
@@ -707,6 +707,96 @@ body {
707
707
  .ctrl-msg.warn { border-left-color: var(--warn); color: var(--warn); }
708
708
  .ctrl-msg.err { border-left-color: var(--danger); color: var(--danger); }
709
709
 
710
+ /* ---- RTSP panel ------------------------------------------- */
711
+
712
+ .rtsp-panel { padding-bottom: 14px; }
713
+
714
+ .rtsp-row {
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: space-between;
718
+ gap: 14px;
719
+ margin-bottom: 12px;
720
+ }
721
+ .rtsp-state {
722
+ font-family: var(--f-display);
723
+ font-weight: 700;
724
+ font-size: 20px;
725
+ letter-spacing: 0.04em;
726
+ color: var(--fg-dim);
727
+ text-transform: uppercase;
728
+ }
729
+ .rtsp-state.on { color: var(--ok); }
730
+ .rtsp-state.off { color: var(--fg-dim); }
731
+ .rtsp-state.err { color: var(--danger); }
732
+ .rtsp-state.na { color: var(--fg-faint); }
733
+
734
+ .rtsp-toggle {
735
+ min-width: 110px;
736
+ padding: 10px 14px;
737
+ border-bottom-width: 3px;
738
+ }
739
+
740
+ .rtsp-url-wrap {
741
+ display: flex;
742
+ align-items: stretch;
743
+ gap: 6px;
744
+ margin-bottom: 10px;
745
+ }
746
+ .rtsp-url {
747
+ flex: 1;
748
+ font-family: var(--f-mono);
749
+ font-size: 11px;
750
+ color: var(--cyan);
751
+ background: var(--bg-inset);
752
+ border: 1px solid var(--rule);
753
+ padding: 8px 10px;
754
+ overflow-x: auto;
755
+ white-space: nowrap;
756
+ user-select: all;
757
+ }
758
+ .rtsp-url.dim { color: var(--fg-faint); }
759
+
760
+ .copy-btn {
761
+ padding: 0 12px;
762
+ font-family: var(--f-label);
763
+ font-size: 10px;
764
+ letter-spacing: 0.2em;
765
+ font-weight: 700;
766
+ color: var(--fg-dim);
767
+ background: var(--bg-inset);
768
+ border: 1px solid var(--rule);
769
+ cursor: pointer;
770
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
771
+ }
772
+ .copy-btn:hover {
773
+ color: var(--amber);
774
+ border-color: var(--amber);
775
+ background: #1a1812;
776
+ }
777
+ .copy-btn.done { color: var(--ok); border-color: var(--ok); }
778
+
779
+ .rtsp-meta {
780
+ font-family: var(--f-mono);
781
+ font-size: 10px;
782
+ color: var(--fg-dim);
783
+ letter-spacing: 0.04em;
784
+ padding: 4px 0;
785
+ }
786
+
787
+ .rtsp-msg {
788
+ margin-top: 6px;
789
+ font-family: var(--f-mono);
790
+ font-size: 11px;
791
+ color: var(--fg-dim);
792
+ min-height: 1.2em;
793
+ padding-left: 10px;
794
+ border-left: 2px solid var(--rule);
795
+ }
796
+ .rtsp-msg.ok { border-left-color: var(--ok); color: var(--ok); }
797
+ .rtsp-msg.err { border-left-color: var(--danger); color: var(--danger); }
798
+ .rtsp-msg.warn { border-left-color: var(--warn); color: var(--warn); }
799
+
710
800
  /* ---- Load-in cascade -------------------------------------- */
711
801
 
712
802
  .panel, .viewport {
@@ -0,0 +1,113 @@
1
+ """Unit tests for the RTSP launcher — no processes actually spawned."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from pycentauri.rtsp import (
10
+ FFMPEG_HINT,
11
+ INSTALL_HINT,
12
+ RtspConfig,
13
+ RtspError,
14
+ build_urls,
15
+ ensure_binaries,
16
+ find_binary,
17
+ render_config,
18
+ )
19
+
20
+
21
+ def test_find_binary_rejects_missing_override(tmp_path: Path) -> None:
22
+ assert find_binary("mediamtx", override=str(tmp_path / "does-not-exist")) is None
23
+
24
+
25
+ def test_find_binary_accepts_executable_override(tmp_path: Path) -> None:
26
+ fake = tmp_path / "fake-bin"
27
+ fake.write_text("#!/bin/sh\nexit 0\n")
28
+ fake.chmod(0o755)
29
+ assert find_binary("whatever", override=str(fake)) == str(fake)
30
+
31
+
32
+ def test_ensure_binaries_raises_when_mediamtx_missing(tmp_path: Path) -> None:
33
+ cfg = RtspConfig(
34
+ printer_host="192.168.1.209",
35
+ mediamtx_path=str(tmp_path / "nope"),
36
+ )
37
+ with pytest.raises(RtspError) as excinfo:
38
+ ensure_binaries(cfg)
39
+ assert INSTALL_HINT in str(excinfo.value)
40
+
41
+
42
+ def test_ensure_binaries_raises_when_ffmpeg_missing(tmp_path: Path) -> None:
43
+ mtx = tmp_path / "mediamtx"
44
+ mtx.write_text("#!/bin/sh\nexit 0\n")
45
+ mtx.chmod(0o755)
46
+ cfg = RtspConfig(
47
+ printer_host="192.168.1.209",
48
+ mediamtx_path=str(mtx),
49
+ ffmpeg_path=str(tmp_path / "no-ffmpeg"),
50
+ )
51
+ with pytest.raises(RtspError) as excinfo:
52
+ ensure_binaries(cfg)
53
+ assert FFMPEG_HINT in str(excinfo.value)
54
+
55
+
56
+ def test_render_config_contains_expected_fields() -> None:
57
+ cfg = RtspConfig(
58
+ printer_host="192.168.1.209",
59
+ rtsp_port=8554,
60
+ bind="0.0.0.0",
61
+ path="printer",
62
+ fps=15,
63
+ bitrate="2M",
64
+ )
65
+ yaml = render_config(cfg, ffmpeg_bin="/usr/bin/ffmpeg")
66
+ assert "rtspAddress: :8554" in yaml
67
+ assert "http://192.168.1.209:3031/video" in yaml
68
+ assert "paths:" in yaml
69
+ assert " printer:" in yaml
70
+ assert "runOnDemand:" in yaml
71
+ assert "/usr/bin/ffmpeg" in yaml
72
+ assert "libx264" in yaml
73
+ assert "-b:v 2M" in yaml
74
+ assert "-r 15" in yaml
75
+ assert "webrtc: no" in yaml
76
+ assert "hls: no" in yaml
77
+
78
+
79
+ def test_render_config_explicit_bind() -> None:
80
+ cfg = RtspConfig(printer_host="h", bind="127.0.0.1", rtsp_port=9000)
81
+ yaml = render_config(cfg, ffmpeg_bin="ffmpeg")
82
+ assert "rtspAddress: 127.0.0.1:9000" in yaml
83
+
84
+
85
+ def test_render_config_custom_path() -> None:
86
+ cfg = RtspConfig(printer_host="h", path="workshop-cam-1")
87
+ yaml = render_config(cfg, ffmpeg_bin="ffmpeg")
88
+ assert " workshop-cam-1:" in yaml
89
+
90
+
91
+ def test_render_config_enables_webrtc_and_hls_when_requested() -> None:
92
+ cfg = RtspConfig(printer_host="h", enable_webrtc=True, enable_hls=True)
93
+ yaml = render_config(cfg, ffmpeg_bin="ffmpeg")
94
+ assert "webrtc: no" not in yaml
95
+ assert "hls: no" not in yaml
96
+
97
+
98
+ def test_build_urls_masks_wildcard_bind() -> None:
99
+ cfg = RtspConfig(printer_host="h", bind="0.0.0.0", rtsp_port=8554, path="printer")
100
+ urls = build_urls(cfg)
101
+ assert urls == ["rtsp://<this-host>:8554/printer"]
102
+
103
+
104
+ def test_build_urls_uses_advertised_host() -> None:
105
+ cfg = RtspConfig(printer_host="h", bind="0.0.0.0", rtsp_port=8554, path="printer")
106
+ assert build_urls(cfg, advertised_host="nix.brancloud.online") == [
107
+ "rtsp://nix.brancloud.online:8554/printer"
108
+ ]
109
+
110
+
111
+ def test_build_urls_with_explicit_bind() -> None:
112
+ cfg = RtspConfig(printer_host="h", bind="192.168.1.101", rtsp_port=8554, path="cam")
113
+ assert build_urls(cfg) == ["rtsp://192.168.1.101:8554/cam"]
@@ -7,6 +7,7 @@ We hit the app through httpx's ASGI transport — no real HTTP port bound.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from pathlib import Path
10
11
  from typing import Any
11
12
 
12
13
  import httpx
@@ -96,6 +97,105 @@ async def test_control_endpoints_registered_when_enabled(monkeypatch: pytest.Mon
96
97
  await server.stop()
97
98
 
98
99
 
100
+ async def test_rtsp_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
101
+ server = _FakePrinter()
102
+ await server.start()
103
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
104
+
105
+ app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD)
106
+ async with app.router.lifespan_context(app), await _asgi_client(app) as client:
107
+ r = await client.get("/api/rtsp")
108
+ assert r.status_code == 200
109
+ body = r.json()
110
+ assert body["enabled"] is False
111
+ assert body["running"] is False
112
+
113
+ # Start/stop should 404 when feature is off.
114
+ r = await client.post("/api/rtsp/start")
115
+ assert r.status_code == 404
116
+ await server.stop()
117
+
118
+
119
+ async def test_rtsp_enabled_reports_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
120
+ # Don't actually spawn mediamtx — point the override at a fake binary so
121
+ # the availability check passes for /api/rtsp/start's "could not launch"
122
+ # path to be exercised, then end with a real stop() no-op.
123
+ fake_mtx = tmp_path / "mediamtx"
124
+ fake_mtx.write_text("#!/bin/sh\nsleep 60\n")
125
+ fake_mtx.chmod(0o755)
126
+ fake_ffmpeg = tmp_path / "ffmpeg"
127
+ fake_ffmpeg.write_text("#!/bin/sh\nsleep 60\n")
128
+ fake_ffmpeg.chmod(0o755)
129
+
130
+ from pycentauri.rtsp import RtspConfig
131
+
132
+ cfg = RtspConfig(
133
+ printer_host="192.168.1.209",
134
+ rtsp_port=18554,
135
+ bind="127.0.0.1",
136
+ path="printer",
137
+ mediamtx_path=str(fake_mtx),
138
+ ffmpeg_path=str(fake_ffmpeg),
139
+ )
140
+
141
+ fake_ws = _FakePrinter()
142
+ await fake_ws.start()
143
+ monkeypatch.setattr("pycentauri.client.WS_PORT", fake_ws.port)
144
+
145
+ app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD, rtsp_config=cfg)
146
+ async with app.router.lifespan_context(app), await _asgi_client(app) as client:
147
+ r = await client.get("/api/rtsp")
148
+ assert r.status_code == 200
149
+ body = r.json()
150
+ assert body["enabled"] is True
151
+ assert body["available"] is True
152
+ assert body["running"] is False
153
+ assert body["port"] == 18554
154
+ assert body["path"] == "printer"
155
+ assert body["urls"] == ["rtsp://127.0.0.1:18554/printer"]
156
+
157
+ # Start will spawn the fake binary (which just sleeps).
158
+ r = await client.post("/api/rtsp/start")
159
+ assert r.status_code == 200
160
+ assert r.json()["running"] is True
161
+
162
+ # Stop cleans up.
163
+ r = await client.post("/api/rtsp/stop")
164
+ assert r.status_code == 200
165
+ assert r.json()["running"] is False
166
+ await fake_ws.stop()
167
+
168
+
169
+ async def test_rtsp_unavailable_when_binaries_missing(
170
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
171
+ ) -> None:
172
+ from pycentauri.rtsp import RtspConfig
173
+
174
+ cfg = RtspConfig(
175
+ printer_host="192.168.1.209",
176
+ mediamtx_path=str(tmp_path / "does-not-exist"),
177
+ )
178
+
179
+ fake_ws = _FakePrinter()
180
+ await fake_ws.start()
181
+ monkeypatch.setattr("pycentauri.client.WS_PORT", fake_ws.port)
182
+
183
+ app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD, rtsp_config=cfg)
184
+ async with app.router.lifespan_context(app), await _asgi_client(app) as client:
185
+ r = await client.get("/api/rtsp")
186
+ body = r.json()
187
+ assert body["enabled"] is True
188
+ assert body["available"] is False
189
+ assert body["running"] is False
190
+ assert "MediaMTX" in (body["reason"] or "")
191
+
192
+ # Start should 503 with an install hint.
193
+ r = await client.post("/api/rtsp/start")
194
+ assert r.status_code == 503
195
+ assert "MediaMTX" in r.text
196
+ await fake_ws.stop()
197
+
198
+
99
199
  async def test_start_print_request_body_validation(monkeypatch: pytest.MonkeyPatch) -> None:
100
200
  server = _FakePrinter()
101
201
  await server.start()
File without changes
File without changes
File without changes