diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de6b0e..3d4ca8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed +- **Camera on CC1 firmware >= V1.4.x** — newer firmware gates the MJPEG port + behind SDCP Cmd 386 ("enable video stream"): `:3031/video` accepts + connections but sends no frames until the stream is enabled, so `snapshot`, + `/stream`, and the RTSP bridge all timed out. The client now sends Cmd 386 + best-effort before every snapshot, and the server re-sends it on every + (re)connect — the enable is global on the printer but resets when it + reboots. Cmd 386 is commented out in the official SDK + (`{MethodType::VIDEO_STREAM, 386}`) but firmware-handled — the same story + as Cmds 258/259. Verified live on V1.4.49 2026-08-09. Pre-V1.4 firmware + streams without the enable (this library never sent Cmd 386 before and the + camera worked); the command is documented for those versions but untested + there, so it is **never sent to firmware below V1.4** — the send is gated + on the version the printer reports in its Attributes, and an unknown + version counts as pre-V1.4 (both skip paths are logged). Snapshots adopt + the **port/path** of the firmware-reported `VideoUrl` but always connect + to the configured host — the printer puts its own LAN address in the URL, + which is the wrong host behind a tunnel or NAT. The enable stage inside + `snapshot()` is capped at `min(timeout, 8 s)` so SDCP waits can't eat the + camera-read budget; enable failures are logged and the read still + attempted. The standalone `centauri rtsp` command also sends the enable + before wiring ffmpeg to the MJPEG port. + +### Added +- `Printer.set_video_stream(enabled=True, force=False)` — public wrapper for + Cmd 386; returns the firmware-reported `VideoUrl`, if any. No-op (nothing + sent) on firmware below V1.4 or unknown unless `force=True`, and always a + no-op on the CC2, whose camera does not gate on SDCP. + ## [0.9.0] - 2026-07-15 ### Added diff --git a/src/pycentauri/cc2.py b/src/pycentauri/cc2.py index 7ed49f5..30449b2 100644 --- a/src/pycentauri/cc2.py +++ b/src/pycentauri/cc2.py @@ -437,6 +437,16 @@ async def watch(self) -> AsyncIterator[Status]: finally: self._status_queues.discard(queue) + async def set_video_stream( + self, + enabled: bool = True, + *, + force: bool = False, + timeout: float = 15.0, + ) -> str | None: + """No-op on the CC2 — its camera does not gate on SDCP Cmd 386.""" + return None + async def snapshot(self, *, timeout: float = camera_module.DEFAULT_TIMEOUT) -> bytes: return await camera_module.snapshot( self.host, timeout=timeout, port=camera_module.CAMERA_PORT_CC2 diff --git a/src/pycentauri/cli.py b/src/pycentauri/cli.py index 08848bb..183737f 100644 --- a/src/pycentauri/cli.py +++ b/src/pycentauri/cli.py @@ -826,6 +826,19 @@ async def resolve() -> tuple[str, str | None, int]: # harmless kernel RST — we deliberately never probe :3030, which # is sensitive to connect/close churn. cam = CAMERA_PORT_CC2 if await _port_open(h, 1883) else CAMERA_PORT + if cam == CAMERA_PORT: + # CC1 fw >= V1.4.x gates the MJPEG stream behind Cmd 386; + # enable it before wiring ffmpeg to the port. Version-gated + # in the client (no-op on older firmware). If the printer + # reboots while the bridge runs, re-run this command. + try: + async with await Printer.connect(h, mainboard_id=mid) as printer: + await asyncio.wait_for(printer.set_video_stream(enabled=True), timeout=8.0) + except Exception: + _echo_err( + "warning: could not enable the video stream (Cmd 386); " + "the RTSP feed may be blank on firmware >= V1.4" + ) return h, mid, cam h, _mid, cam_port = asyncio.run(resolve()) diff --git a/src/pycentauri/client.py b/src/pycentauri/client.py index 8530054..9d30dde 100644 --- a/src/pycentauri/client.py +++ b/src/pycentauri/client.py @@ -12,9 +12,11 @@ import asyncio import contextlib import logging +import re from collections.abc import AsyncIterator from types import TracebackType from typing import TYPE_CHECKING, Any, ClassVar +from urllib.parse import urlsplit import websockets from typing_extensions import Self @@ -38,6 +40,25 @@ DEFAULT_REQUEST_TIMEOUT = 15.0 DEFAULT_PUSH_PERIOD_MS = sdcp.DEFAULT_PUSH_PERIOD_MS +# Cmd 386 is verified live on firmware >= V1.4, where it is also required +# (the MJPEG port sends no frames until the stream is enabled). Older +# firmware streams without it and the command is untested there, so +# set_video_stream only sends at or above this version unless forced. +VIDEO_STREAM_MIN_FIRMWARE = (1, 4) + + +def _parse_firmware_version(version: str | None) -> tuple[int, ...] | None: + """Parse a firmware string like ``"V1.4.49"`` into ``(1, 4, 49)``. + + Returns ``None`` when the version is missing or unparseable. + """ + if not version: + return None + m = re.match(r"[vV]?(\d+(?:\.\d+)*)", version.strip()) + if not m: + return None + return tuple(int(part) for part in m.group(1).split(".")) + class PrinterError(RuntimeError): """Base for all client-side printer errors.""" @@ -237,9 +258,96 @@ async def watch(self) -> AsyncIterator[Status]: finally: self._status_queues.discard(queue) + async def set_video_stream( + self, + enabled: bool = True, + *, + force: bool = False, + timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> str | None: + """Enable or disable the webcam's MJPEG stream (SDCP Cmd 386). + + Firmware >= V1.4.x gates the MJPEG port behind this command: the + port accepts connections but emits no frames until the stream is + enabled (verified live on V1.4.49). Older firmware streams + without it, and while Cmd 386 is documented for those versions + too, it is untested there — so the command is only sent when the + printer's reported firmware is >= V1.4. On older or unknown + firmware this is a no-op returning ``None``; pass ``force=True`` + to send regardless. + + The enable is global on the printer and resets when it reboots. + Returns the ``VideoUrl`` reported by the firmware, if any. + """ + if not force: + try: + attrs = await self.attributes(timeout=timeout) + except Exception: + log.warning( + "could not read printer attributes to determine the firmware " + "version; not sending Cmd 386 (video-stream enable)", + exc_info=True, + ) + return None + fw = _parse_firmware_version(attrs.firmware_version) + if fw is None or fw < VIDEO_STREAM_MIN_FIRMWARE: + # Normal on pre-V1.4 firmware (the stream is ungated there). + log.debug( + "firmware %r is below V1.4 or unparseable; not sending Cmd 386", + attrs.firmware_version, + ) + return None + mid = await self.wait_for_mainboard() + resp = await self._request( + sdcp.Cmd.ENABLE_VIDEO_STREAM, + {"Enable": int(bool(enabled))}, + mid, + timeout=timeout, + ) + inner = resp.inner or {} + url = (inner.get("Data") or {}).get("VideoUrl") + if not isinstance(url, str) or not url: + return None + # V1.4.49 returns a scheme-less URL ("172.16.2.78:3031/video"); + # the OpenCentauri docs show "http://…". Normalize to a full URL. + if "://" not in url: + url = f"http://{url}" + return url + async def snapshot(self, *, timeout: float = camera_module.DEFAULT_TIMEOUT) -> bytes: - """Return a single JPEG frame from the built-in webcam.""" - return await camera_module.snapshot(self.host, timeout=timeout) + """Return a single JPEG frame from the built-in webcam. + + On firmware >= V1.4 the MJPEG stream must be enabled first + (:meth:`set_video_stream`). The port/path of the firmware-reported + ``VideoUrl`` are adopted for the read, but the connection always + targets :attr:`host` — the printer reports its own LAN address in + the URL, which is the wrong host behind a tunnel or NAT. The + enable stage is capped at ``min(timeout, 8.0)`` seconds so SDCP + waits cannot eat the camera-read budget; an enable failure is + logged and the read still attempted (older firmware streams + without it, and on newer firmware the read's own timeout surfaces + the problem to the caller). + """ + video_url: str | None = None + try: + video_url = await asyncio.wait_for( + self.set_video_stream(enabled=True, timeout=timeout), + timeout=min(timeout, 8.0), + ) + except Exception: + log.warning( + "enabling the video stream (Cmd 386) failed; attempting the camera read anyway", + exc_info=True, + ) + port, path = camera_module.CAMERA_PORT, camera_module.CAMERA_PATH + if video_url: + try: + parts = urlsplit(video_url) + port = parts.port or port + path = parts.path or path + except ValueError: + log.warning("ignoring malformed VideoUrl %r", video_url) + return await camera_module.snapshot(self.host, timeout=timeout, port=port, path=path) async def upload_file( self, diff --git a/src/pycentauri/sdcp.py b/src/pycentauri/sdcp.py index 16bbce2..52f903e 100644 --- a/src/pycentauri/sdcp.py +++ b/src/pycentauri/sdcp.py @@ -40,6 +40,18 @@ class Cmd(IntEnum): GET_PRINT_HISTORY = 320 GET_PRINT_HISTORY_DETAIL = 321 GET_CANVAS_STATUS = 324 + # Cmd 386 is commented out in the SDK ("{MethodType::VIDEO_STREAM, 386} + # // Get camera video stream") but the firmware handles it — and from + # V1.4.x it is *required*: the MJPEG port accepts connections but sends + # no frames until the stream is enabled (verified live on V1.4.49, + # 2026-08-09). Pre-V1.4 firmware streamed without it — this library never + # sent 386 before and the camera worked; the enable is documented for + # those versions too but is untested there. + # Payload {"Enable": 1|0}; response Data carries {"Ack": 0=ok, + # 1=max-streams-exceeded, 2=no-camera, 3=unknown, "VideoUrl": …} — the + # URL is scheme-less on V1.4.49 (":3031/video"), though the + # OpenCentauri docs show it with http://. + ENABLE_VIDEO_STREAM = 386 # Cmd 403 is overloaded — the payload shape dispatches: # {"PrintSpeedPct": N} → set print speed # {"TargetFanSpeed": {"ModelFan":...,"BoxFan":...,"AuxiliaryFan":...}} diff --git a/src/pycentauri/server.py b/src/pycentauri/server.py index 4f0ea4b..12c8de5 100644 --- a/src/pycentauri/server.py +++ b/src/pycentauri/server.py @@ -152,6 +152,21 @@ async def _run(self) -> None: timeout=5.0, ) log.info("connected to %s", self.host) + # CC1 fw >= V1.4.x only streams MJPEG after Cmd 386; send it + # on every (re)connect so /stream, /snapshot and RTSP recover + # when the printer reboots (the enable resets with it). + # No-op on pre-V1.4 firmware (version-gated in the client) + # and on the CC2 (its camera does not gate on SDCP). + try: + await asyncio.wait_for( + self._printer.set_video_stream(enabled=True), + timeout=8.0, + ) + except Exception: + log.warning( + "could not enable the printer video stream (Cmd 386)", + exc_info=True, + ) # The MJPEG port differs per model (CC1 :3031, CC2 :8080) # and we only know which we got after connecting. if self._rtsp_config is not None: diff --git a/tests/test_client.py b/tests/test_client.py index ee96690..0a47056 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,7 +16,12 @@ import pytest from websockets.asyncio.server import serve -from pycentauri.client import ControlDisabledError, Printer, PrinterError +from pycentauri.client import ( + ControlDisabledError, + Printer, + PrinterError, + _parse_firmware_version, +) from pycentauri.models import Status from pycentauri.sdcp import Cmd @@ -26,8 +31,16 @@ class _FakePrinter: """In-process SDCP server just good enough for the client tests.""" - def __init__(self) -> None: + def __init__(self, firmware: str = "V1.4.49") -> None: self.received: list[dict[str, Any]] = [] + self.firmware = firmware + # Model of the firmware's Cmd 386 behavior, derived from the + # version like the real device (same parser the client uses, so + # strings like "V0.3.0-o" work): V1.4.x acks with a scheme-less + # VideoUrl (observed live on V1.4.49). What pre-V1.4 firmware + # returns is unobserved; modeled here, once, as a plain ack with + # no VideoUrl. + self._video_url_in_ack = (_parse_firmware_version(firmware) or (0,)) >= (1, 4) self.port: int | None = None self._server = None self._tasks: set[asyncio.Task[None]] = set() @@ -60,7 +73,7 @@ async def _handler(self, ws) -> None: # type: ignore[no-untyped-def] "MainboardID": MAINBOARD, "Name": "FakeCarbon", "MachineName": "Centauri Carbon", - "FirmwareVersion": "V0.0.0", + "FirmwareVersion": self.firmware, }, }, } @@ -78,6 +91,14 @@ async def _handler(self, ws) -> None: # type: ignore[no-untyped-def] request_id = data.get("RequestID") # Ack the request. + ack: dict[str, Any] = {"Ack": 0} + if cmd == int(Cmd.ENABLE_VIDEO_STREAM) and self._video_url_in_ack: + # Real V1.4.49 firmware returns the URL scheme-less (the + # client normalizes it to http://) with the printer's OWN + # address in it. Deliberately a different host than this + # fake's, so tests catch a client that dials the reported + # host instead of the configured one (wrong behind NAT). + ack["VideoUrl"] = "192.0.2.99:3031/video" await ws.send( json.dumps( { @@ -87,7 +108,7 @@ async def _handler(self, ws) -> None: # type: ignore[no-untyped-def] "Cmd": cmd, "RequestID": request_id, "MainboardID": MAINBOARD, - "Data": {"Ack": 0}, + "Data": ack, }, } ) @@ -135,8 +156,10 @@ async def _push_status(self, ws) -> None: # type: ignore[no-untyped-def] @asynccontextmanager -async def _fake_printer(): - server = _FakePrinter() +async def _fake_printer(firmware: str | None = None): + # None → _FakePrinter's default; the class is the one place it lives + # (test_server.py instantiates _FakePrinter directly). + server = _FakePrinter() if firmware is None else _FakePrinter(firmware=firmware) await server.start() try: yield server @@ -335,3 +358,61 @@ async def test_adjust_disabled_without_control(monkeypatch: pytest.MonkeyPatch) except ControlDisabledError: continue raise AssertionError("expected ControlDisabledError") + + +async def test_set_video_stream_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + async with _fake_printer() as server: + monkeypatch.setattr("pycentauri.client.WS_PORT", server.port) + async with await Printer.connect("127.0.0.1") as printer: + url = await asyncio.wait_for(printer.set_video_stream(), timeout=3) + assert url == "http://192.0.2.99:3031/video" + + sent = [m["Data"] for m in server.received if m["Data"]["Cmd"] == int(Cmd.ENABLE_VIDEO_STREAM)] + assert sent and sent[0]["Data"] == {"Enable": 1} + + +async def test_snapshot_enables_video_first(monkeypatch: pytest.MonkeyPatch) -> None: + """fw >= V1.4.x sends no MJPEG frames until Cmd 386 — snapshot must + enable the stream *before* reading the camera, adopt the VideoUrl's + port/path for the read, and never adopt its host.""" + async with _fake_printer() as server: + grabbed: list[tuple[str, int, str]] = [] + + async def fake_camera_snapshot( + host: str, *, port: int = 0, path: str = "", **_: object + ) -> bytes: + # Ordering: the enable must already be on the wire when the + # camera is read, else the pre-fix bug is back. + cmds = [m["Data"]["Cmd"] for m in server.received] + assert int(Cmd.ENABLE_VIDEO_STREAM) in cmds, "camera read before Cmd 386" + grabbed.append((host, port, path)) + return b"\xff\xd8fake-jpeg" + + monkeypatch.setattr("pycentauri.client.WS_PORT", server.port) + monkeypatch.setattr("pycentauri.client.camera_module.snapshot", fake_camera_snapshot) + async with await Printer.connect("127.0.0.1") as printer: + jpeg = await asyncio.wait_for(printer.snapshot(), timeout=5) + assert jpeg.startswith(b"\xff\xd8") + + # Port/path come from the fake's VideoUrl; the host stays the + # configured one, NOT the 192.0.2.99 the firmware reported. + assert grabbed == [("127.0.0.1", 3031, "/video")] + + +async def test_set_video_stream_gated_on_old_firmware(monkeypatch: pytest.MonkeyPatch) -> None: + """Cmd 386 is untested on pre-V1.4 firmware, so it must not be sent + there unless explicitly forced.""" + async with _fake_printer(firmware="V1.1.29") as server: + monkeypatch.setattr("pycentauri.client.WS_PORT", server.port) + async with await Printer.connect("127.0.0.1") as printer: + assert await asyncio.wait_for(printer.set_video_stream(), timeout=3) is None + cmds = [m["Data"]["Cmd"] for m in server.received] + assert int(Cmd.ENABLE_VIDEO_STREAM) not in cmds + + # force=True must send despite the version gate, and the client + # must not invent a URL when the ack carries none. + url = await asyncio.wait_for(printer.set_video_stream(force=True), timeout=3) + assert url is None + + cmds = [m["Data"]["Cmd"] for m in server.received] + assert cmds.count(int(Cmd.ENABLE_VIDEO_STREAM)) == 1