pycentauri 0.3.0__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 (33) hide show
  1. {pycentauri-0.3.0 → pycentauri-0.4.0}/CHANGELOG.md +36 -0
  2. {pycentauri-0.3.0 → pycentauri-0.4.0}/PKG-INFO +32 -4
  3. {pycentauri-0.3.0 → pycentauri-0.4.0}/README.md +31 -3
  4. {pycentauri-0.3.0 → pycentauri-0.4.0}/pyproject.toml +1 -1
  5. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.3.0 → 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.0 → pycentauri-0.4.0}/src/pycentauri/server.py +141 -2
  9. pycentauri-0.4.0/src/pycentauri/web/app.js +400 -0
  10. pycentauri-0.4.0/src/pycentauri/web/index.html +231 -0
  11. pycentauri-0.4.0/src/pycentauri/web/styles.css +834 -0
  12. pycentauri-0.4.0/tests/test_rtsp.py +113 -0
  13. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_server.py +100 -0
  14. pycentauri-0.3.0/src/pycentauri/web/app.js +0 -179
  15. pycentauri-0.3.0/src/pycentauri/web/index.html +0 -85
  16. pycentauri-0.3.0/src/pycentauri/web/styles.css +0 -257
  17. {pycentauri-0.3.0 → pycentauri-0.4.0}/.gitignore +0 -0
  18. {pycentauri-0.3.0 → pycentauri-0.4.0}/LICENSE +0 -0
  19. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/camera.py +0 -0
  20. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/client.py +0 -0
  21. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/discovery.py +0 -0
  22. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/__init__.py +0 -0
  23. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/__main__.py +0 -0
  24. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/server.py +0 -0
  25. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/models.py +0 -0
  26. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/py.typed +0 -0
  27. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/sdcp.py +0 -0
  28. {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/web/__init__.py +0 -0
  29. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/__init__.py +0 -0
  30. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/integration/__init__.py +0 -0
  31. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_client.py +0 -0
  32. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_discovery.py +0 -0
  33. {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_sdcp.py +0 -0
@@ -6,6 +6,42 @@ 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
+
30
+ ## [0.3.1] - 2026-04-22
31
+
32
+ ### Changed
33
+ - **Web UI redesign.** Moved from the generic dark-dashboard look to an
34
+ industrial instrument-panel aesthetic — amber-on-near-black, viewfinder
35
+ corner brackets and a faint crosshair over the webcam, ruler-style
36
+ progress bar with diagonal-hatch fill, tabular-numeric temperature
37
+ gauges with per-channel setpoint bars, kinematics readout with X/Y/Z
38
+ cells and fan/Z-offset/mainboard telemetry, keyboard-key-style F1/F2/F3
39
+ control buttons (with real keyboard shortcuts). Typography: Space Mono
40
+ for display numerals, IBM Plex Mono for data, IBM Plex Sans Condensed
41
+ for SCADA-style labels.
42
+ - Webcam MJPEG element now auto-recovers if the stream stalls for 15s.
43
+ - Added a favicon so browsers stop 404-ing `/favicon.ico`.
44
+
9
45
  ## [0.3.0] - 2026-04-22
10
46
 
11
47
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.3.0
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.0"
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.0"
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
+ ]