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.
- {pycentauri-0.3.0 → pycentauri-0.4.0}/CHANGELOG.md +36 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/PKG-INFO +32 -4
- {pycentauri-0.3.0 → pycentauri-0.4.0}/README.md +31 -3
- {pycentauri-0.3.0 → pycentauri-0.4.0}/pyproject.toml +1 -1
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/cli.py +81 -0
- pycentauri-0.4.0/src/pycentauri/rtsp.py +259 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/server.py +141 -2
- pycentauri-0.4.0/src/pycentauri/web/app.js +400 -0
- pycentauri-0.4.0/src/pycentauri/web/index.html +231 -0
- pycentauri-0.4.0/src/pycentauri/web/styles.css +834 -0
- pycentauri-0.4.0/tests/test_rtsp.py +113 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_server.py +100 -0
- pycentauri-0.3.0/src/pycentauri/web/app.js +0 -179
- pycentauri-0.3.0/src/pycentauri/web/index.html +0 -85
- pycentauri-0.3.0/src/pycentauri/web/styles.css +0 -257
- {pycentauri-0.3.0 → pycentauri-0.4.0}/.gitignore +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/LICENSE +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/client.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/models.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/__init__.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/integration/__init__.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_client.py +0 -0
- {pycentauri-0.3.0 → pycentauri-0.4.0}/tests/test_discovery.py +0 -0
- {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
|
+
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
|
|
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** (
|
|
64
|
-
|
|
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
|
|
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** (
|
|
16
|
-
|
|
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:
|
|
@@ -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
|
+
]
|