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.
- {pycentauri-0.3.1 → pycentauri-0.4.0}/CHANGELOG.md +21 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/PKG-INFO +32 -4
- {pycentauri-0.3.1 → pycentauri-0.4.0}/README.md +31 -3
- {pycentauri-0.3.1 → pycentauri-0.4.0}/pyproject.toml +1 -1
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/cli.py +81 -0
- pycentauri-0.4.0/src/pycentauri/rtsp.py +259 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/server.py +141 -2
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/app.js +118 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/index.html +21 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/styles.css +90 -0
- pycentauri-0.4.0/tests/test_rtsp.py +113 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_server.py +100 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/.gitignore +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/LICENSE +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/client.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/models.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/__init__.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/integration/__init__.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_client.py +0 -0
- {pycentauri-0.3.1 → pycentauri-0.4.0}/tests/test_discovery.py +0 -0
- {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
|
+
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
|
+
]
|
|
@@ -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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|