Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/pycentauri/cc2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/pycentauri/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
112 changes: 110 additions & 2 deletions src/pycentauri/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/pycentauri/sdcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<ip>: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":...}}
Expand Down
15 changes: 15 additions & 0 deletions src/pycentauri/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 87 additions & 6 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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,
},
},
}
Expand All @@ -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(
{
Expand All @@ -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,
},
}
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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