pycentauri MCP server
by bjan
README.md
# pycentauri
Local-network toolkit for [Elegoo Centauri Carbon](https://www.elegoo.com/)
3D printers — the **original Centauri Carbon (CC1)** and the **Centauri
Carbon 2 (CC2)**. One async client, six surfaces: Python library, CLI,
MCP server for AI agents, REST/SSE HTTP server, built-in web dashboard,
and an RTSP bridge for your NVR.
No cloud account, no Elegoo servers — everything talks directly to the
printer on your LAN. `pycentauri` auto-detects which model it's talking
to and speaks the right protocol:
| | CC1 | CC2 |
|---|---|---|
| Transport | SDCP v3 over WebSocket (`:3030`) | JSON-RPC over MQTT (`:1883`) |
| Auth | none | access code (printer screen) |
| Discovery | UDP broadcast | direct IP + HTTP bootstrap |
| Webcam | MJPEG `:3031` | MJPEG `:8080` |
| Live head speed | — | ✓ (`gcode_move.speed` ÷ 60 = the screen's mm/s readout) |
| Fan channels | 3 | 5 |
| Canvas multi-filament | — | ✓ (status + auto-refill) |
| File management | list, upload, delete, history | list, upload, delete, disk info, history |
| Filament-switch detection | — | ✓ (position-based) |
> **Status:** alpha, but used daily against real printers. Protocols were
> reverse-engineered from Elegoo's official
> [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK, the
> [`CentauriLink`](https://github.com/CentauriLink/Centauri-Link) project,
> and live wire captures. CC1 tested on firmware V1.1.46 and OpenCentauri
> V0.3.0-o; CC2 tested on firmware 01.03.02.51. Full wire-protocol notes
> live in [`docs/PROTOCOL.md`](docs/PROTOCOL.md).
## Install
```sh
pip install pycentauri # library + CLI
pip install "pycentauri[mcp]" # + MCP server
pip install "pycentauri[server]" # + HTTP REST/SSE server + web UI
pip install "pycentauri[mcp,server]" # all Python surfaces
```
The RTSP bridge additionally requires
[MediaMTX](https://github.com/bluenviron/mediamtx/releases) and `ffmpeg`
on `$PATH`.
Python 3.10+. Core dependencies: `websockets`, `paho-mqtt`, `httpx`,
`typer`, `pydantic`.
## Connecting to your printer
**CC1** needs only its IP (or nothing at all — it answers UDP discovery).
**CC2** needs its IP *and* its access code, found on the printer's
touchscreen under network/connectivity settings. Pass it as
`--access-code` / `access_code=` / `PYCENTAURI_ACCESS_CODE`. The examples
below use `Ab3dEf` as a stand-in — substitute your own.
> **Enable "LAN Only" mode on the CC2** (network settings on the
> touchscreen). The CC2 gates its local API behind it — with LAN Only
> off the printer works through Elegoo's cloud and leaves the local HTTP
> endpoint closed, so pycentauri can't reach it and you'll get a
> connection error. This is required on firmware 2.0 and recommended on
> all CC2 firmware.
Every CLI command accepts `--host` (env: `PYCENTAURI_HOST`). With no host
given, commands try UDP discovery, which only finds CC1s.
## CLI
```sh
# Discovery (CC1 only — CC2 doesn't answer broadcasts)
centauri discover
# Status, attributes, live watch, snapshot
centauri status --host 192.168.1.209 # CC1
centauri status --host 192.168.1.189 --access-code Ab3dEf # CC2
centauri status --host 192.168.1.209 --json
centauri attributes --host 192.168.1.209
centauri watch --host 192.168.1.209
centauri snapshot --host 192.168.1.209 shot.jpg
# Upload a file, then print it — all writes require --enable-control
centauri upload model.gcode --host 192.168.1.209 --enable-control
centauri upload model.gcode --start --host 192.168.1.209 --enable-control
# Print control
centauri print start model.gcode --host 192.168.1.209 --enable-control
centauri print pause --host 192.168.1.209 --enable-control
centauri print resume --host 192.168.1.209 --enable-control
centauri print stop --host 192.168.1.209 --enable-control
# Live adjust while printing
centauri speed sport --host 192.168.1.209 --enable-control
centauri fan --model 100 --aux 60 --chamber 30 --host 192.168.1.209 --enable-control
centauri temp --nozzle 215 --bed 60 --host 192.168.1.209 --enable-control
# File management (both models; `disk` is CC2-only)
centauri files --host 192.168.1.209 # CC1
centauri files --storage u-disk --host 192.168.1.189 --access-code Ab3dEf
centauri disk --host 192.168.1.189 --access-code Ab3dEf # CC2 only
centauri history --host 192.168.1.209 # CC1 or CC2
centauri delete old.gcode --host 192.168.1.209 --enable-control
# Chamber light (both models)
centauri light on --host 192.168.1.189 --access-code Ab3dEf --enable-control
centauri light off --host 192.168.1.189 --access-code Ab3dEf --enable-control
# Canvas multi-filament (CC2 only)
centauri canvas --host 192.168.1.189 --access-code Ab3dEf
centauri refill --on --host 192.168.1.189 --access-code Ab3dEf --enable-control
```
`centauri canvas` prints each tray's filament, color, temperature range,
and loaded state:
```
auto_refill : OFF
active_tray : none
connected : yes
canvas #0:
● tray 0: PLA Wood (PLA) #F72221 [190-230°C]
● tray 1: PLA Wood (PLA) #AF7832 [190-230°C]
● tray 2: PETG (PETG) #A03BF7 [230-260°C]
● tray 3: PLA Wood (PLA) #D2C5A3 [190-230°C]
```
### Speed modes
Both printers accept exactly four speed settings — arbitrary percentages
are silently ignored by the firmware:
| Mode | CC1 wire value (`PrintSpeedPct`) | CC2 wire value (`speed_mode`) |
|---|---|---|
| `silent` | 50 | 0 |
| `balanced` | 100 | 1 |
| `sport` | 130 | 2 |
| `ludicrous` | 160 | 3 |
Speed changes only take effect while a print is actively running.
#### CC2 speed pinning
Historically the CC2 firmware reset the speed mode back to balanced on
every Canvas filament switch, losing your choice. pycentauri works
around it by **pinning** the mode you set and re-applying it when a
filament switch completes (CC2 only, requires `--enable-control`):
- The mode you set via pycentauri is *pinned*.
- When a Canvas filament switch finishes, the pinned mode is re-applied
once (harmless if the firmware didn't reset it).
- The pin clears when the print ends.
A note on firmware: on **02.01.00.00** the printer no longer exposes a
stable "set speed mode" over the wire — `gcode_move.speed_mode` in the
real-time stream is the *current move's* speed factor, which varies per
feature. pycentauri therefore treats the pin as an explicit setting and
never infers or enforces it from that noisy value; it only re-applies
your pinned mode on switch completion. To change speed, set it through
pycentauri (the dashboard, `centauri speed`, etc.).
The CC1 has none of this — its speed mode stays where you put it, so
`set_print_speed` is a plain one-shot there.
## Python library
```python
import asyncio
from pycentauri import Printer, CC2Printer, connect_auto
async def main():
# Explicit CC1
async with await Printer.connect("192.168.1.209") as printer:
st = await printer.status()
print(st.print_status, st.progress, st.temp_nozzle)
# Explicit CC2
async with await CC2Printer.connect("192.168.1.189", access_code="Ab3dEf") as printer:
st = await printer.status()
print(st.temp_nozzle, st.raw["_cc2"]["gcode_move_speed"]) # mm/min; ÷60 = screen's mm/s
canvas = await printer.canvas_status()
for unit in canvas.canvas_list:
for tray in unit.tray_list:
print(tray.tray_id, tray.filament_name, tray.filament_color)
# Auto-detect — port-probes :3030 vs :1883 and returns the right class
async with await connect_auto("192.168.1.189", access_code="Ab3dEf") as printer:
attrs = await printer.attributes()
print(attrs.machine_name, attrs.firmware_version)
asyncio.run(main())
```
Both classes expose the same API: `status()`, `attributes()`, `watch()`
(async iterator of live status), `snapshot()`, `upload_file()`,
`start_print()`, `pause()`, `resume()`, `stop()`, `set_print_speed()`,
`set_fan_speed()`, `set_temperatures()`, `set_light()`, `list_files()`,
`delete_files()`, `disk_info()`, `print_history()`, `canvas_status()`,
`set_auto_refill()`. Write methods require `enable_control=True` at
connect time and raise `ControlDisabledError` otherwise. File
management and Canvas methods raise `PrinterError` on CC1 (not
available over SDCP).
`upload_file(local_path, *, remote_name=None, progress=None)` pushes a
file to the printer's internal storage over chunked HTTP (independent of
the control channel, so it can't disrupt a print) and returns the name
`start_print()` expects — so the typical flow is `await
p.upload_file("model.gcode")` then `await p.start_print(...)`. The
optional `progress` callback receives `(bytes_sent, total_bytes)`.
CC2-only telemetry rides along in `Status.raw["_cc2"]`: the live head
speed (`gcode_move_speed` — the commanded speed of the current move in
**mm/min**; divide by 60 for the mm/s figure the printer's screen
shows), `speed_mode`, filament runout sensor state,
firmware-computed `remaining_time_sec`, `machine_status`/`sub_status`
raw codes, and `external_device` (camera / U-disk presence).
## HTTP server + web UI
```sh
# Read-only, loopback only
centauri server --host 192.168.1.209
# Read + write + RTSP, on the LAN (put an authenticating proxy in front)
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 \
--enable-control --rtsp
# CC2
centauri server --host 192.168.1.189 --access-code Ab3dEf \
--bind 0.0.0.0 --port 8787 --enable-control
# Opt in to a dashboard "update available" badge (the only outbound call;
# off by default — a cached PyPI check every 12 h, fail-silent)
centauri server --host 192.168.1.209 --check-updates
```
The server holds a single long-lived connection to the printer
(WebSocket for CC1, MQTT for CC2) with automatic reconnect and
exponential backoff — it will never exhaust CC1's 5-connection limit.
The **web UI** at `/ui/` is a clean dark dashboard, mobile-friendly and
dependency-free (no CDN assets — works on an air-gapped LAN): live
webcam, job progress with layer/ETA, printer state, thermals, kinematics
(with live head speed on CC2), pause/resume/stop, speed-mode selector,
fan and heater sliders that hydrate from live values, a Canvas panel
with per-tray color swatches and an auto-refill toggle (CC2), and RTSP
bridge controls. Control panels only render when the server was started
with `--enable-control`.
### Endpoints
| Method | Path | Notes |
|---|---|---|
| `GET` | `/` | Redirects to `/ui/` |
| `GET` | `/ui/` | Web dashboard |
| `GET` | `/api/info` | Health + version + connection state |
| `GET` | `/status` | Latest status (typed summary + full `raw` payload) |
| `GET` | `/attributes` | Model, firmware, mainboard ID |
| `GET` | `/snapshot` | Single JPEG frame |
| `GET` | `/stream` | MJPEG proxy (drop into an `<img>` tag) |
| `GET` | `/events/status` | Server-Sent Events stream of status pushes |
| `GET` | `/discover` | UDP LAN scan (finds CC1s) |
| `GET` | `/canvas` | Canvas state (CC2; `501` on CC1) |
| `GET` | `/docs`, `/redoc` | OpenAPI documentation |
| `POST` | `/print/start` | `{"filename": "cube.gcode", "storage": "local"}` † |
| `POST` | `/print/pause` · `/print/resume` · `/print/stop` | † |
| `POST` | `/print/speed` | `{"mode": "sport"}` or `{"mode": 130}` † |
| `POST` | `/print/fan` | `{"model": 50, "auxiliary": 30, "chamber": 0}` — any subset, 0–100 † |
| `POST` | `/print/temperature` | `{"nozzle": 215, "bed": 60}` — any subset, °C, 0 = off † |
| `POST` | `/files/upload` | multipart `file=@model.gcode` (+ optional `start=true`) † |
| `POST` | `/files/delete` | `{"filenames": ["a.gcode"], "storage": "local"}` — refuses the printing file † |
| `GET` | `/files` | `?storage=local&limit=100` — file list |
| `GET` | `/disk` | Disk usage: `total_bytes`, `used_bytes` (CC2; `501` on CC1) |
| `GET` | `/history` | Print job history |
| `POST` | `/light` | `{"on": true}` — chamber light † |
| `POST` | `/canvas/refill` | `{"enabled": true}` (CC2) † |
| `GET` | `/api/rtsp` | RTSP bridge state (when `--rtsp`) |
| `POST` | `/api/rtsp/start` · `/api/rtsp/stop` | Toggle the bridge (when `--rtsp`) |
† requires the server to be launched with `--enable-control`; otherwise
the route isn't registered at all.
Temperature writes are bounds-checked server-side: nozzle 0–300 °C,
bed 0–110 °C, chamber 0–60 °C.
## MCP server (AI agents)
Give Claude Code, Claude Desktop, Cursor, or any MCP client eyes and
hands on your printer:
```sh
# Read-only (status, snapshot, attributes, discovery, canvas)
claude mcp add pycentauri --env PYCENTAURI_HOST=192.168.1.209 \
-- python -m pycentauri.mcp
# With control tools
claude mcp add pycentauri-cc2 \
--env PYCENTAURI_HOST=192.168.1.189 \
--env PYCENTAURI_ACCESS_CODE=Ab3dEf \
-- python -m pycentauri.mcp --enable-control
```
The target host is pinned in the server's environment at spawn time — a
prompt-injected agent cannot redirect commands to an arbitrary IP,
because no tool takes a host parameter.
| Tool | Availability | Description |
|---|---|---|
| `get_status` | always | State, temps, progress, layer, position, fans |
| `get_attributes` | always | Model, firmware, mainboard ID |
| `get_snapshot` | always | Webcam frame as MCP image — the model *sees* the print |
| `discover_printers` | always | UDP LAN scan |
| `get_canvas_status` | always | Canvas trays, colors, auto-refill (CC2) |
| `start_print` | `--enable-control` | Start a file already on the printer |
| `pause_print` / `resume_print` / `stop_print` | `--enable-control` | Job control |
| `set_print_speed` | `--enable-control` | `silent`/`balanced`/`sport`/`ludicrous` |
| `set_fan_speed` | `--enable-control` | Any subset of model/aux/chamber, 0–100% |
| `set_temperatures` | `--enable-control` | Any subset of nozzle/bed/chamber, °C |
| `set_auto_refill` | `--enable-control` | Canvas auto-refill toggle (CC2) |
Control tools aren't merely gated — without the flag they are never
registered, so they don't appear in the model's tool list at all.
## RTSP bridge
Re-streams the printer's MJPEG webcam as H.264/RTSP for clients that
don't speak MJPEG — Home Assistant, Frigate, Jellyfin, Synology
Surveillance, VLC:
```sh
# Standalone (foreground, Ctrl-C to stop)
centauri rtsp --host 192.168.1.209
# → rtsp://<this-host>:8554/printer
# Integrated with the HTTP server — adds a STREAM panel to the web UI
centauri server --host 192.168.1.209 --rtsp --bind 0.0.0.0
```
MediaMTX only runs the ffmpeg transcode while a client is connected, so
idle cost is zero. Tunables: `--fps`, `--bitrate`, `--preset`, `--path`,
`--port` (standalone) or the `--rtsp-*` variants on `centauri server`.
The bridge picks the correct camera port for CC1 vs CC2 automatically.
## Print status codes
`print_status` in the API and library uses the CC1 firmware's code
space, extended with three codes for CC2 Canvas operations:
| Code | Meaning | | Code | Meaning |
|---|---|---|---|---|
| 0 | Idle | | 13 | Printing |
| 1 | Homing | | 14 | Error |
| 5 | Pausing | | 15 | Leveling |
| 6 | Paused | | 16 | Preheating |
| 7 | Stopping | | **27** | **Switching filament** (CC2) |
| 8 | Stopped | | **28** | **Filament load complete** (CC2) |
| 9 | Completed | | **29** | **Unloading filament** (CC2) |
The full table (including CC1's resin-inherited codes) is in
[`docs/PROTOCOL.md`](docs/PROTOCOL.md).
On the CC2, mid-print Canvas filament switches are detected by head
position: the firmware never fully leaves its "printing" state during a
switch, but the head parks at the purge chute behind the bed (y ≥ 258 mm,
physically outside the printable area) for the duration. pycentauri
reports code 27 the entire time the head is parked there mid-print.
## Safety model
- **Explicit opt-in for writes.** Every surface requires
`enable_control=True` / `--enable-control` before any state-changing
command is possible. Read-only is the default everywhere.
- **Bounds-checked heaters.** The library refuses temperature targets
outside nozzle 0–300 °C / bed 0–110 °C / chamber 0–60 °C even though
the firmware might accept them.
- **Unauthenticated HTTP surface.** The REST server has no auth of its
own. Bind it to loopback (the default) or put an authenticating
reverse proxy in front before exposing it beyond localhost.
- **You own unattended printing.** An agent with control tools can pause,
stop, or heat your printer. Leaving one unattended is your call.
## Known firmware quirks
### CC1 (original Centauri Carbon)
- **5 concurrent WebSocket slots, hard.** The 6th connection gets HTTP
500 `"too many client"`. Slots free on close. The CLI opens one per
invocation; the HTTP/MCP servers hold exactly one long-lived slot.
- **Paused/errored states don't push Attributes.** Every SDCP command
needs the printer's `MainboardID`, which normally arrives in an
Attributes push — but not while paused or errored. pycentauri
pre-seeds it from UDP discovery on every connect. If you call
`Printer.connect()` on a paused printer without discovery, pass
`mainboard_id=` yourself.
- **Unknown commands crash the firmware.** A few unrecognised SDCP
commands in quick succession kill the printer's `app` daemon — and any
active print with it. Don't probe undocumented command codes against a
printer that's doing something you care about.
- **The push scheduler goes dormant at idle (and a reboot doesn't wake
it).** The firmware can enter a state where `Cmd 512` subscribes are
acknowledged but no status frame is ever pushed while the printer
sits idle — persisting across reboots (verified 2026-07-05 on
V0.3.0-o). Starting a print revives pushes at full rate. One-shot
`Cmd 0` requests always work, so pycentauri automatically falls back
to polling when a subscribe goes quiet (~7 s updates at idle,
full-rate pushes while printing). Clients that rely purely on
subscribe pushes will hang forever on an idle printer in this state.
### CC2 (Centauri Carbon 2)
- **No UDP discovery.** The CC2 ignores broadcast probes; specify its IP
explicitly. Give it a static DHCP lease — it doesn't register a
hostname with most routers, so its address drifts otherwise.
- **Access code required for MQTT**, passed as the password with
username `elegoo`. The HTTP bootstrap (`/system/info`) wants the same
code as an `X-Token` *query parameter* — it ignores the header form.
- **The webcam is unauthenticated.** MJPEG on `:8080` (any path) is open
to anyone on your LAN, access code or not. That's the firmware's
choice, not ours.
- **Rate limiting.** Rapid-fire MQTT requests (3+ back-to-back) trip a
cooldown of a few seconds during which the broker silently drops
responses. pycentauri's polling cadence stays under it; your scripts
should too.
- **The firmware resets the speed mode to balanced on every Canvas
filament switch.** pycentauri pins your chosen mode and re-applies it
automatically, while still honoring a deliberate balanced from the
touchscreen — see
[CC2 speed pinning](#cc2-speed-pinning-the-firmware-fights-you-so-pycentauri-fights-back)
above for how it tells the two apart.
- **Registrations expire without an app-level PING.** The printer
forgets a registered client after several quiet minutes and silently
stops answering that session's requests — the MQTT connection itself
stays up, so there's no error to catch. pycentauri sends the SDK's
`{"type": "PING"}` keepalive every 30 s to hold the registration; if
you write your own client, you must too.
- **File list (method 1044) and video stream (1042) don't respond** on
firmware 01.03.02.51, so remote print-start on CC2 requires knowing
the filename in advance.
## Project layout & docs
```
src/pycentauri/
├── client.py # CC1: async SDCP-over-WebSocket client
├── cc2.py # CC2: async JSON-RPC-over-MQTT client (same API)
├── connect.py # connect_auto() — port-probe model detection
├── sdcp.py # SDCP v3 envelope build/parse
├── discovery.py # UDP broadcast discovery
├── camera.py # MJPEG frame grabber
├── models.py # Status / Attributes / CanvasStatus / PrintInfo
├── cli.py # Typer CLI
├── server.py # FastAPI app + connection supervisor
├── rtsp.py # MediaMTX/ffmpeg bridge
├── mcp/ # FastMCP stdio server
└── web/ # Static dashboard (no build step, no CDN)
```
- [`docs/PROTOCOL.md`](docs/PROTOCOL.md) — both wire protocols in
detail: envelopes, command/method tables with tested-on dates, status
payloads, error codes, failure modes, and a CC1-vs-CC2 comparison.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — module map and
request flow.
## Development
```sh
git clone https://github.com/bjan/pycentauri && cd pycentauri
python -m venv .venv && .venv/bin/pip install -e ".[mcp,server,dev]"
.venv/bin/ruff check . && .venv/bin/ruff format --check .
.venv/bin/mypy src # strict mode
.venv/bin/pytest -q # no printer required — tests use in-process fakes
```
Tests run against an in-process fake SDCP WebSocket server plus pure
translation-layer tests for CC2; nothing in CI touches real hardware.
Live verification against a physical printer is manual — `centauri
status`, `centauri canvas`, and a fan write are the standard smoke
test after protocol-layer changes.
## Credits & license
- Protocol references: Elegoo's
[`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) SDK
(Apache-2.0) and
[`CentauriLink`](https://github.com/CentauriLink/Centauri-Link).
- Licensed under Apache-2.0 — see [LICENSE](LICENSE).
- Not affiliated with or endorsed by Elegoo. Reverse-engineered
protocols can break with any firmware update; nothing here is
warranted to keep your prints alive.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessUnresponsive