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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- **CC2 discovery is now implemented.** The CC2 listens on UDP port 52700
for JSON-RPC `{"id": 0, "method": 7000}` as opposed to the `M99999`
payload over port 3000 on the CC1. `--access-code` must still be passed.

## [0.7.0] - 2026-07-14

### Fixed
Expand Down
20 changes: 15 additions & 5 deletions src/pycentauri/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async def _resolve_target(host: str | None) -> tuple[str, str | None]:
if len(found) > 1:
_echo_err(f"Multiple printers found ({len(found)}); pass --host explicitly.")
for p in found:
_echo_err(f" {p.host} {p.machine_name or '?'} {p.firmware_version or '?'}")
_echo_err(f" {p.host} {p.machine_name or '?'} {p.protocol}")
raise typer.Exit(code=2)
return found[0].host, found[0].mainboard_id

Expand Down Expand Up @@ -140,10 +140,13 @@ async def run() -> None:
payload = [
{
"host": p.host,
"protocol": p.protocol,
"mainboard_id": p.mainboard_id,
"name": p.name,
"machine_name": p.machine_name,
"firmware_version": p.firmware_version,
"serial_number": p.serial_number,
"lan_status": p.lan_status,
}
for p in found
]
Expand All @@ -153,10 +156,17 @@ async def run() -> None:
typer.echo("(no printers responded)")
return
for p in found:
typer.echo(
f"{p.host:<15s} {p.machine_name or '?':<18s} "
f"fw={p.firmware_version or '?':<8s} id={p.mainboard_id or '?'}"
)
if p.protocol == "cc2":
cloud_note = " (cloud mode — not supported)" if p.lan_status == 0 else ""
typer.echo(
f"{p.host:<15s} {p.machine_name or '?':<18s} "
f"cc2 sn={p.serial_number or '?'}{cloud_note}"
)
else:
typer.echo(
f"{p.host:<15s} {p.machine_name or '?':<18s} "
f"cc1 fw={p.firmware_version or '?':<8s} id={p.mainboard_id or '?'}"
)

_run(run())

Expand Down
44 changes: 37 additions & 7 deletions src/pycentauri/discovery.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""LAN discovery for Elegoo Centauri Carbon printers.

The original Centauri Carbon listens on UDP port 3000 and responds to the
magic probe string ``M99999`` with a JSON payload describing itself. The
newer Centauri Carbon 2 uses a different JSON-RPC probe and is not supported
here.
The original Centauri Carbon (CC1) listens on UDP port 3000 and responds to
the magic probe string ``M99999`` with a JSON payload describing itself. The
Centauri Carbon 2 (CC2) listens on UDP port 52700 and responds to a
JSON-RPC probe (``{"id": 0, "method": 7000}``) with its own JSON payload.
Both probes are broadcast from the same socket, and responses are told apart
by shape: CC1 replies nest their fields under ``Data``, CC2 replies nest
theirs under ``result``.
"""

from __future__ import annotations
Expand All @@ -16,6 +19,8 @@

DISCOVERY_PORT = 3000
DISCOVERY_PROBE = b"M99999"
DISCOVERY_PORT_CC2 = 52700
DISCOVERY_PROBE_CC2 = json.dumps({"id": 0, "method": 7000}).encode("utf-8")
DEFAULT_TIMEOUT = 3.0


Expand All @@ -24,10 +29,13 @@ class DiscoveredPrinter:
"""A printer that answered a discovery broadcast."""

host: str
protocol: str # "cc1" or "cc2"
mainboard_id: str | None
name: str | None
machine_name: str | None
firmware_version: str | None
serial_number: str | None
lan_status: int | None
raw: dict[str, Any]


Expand All @@ -38,14 +46,32 @@ def _parse_response(data: bytes, host: str) -> DiscoveredPrinter | None:
return None
if not isinstance(obj, dict):
return None

result_raw = obj.get("result")
if isinstance(result_raw, dict) and "sn" in result_raw:
return DiscoveredPrinter(
host=host,
protocol="cc2",
mainboard_id=None,
name=result_raw.get("host_name"),
machine_name=result_raw.get("machine_model"),
firmware_version=None,
serial_number=result_raw.get("sn"),
lan_status=result_raw.get("lan_status"),
raw=obj,
)

inner_raw = obj.get("Data")
inner: dict[str, Any] = inner_raw if isinstance(inner_raw, dict) else {}
return DiscoveredPrinter(
host=host,
protocol="cc1",
mainboard_id=inner.get("MainboardID") or obj.get("MainboardID"),
name=inner.get("Name"),
machine_name=inner.get("MachineName"),
firmware_version=inner.get("FirmwareVersion"),
serial_number=None,
lan_status=None,
raw=obj,
)

Expand All @@ -68,14 +94,17 @@ async def discover(
timeout: float = DEFAULT_TIMEOUT,
broadcast_address: str = "255.255.255.255",
port: int = DISCOVERY_PORT,
port_cc2: int = DISCOVERY_PORT_CC2,
retries: int = 3,
) -> list[DiscoveredPrinter]:
"""Broadcast the SDCP discovery probe and collect responders.
"""Broadcast both the CC1 and CC2 discovery probes and collect responders.

Blocks for ``timeout`` seconds. Returns one entry per responding printer,
de-duplicated by source IP. The probe is retransmitted ``retries`` times
de-duplicated by source IP. Each probe is retransmitted ``retries`` times
at evenly-spaced intervals within the timeout window, since UDP probes
can be dropped on busy or congested networks. Safe to call concurrently
can be dropped on busy or congested networks. Both probes are sent from
the same socket, since replies come back to the sender's address:port
regardless of which port they were sent to. Safe to call concurrently
from multiple tasks; each call uses its own UDP socket.
"""
loop = asyncio.get_running_loop()
Expand All @@ -95,6 +124,7 @@ async def discover(
interval = timeout / max(tries, 1) / 2
for _ in range(tries):
transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
transport.sendto(DISCOVERY_PROBE_CC2, (broadcast_address, port_cc2))
await asyncio.sleep(interval)
# Listen for the remainder of the budget for late replies.
remaining = max(0.0, timeout - interval * tries)
Expand Down
5 changes: 4 additions & 1 deletion src/pycentauri/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ async def get_snapshot() -> Image:

@mcp.tool()
async def discover_printers() -> list[dict[str, Any]]:
"""Broadcast the SDCP discovery probe and return responding printers.
"""Broadcast the CC1 and CC2 discovery probes and return responding printers.

Useful to verify the configured host matches what's actually on the
LAN, or to find a newly-added printer's IP.
Expand All @@ -149,10 +149,13 @@ async def discover_printers() -> list[dict[str, Any]]:
return [
{
"host": p.host,
"protocol": p.protocol,
"mainboard_id": p.mainboard_id,
"name": p.name,
"machine_name": p.machine_name,
"firmware_version": p.firmware_version,
"serial_number": p.serial_number,
"lan_status": p.lan_status,
}
for p in found
]
Expand Down
5 changes: 4 additions & 1 deletion src/pycentauri/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* ``GET /status`` / ``GET /attributes`` — printer state (JSON)
* ``GET /snapshot`` / ``GET /stream`` — webcam JPEG / MJPEG proxy
* ``GET /events/status`` — Server-Sent Events stream of status pushes
* ``GET /discover`` — UDP LAN scan (finds CC1s)
* ``GET /discover`` — UDP LAN scan (finds CC1s and CC2s)
* ``GET /canvas`` — Canvas multi-filament state (CC2)
* ``GET|POST /api/rtsp*`` — RTSP bridge state/control (with ``--rtsp``)
* ``POST /print/{start,pause,resume,stop,speed,fan,temperature}`` and
Expand Down Expand Up @@ -447,10 +447,13 @@ async def discover_endpoint() -> list[dict[str, Any]]:
return [
{
"host": p.host,
"protocol": p.protocol,
"mainboard_id": p.mainboard_id,
"name": p.name,
"machine_name": p.machine_name,
"firmware_version": p.firmware_version,
"serial_number": p.serial_number,
"lan_status": p.lan_status,
}
for p in found
]
Expand Down
36 changes: 36 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def test_parse_response_populates_fields() -> None:
p = _parse_response(raw, "192.168.1.209")
assert p is not None
assert p.host == "192.168.1.209"
assert p.protocol == "cc1"
assert p.mainboard_id == "ffffffff"
assert p.name == "fake-carbon"
assert p.machine_name == "Centauri Carbon"
Expand Down Expand Up @@ -61,3 +62,38 @@ def test_parse_response_tolerates_missing_data_block() -> None:
p = _parse_response(raw, "10.0.0.5")
assert p is not None
assert p.mainboard_id == "bb"


def test_parse_response_recognizes_cc2() -> None:
raw = json.dumps(
{
"id": 0,
"result": {
"host_name": "Centauri Carbon 2",
"machine_model": "Centauri Carbon 2",
"sn": "CC2ABCD1234567890",
"token_status": 0,
"lan_status": 1,
},
}
).encode("utf-8")

p = _parse_response(raw, "192.168.1.50")
assert p is not None
assert p.host == "192.168.1.50"
assert p.protocol == "cc2"
assert p.mainboard_id is None
assert p.name == "Centauri Carbon 2"
assert p.machine_name == "Centauri Carbon 2"
assert p.firmware_version is None
assert p.serial_number == "CC2ABCD1234567890"
assert p.lan_status == 1


def test_parse_response_rejects_cc2_missing_sn() -> None:
"""A result block without ``sn`` doesn't match the CC2 shape — fall through."""
raw = json.dumps({"id": 0, "result": {"host_name": "no serial"}}).encode("utf-8")
p = _parse_response(raw, "10.0.0.5")
assert p is not None
assert p.protocol == "cc1"
assert p.mainboard_id is None