diff --git a/CHANGELOG.md b/CHANGELOG.md index e43d214..a9182ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/pycentauri/cli.py b/src/pycentauri/cli.py index f363de5..740091e 100644 --- a/src/pycentauri/cli.py +++ b/src/pycentauri/cli.py @@ -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 @@ -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 ] @@ -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()) diff --git a/src/pycentauri/discovery.py b/src/pycentauri/discovery.py index 78275cc..03e383c 100644 --- a/src/pycentauri/discovery.py +++ b/src/pycentauri/discovery.py @@ -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 @@ -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 @@ -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] @@ -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, ) @@ -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() @@ -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) diff --git a/src/pycentauri/mcp/server.py b/src/pycentauri/mcp/server.py index cb5cf91..192852d 100644 --- a/src/pycentauri/mcp/server.py +++ b/src/pycentauri/mcp/server.py @@ -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. @@ -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 ] diff --git a/src/pycentauri/server.py b/src/pycentauri/server.py index 263eb26..cf0d15b 100644 --- a/src/pycentauri/server.py +++ b/src/pycentauri/server.py @@ -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 @@ -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 ] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2251a87..9ca5828 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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" @@ -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