pycentauri 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. pycentauri-0.1.1/CHANGELOG.md +50 -0
  2. {pycentauri-0.1.0 → pycentauri-0.1.1}/PKG-INFO +16 -1
  3. {pycentauri-0.1.0 → pycentauri-0.1.1}/README.md +15 -0
  4. {pycentauri-0.1.0 → pycentauri-0.1.1}/pyproject.toml +1 -1
  5. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/cli.py +31 -19
  7. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/client.py +38 -15
  8. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/discovery.py +15 -5
  9. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/server.py +32 -14
  10. {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/test_client.py +51 -0
  11. pycentauri-0.1.1/tests/test_discovery.py +63 -0
  12. pycentauri-0.1.0/CHANGELOG.md +0 -21
  13. pycentauri-0.1.0/tests/test_discovery.py +0 -63
  14. {pycentauri-0.1.0 → pycentauri-0.1.1}/.github/workflows/ci.yml +0 -0
  15. {pycentauri-0.1.0 → pycentauri-0.1.1}/.github/workflows/publish.yml +0 -0
  16. {pycentauri-0.1.0 → pycentauri-0.1.1}/.gitignore +0 -0
  17. {pycentauri-0.1.0 → pycentauri-0.1.1}/CONTRIBUTING.md +0 -0
  18. {pycentauri-0.1.0 → pycentauri-0.1.1}/LICENSE +0 -0
  19. {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/snapshot.py +0 -0
  20. {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/start_print.py +0 -0
  21. {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/status_watch.py +0 -0
  22. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/camera.py +0 -0
  23. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/__init__.py +0 -0
  24. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/__main__.py +0 -0
  25. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/models.py +0 -0
  26. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/py.typed +0 -0
  27. {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/sdcp.py +0 -0
  28. {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/__init__.py +0 -0
  29. {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/integration/__init__.py +0 -0
  30. {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/test_sdcp.py +0 -0
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a
5
+ Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.1] - 2026-04-22
10
+
11
+ ### Fixed
12
+ - `Printer.status()`, `attributes()`, and all control methods hung
13
+ indefinitely when the printer was in a paused or errored state. The
14
+ firmware doesn't push `Attributes` spontaneously outside idle/active
15
+ states, and every SDCP command needs a `MainboardID` in its envelope,
16
+ so the client would deadlock waiting for a push that never comes.
17
+ - The CLI and MCP server now pre-discover the printer over UDP before
18
+ opening the WebSocket and pass the mainboard ID into `Printer.connect()`.
19
+
20
+ ### Added
21
+ - `Printer.connect(..., mainboard_id=...)` — pre-seed the mainboard ID
22
+ (e.g. from a prior `discover()`) so the client can send commands
23
+ immediately, without waiting for the printer's first `Attributes` push.
24
+ - `Printer.wait_for_mainboard()` now raises a `PrinterError` with a
25
+ pointer at the `mainboard_id=` workaround instead of a bare
26
+ `asyncio.TimeoutError`.
27
+
28
+ ### Changed
29
+ - `discover()` retransmits the probe multiple times within the timeout
30
+ window, improving reliability on busy or lossy networks. Also binds
31
+ explicitly to `0.0.0.0` so loopback delivery works on macOS.
32
+
33
+ ### Known limits
34
+ - Elegoo firmware accepts at most **5 concurrent WebSocket connections**
35
+ on port 3030. The 6th attempt is rejected at the HTTP upgrade with
36
+ `HTTP 500 "too many client"`. Slots release immediately on close.
37
+
38
+ ## [0.1.0] - 2026-04-22
39
+
40
+ ### Added
41
+ - Async Python client for Elegoo Centauri Carbon printers speaking SDCP v3 over
42
+ WebSocket (`ws://<host>:3030/websocket`).
43
+ - UDP broadcast discovery on port 3000 with the `M99999` probe.
44
+ - MJPEG snapshot grabber for the built-in webcam
45
+ (`/network-device-manager/network/camera`).
46
+ - `centauri` CLI with `discover`, `status`, `watch`, `snapshot`, `attributes`,
47
+ `files`, `print {start,pause,resume,stop}`, `upload`, `mcp`.
48
+ - Optional MCP server (`python -m pycentauri.mcp`) with read-only tools by
49
+ default; control tools registered only when `--enable-control` is set.
50
+ - Apache-2.0 license.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Python client and MCP server for Elegoo Centauri Carbon 3D printers
5
5
  Project-URL: Homepage, https://github.com/bjan/pycentauri
6
6
  Project-URL: Repository, https://github.com/bjan/pycentauri
@@ -145,6 +145,21 @@ Control actions are gated behind an explicit `enable_control=True` (library) or
145
145
  registered when the flag is off, so an LLM never sees them. Still: leaving a
146
146
  printer running unattended with write-capable agents is your responsibility.
147
147
 
148
+ ## Known firmware quirks
149
+
150
+ - **5 concurrent WebSocket connections max.** The printer's SDCP server
151
+ accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
152
+ with body `"too many client"`. Slots release immediately when a
153
+ connection closes — the CLI and MCP server each open/close one per
154
+ invocation, so this is almost never a problem in practice.
155
+ - **Paused / errored states don't auto-push Attributes.** The printer only
156
+ sends its `Attributes` frame spontaneously while idle or printing. In
157
+ paused and errored states it stays silent until asked. Since every SDCP
158
+ command needs the printer's `MainboardID`, the client takes care of this
159
+ by pre-seeding the mainboard ID from a UDP discovery on every connect
160
+ (as of v0.1.1). If you call `Printer.connect()` directly without
161
+ discovery, pass `mainboard_id=` yourself.
162
+
148
163
  ## Credits & licensing
149
164
 
150
165
  - Protocol reference: [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link)
@@ -106,6 +106,21 @@ Control actions are gated behind an explicit `enable_control=True` (library) or
106
106
  registered when the flag is off, so an LLM never sees them. Still: leaving a
107
107
  printer running unattended with write-capable agents is your responsibility.
108
108
 
109
+ ## Known firmware quirks
110
+
111
+ - **5 concurrent WebSocket connections max.** The printer's SDCP server
112
+ accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
113
+ with body `"too many client"`. Slots release immediately when a
114
+ connection closes — the CLI and MCP server each open/close one per
115
+ invocation, so this is almost never a problem in practice.
116
+ - **Paused / errored states don't auto-push Attributes.** The printer only
117
+ sends its `Attributes` frame spontaneously while idle or printing. In
118
+ paused and errored states it stays silent until asked. Since every SDCP
119
+ command needs the printer's `MainboardID`, the client takes care of this
120
+ by pre-seeding the mainboard ID from a UDP discovery on every connect
121
+ (as of v0.1.1). If you call `Printer.connect()` directly without
122
+ discovery, pass `mainboard_id=` yourself.
123
+
109
124
  ## Credits & licensing
110
125
 
111
126
  - Protocol reference: [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycentauri"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Python client and MCP server for Elegoo Centauri Carbon 3D printers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "discover",
16
16
  ]
17
17
 
18
- __version__ = "0.1.0"
18
+ __version__ = "0.1.1"
@@ -55,9 +55,21 @@ def _echo_err(msg: str) -> None:
55
55
  typer.echo(msg, err=True)
56
56
 
57
57
 
58
- async def _resolve_host(host: str | None) -> str:
58
+ async def _resolve_target(host: str | None) -> tuple[str, str | None]:
59
+ """Return (host, mainboard_id). Mainboard_id may be None if discovery failed.
60
+
61
+ Always runs a brief UDP discovery so we can pre-seed the mainboard ID on
62
+ ``Printer.connect()`` — the printer doesn't push Attributes in paused/
63
+ errored states, and without a mainboard ID every command would hang.
64
+ """
59
65
  if host:
60
- return host
66
+ # Short discovery to learn the mainboard; don't fail if it times out.
67
+ found = await discover_printers(timeout=1.0, retries=2)
68
+ for p in found:
69
+ if p.host == host and p.mainboard_id:
70
+ return host, p.mainboard_id
71
+ return host, None
72
+
61
73
  found = await discover_printers(timeout=2.5)
62
74
  if not found:
63
75
  _echo_err("No printers found on the LAN. Pass --host explicitly.")
@@ -67,7 +79,7 @@ async def _resolve_host(host: str | None) -> str:
67
79
  for p in found:
68
80
  _echo_err(f" {p.host} {p.machine_name or '?'} {p.firmware_version or '?'}")
69
81
  raise typer.Exit(code=2)
70
- return found[0].host
82
+ return found[0].host, found[0].mainboard_id
71
83
 
72
84
 
73
85
  def _run(coro: asyncio.coroutines.Coroutine[object, object, object]) -> object: # type: ignore[name-defined]
@@ -128,8 +140,8 @@ def cmd_status(host: HostOpt = None, as_json: JsonOpt = False) -> None:
128
140
  """Print the printer's current status once and exit."""
129
141
 
130
142
  async def run() -> None:
131
- h = await _resolve_host(host)
132
- async with await Printer.connect(h) as printer:
143
+ h, mid = await _resolve_target(host)
144
+ async with await Printer.connect(h, mainboard_id=mid) as printer:
133
145
  st = await printer.status()
134
146
  if as_json:
135
147
  typer.echo(json.dumps(st.raw, indent=2, default=str))
@@ -178,8 +190,8 @@ def cmd_watch(
178
190
  """Stream live status updates until interrupted (Ctrl-C)."""
179
191
 
180
192
  async def run() -> None:
181
- h = await _resolve_host(host)
182
- async with await Printer.connect(h, push_period_ms=period_ms) as printer:
193
+ h, mid = await _resolve_target(host)
194
+ async with await Printer.connect(h, push_period_ms=period_ms, mainboard_id=mid) as printer:
183
195
  async for st in printer.watch():
184
196
  if as_json:
185
197
  typer.echo(json.dumps(st.raw, default=str))
@@ -201,8 +213,8 @@ def cmd_attributes(host: HostOpt = None, as_json: JsonOpt = False) -> None:
201
213
  """Print the printer's attributes (model, firmware, capabilities)."""
202
214
 
203
215
  async def run() -> None:
204
- h = await _resolve_host(host)
205
- async with await Printer.connect(h) as printer:
216
+ h, mid = await _resolve_target(host)
217
+ async with await Printer.connect(h, mainboard_id=mid) as printer:
206
218
  attrs = await printer.attributes()
207
219
  if as_json:
208
220
  typer.echo(json.dumps(attrs.raw, indent=2, default=str))
@@ -226,8 +238,8 @@ def cmd_snapshot(
226
238
  """Save a JPEG snapshot from the built-in webcam."""
227
239
 
228
240
  async def run() -> None:
229
- h = await _resolve_host(host)
230
- async with await Printer.connect(h) as printer:
241
+ h, mid = await _resolve_target(host)
242
+ async with await Printer.connect(h, mainboard_id=mid) as printer:
231
243
  jpeg = await printer.snapshot(timeout=timeout)
232
244
  if str(out) == "-":
233
245
  sys.stdout.buffer.write(jpeg)
@@ -266,8 +278,8 @@ def cmd_print_start(
266
278
  raise typer.Exit(code=2)
267
279
 
268
280
  async def run() -> None:
269
- h = await _resolve_host(host)
270
- async with await Printer.connect(h, enable_control=True) as printer:
281
+ h, mid = await _resolve_target(host)
282
+ async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
271
283
  result = await printer.start_print(
272
284
  filename, storage=storage, auto_leveling=auto_leveling, timelapse=timelapse
273
285
  )
@@ -284,8 +296,8 @@ def cmd_print_pause(host: HostOpt = None, enable_control: ControlOpt = False) ->
284
296
  raise typer.Exit(code=2)
285
297
 
286
298
  async def run() -> None:
287
- h = await _resolve_host(host)
288
- async with await Printer.connect(h, enable_control=True) as printer:
299
+ h, mid = await _resolve_target(host)
300
+ async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
289
301
  await printer.pause()
290
302
  typer.echo("paused")
291
303
 
@@ -300,8 +312,8 @@ def cmd_print_resume(host: HostOpt = None, enable_control: ControlOpt = False) -
300
312
  raise typer.Exit(code=2)
301
313
 
302
314
  async def run() -> None:
303
- h = await _resolve_host(host)
304
- async with await Printer.connect(h, enable_control=True) as printer:
315
+ h, mid = await _resolve_target(host)
316
+ async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
305
317
  await printer.resume()
306
318
  typer.echo("resumed")
307
319
 
@@ -316,8 +328,8 @@ def cmd_print_stop(host: HostOpt = None, enable_control: ControlOpt = False) ->
316
328
  raise typer.Exit(code=2)
317
329
 
318
330
  async def run() -> None:
319
- h = await _resolve_host(host)
320
- async with await Printer.connect(h, enable_control=True) as printer:
331
+ h, mid = await _resolve_target(host)
332
+ async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
321
333
  await printer.stop()
322
334
  typer.echo("stop sent")
323
335
 
@@ -69,6 +69,7 @@ class Printer:
69
69
  *,
70
70
  enable_control: bool = False,
71
71
  push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
72
+ mainboard_id: str | None = None,
72
73
  ) -> None:
73
74
  self.host = host
74
75
  self.enable_control = enable_control
@@ -76,9 +77,11 @@ class Printer:
76
77
 
77
78
  self._ws: ClientConnection | None = None
78
79
  self._reader: asyncio.Task[None] | None = None
79
- self._mainboard_id: str | None = None
80
+ self._mainboard_id: str | None = mainboard_id or None
80
81
 
81
82
  self._mainboard_event = asyncio.Event()
83
+ if self._mainboard_id:
84
+ self._mainboard_event.set()
82
85
  self._latest_status: Status | None = None
83
86
  self._latest_status_event = asyncio.Event()
84
87
  self._latest_attributes: Attributes | None = None
@@ -96,9 +99,23 @@ class Printer:
96
99
  enable_control: bool = False,
97
100
  push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
98
101
  connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
102
+ mainboard_id: str | None = None,
99
103
  ) -> Self:
100
- """Open a WebSocket to the printer and start the reader task."""
101
- self = cls(host, enable_control=enable_control, push_period_ms=push_period_ms)
104
+ """Open a WebSocket to the printer and start the reader task.
105
+
106
+ ``mainboard_id`` lets callers pre-seed the printer's serial/mainboard
107
+ identifier (e.g. from a prior discovery). When set, the client will
108
+ not wait for the printer's spontaneous ``Attributes`` push before
109
+ sending commands — which matters in paused/error states, where the
110
+ firmware doesn't push Attributes until prompted, and every command
111
+ requires ``MainboardID`` in its envelope.
112
+ """
113
+ self = cls(
114
+ host,
115
+ enable_control=enable_control,
116
+ push_period_ms=push_period_ms,
117
+ mainboard_id=mainboard_id,
118
+ )
102
119
  url = f"ws://{host}:{WS_PORT}{WS_PATH}"
103
120
  self._ws = await asyncio.wait_for(connect(url, max_size=None), timeout=connect_timeout)
104
121
  self._reader = asyncio.create_task(self._read_loop(), name=f"pycentauri-reader-{host}")
@@ -125,20 +142,26 @@ class Printer:
125
142
  return self._mainboard_id
126
143
 
127
144
  async def wait_for_mainboard(self, timeout: float = 5.0) -> str:
128
- """Block until the printer has reported its mainboard ID."""
145
+ """Block until the printer has reported its mainboard ID.
146
+
147
+ Returns immediately if ``mainboard_id=`` was passed to
148
+ :meth:`connect`. Otherwise waits for the first ``Attributes`` push
149
+ from the printer. The printer only pushes Attributes spontaneously
150
+ in idle/active states — when paused or errored it stays silent until
151
+ asked, so callers that run in those states should pass ``mainboard_id``
152
+ explicitly (e.g. from a prior :func:`pycentauri.discover`).
153
+ """
129
154
  if self._mainboard_id:
130
155
  return self._mainboard_id
131
- # The printer sends an Attributes push shortly after connect; a fresh
132
- # GET_PRINTER_ATTRIBUTES also triggers one. Fire one off just in case.
133
- with contextlib.suppress(Exception):
134
- await self._send_raw(
135
- sdcp.encode(
136
- sdcp.build_request(
137
- sdcp.Cmd.GET_PRINTER_ATTRIBUTES, None, self._mainboard_id or ""
138
- )
139
- )
140
- )
141
- await asyncio.wait_for(self._mainboard_event.wait(), timeout=timeout)
156
+ try:
157
+ await asyncio.wait_for(self._mainboard_event.wait(), timeout=timeout)
158
+ except asyncio.TimeoutError as err:
159
+ raise PrinterError(
160
+ "printer did not push Attributes within "
161
+ f"{timeout}s. Pass mainboard_id=... to Printer.connect() "
162
+ "(discover() provides one) the firmware does not push "
163
+ "Attributes while paused or errored."
164
+ ) from err
142
165
  assert self._mainboard_id is not None
143
166
  return self._mainboard_id
144
167
 
@@ -68,19 +68,22 @@ async def discover(
68
68
  timeout: float = DEFAULT_TIMEOUT,
69
69
  broadcast_address: str = "255.255.255.255",
70
70
  port: int = DISCOVERY_PORT,
71
+ retries: int = 3,
71
72
  ) -> list[DiscoveredPrinter]:
72
73
  """Broadcast the SDCP discovery probe and collect responders.
73
74
 
74
75
  Blocks for ``timeout`` seconds. Returns one entry per responding printer,
75
- de-duplicated by source IP. Safe to call concurrently from multiple
76
- tasks; each call uses its own UDP socket.
76
+ de-duplicated by source IP. The probe is retransmitted ``retries`` times
77
+ at evenly-spaced intervals within the timeout window, since UDP probes
78
+ can be dropped on busy or congested networks. Safe to call concurrently
79
+ from multiple tasks; each call uses its own UDP socket.
77
80
  """
78
81
  loop = asyncio.get_running_loop()
79
82
 
80
83
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
81
84
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
82
85
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
83
- sock.bind(("", 0))
86
+ sock.bind(("0.0.0.0", 0))
84
87
  sock.setblocking(False)
85
88
 
86
89
  transport, protocol = await loop.create_datagram_endpoint(
@@ -88,8 +91,15 @@ async def discover(
88
91
  sock=sock,
89
92
  )
90
93
  try:
91
- transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
92
- await asyncio.sleep(timeout)
94
+ tries = max(1, retries)
95
+ interval = timeout / max(tries, 1) / 2
96
+ for _ in range(tries):
97
+ transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
98
+ await asyncio.sleep(interval)
99
+ # Listen for the remainder of the budget for late replies.
100
+ remaining = max(0.0, timeout - interval * tries)
101
+ if remaining:
102
+ await asyncio.sleep(remaining)
93
103
  finally:
94
104
  transport.close()
95
105
 
@@ -40,6 +40,24 @@ def _resolve_host() -> str:
40
40
  return host
41
41
 
42
42
 
43
+ async def _resolve_target() -> tuple[str, str | None]:
44
+ """Resolve (host, mainboard_id) with a brief discovery.
45
+
46
+ Caches the mainboard ID in the process env after the first successful
47
+ lookup so subsequent tool invocations don't each pay the discovery cost.
48
+ """
49
+ host = _resolve_host()
50
+ cached = os.environ.get("PYCENTAURI_MAINBOARD_ID")
51
+ if cached:
52
+ return host, cached
53
+ found = await _lan_discover(timeout=1.0, retries=2)
54
+ for p in found:
55
+ if p.host == host and p.mainboard_id:
56
+ os.environ["PYCENTAURI_MAINBOARD_ID"] = p.mainboard_id
57
+ return host, p.mainboard_id
58
+ return host, None
59
+
60
+
43
61
  def build_server(*, enable_control: bool = False) -> FastMCP:
44
62
  """Construct the FastMCP server, registering tools per the control flag.
45
63
 
@@ -55,8 +73,8 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
55
73
  Includes state code, job filename, progress %, layer, temperatures
56
74
  (nozzle / bed / chamber), fan speeds, and the raw SDCP payload.
57
75
  """
58
- host = _resolve_host()
59
- async with await Printer.connect(host) as printer:
76
+ host, mid = await _resolve_target()
77
+ async with await Printer.connect(host, mainboard_id=mid) as printer:
60
78
  st = await printer.status()
61
79
  return {
62
80
  "host": host,
@@ -82,8 +100,8 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
82
100
  @mcp.tool()
83
101
  async def get_attributes() -> dict[str, Any]:
84
102
  """Return printer attributes: model, firmware, mainboard ID, capabilities."""
85
- host = _resolve_host()
86
- async with await Printer.connect(host) as printer:
103
+ host, mid = await _resolve_target()
104
+ async with await Printer.connect(host, mainboard_id=mid) as printer:
87
105
  attrs = await printer.attributes()
88
106
  return {
89
107
  "host": host,
@@ -102,8 +120,8 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
102
120
  The image is returned inline so the agent can see what the printer
103
121
  is currently doing (e.g. to spot layer shifts or spaghetti).
104
122
  """
105
- host = _resolve_host()
106
- async with await Printer.connect(host) as printer:
123
+ host, mid = await _resolve_target()
124
+ async with await Printer.connect(host, mainboard_id=mid) as printer:
107
125
  jpeg = await printer.snapshot()
108
126
  return Image(data=jpeg, format="jpeg")
109
127
 
@@ -144,8 +162,8 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
144
162
  ``"local"`` (default) or ``"udisk"``. Ask the user for confirmation
145
163
  before invoking — running a print unattended is the user's risk.
146
164
  """
147
- host = _resolve_host()
148
- async with await Printer.connect(host, enable_control=True) as printer:
165
+ host, mid = await _resolve_target()
166
+ async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
149
167
  result = await printer.start_print(
150
168
  filename,
151
169
  storage=storage,
@@ -157,24 +175,24 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
157
175
  @mcp.tool()
158
176
  async def pause_print() -> dict[str, Any]:
159
177
  """DESTRUCTIVE. Pause the current print. Ask the user before invoking."""
160
- host = _resolve_host()
161
- async with await Printer.connect(host, enable_control=True) as printer:
178
+ host, mid = await _resolve_target()
179
+ async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
162
180
  result = await printer.pause()
163
181
  return {"ok": True, "response": result.inner}
164
182
 
165
183
  @mcp.tool()
166
184
  async def resume_print() -> dict[str, Any]:
167
185
  """Resume a paused print."""
168
- host = _resolve_host()
169
- async with await Printer.connect(host, enable_control=True) as printer:
186
+ host, mid = await _resolve_target()
187
+ async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
170
188
  result = await printer.resume()
171
189
  return {"ok": True, "response": result.inner}
172
190
 
173
191
  @mcp.tool()
174
192
  async def stop_print() -> dict[str, Any]:
175
193
  """DESTRUCTIVE. Stop the current print. Ask the user before invoking."""
176
- host = _resolve_host()
177
- async with await Printer.connect(host, enable_control=True) as printer:
194
+ host, mid = await _resolve_target()
195
+ async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
178
196
  result = await printer.stop()
179
197
  return {"ok": True, "response": result.inner}
180
198
 
@@ -185,6 +185,57 @@ async def test_control_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> N
185
185
  await printer.stop()
186
186
 
187
187
 
188
+ async def test_preseeded_mainboard_skips_attributes_wait(monkeypatch: pytest.MonkeyPatch) -> None:
189
+ """When the caller passes mainboard_id=, commands work before Attributes arrive.
190
+
191
+ Simulates the paused-printer scenario: the fake server only pushes
192
+ Attributes after it sees a subscribe, so a client that waits for
193
+ Attributes before subscribing would deadlock. With mainboard_id=
194
+ preset, the client can subscribe immediately.
195
+ """
196
+
197
+ class _SilentPrinter(_FakePrinter):
198
+ async def _handler(self, ws) -> None: # type: ignore[no-untyped-def,override]
199
+ # Do NOT push Attributes automatically — only reply to commands.
200
+ async for raw in ws:
201
+ try:
202
+ msg = json.loads(raw)
203
+ except json.JSONDecodeError:
204
+ continue
205
+ self.received.append(msg)
206
+ data = msg.get("Data") or {}
207
+ cmd = data.get("Cmd")
208
+ request_id = data.get("RequestID")
209
+ await ws.send(
210
+ json.dumps(
211
+ {
212
+ "Id": MAINBOARD,
213
+ "Topic": f"sdcp/response/{MAINBOARD}",
214
+ "Data": {
215
+ "Cmd": cmd,
216
+ "RequestID": request_id,
217
+ "MainboardID": MAINBOARD,
218
+ "Data": {"Ack": 0},
219
+ },
220
+ }
221
+ )
222
+ )
223
+
224
+ server = _SilentPrinter()
225
+ await server.start()
226
+ try:
227
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
228
+ async with await Printer.connect(
229
+ "127.0.0.1", enable_control=True, mainboard_id=MAINBOARD
230
+ ) as printer:
231
+ # Must work without any Attributes push ever arriving.
232
+ result = await asyncio.wait_for(printer.pause(), timeout=3)
233
+ assert result.inner is not None and result.inner["Data"]["Ack"] == 0
234
+ assert any(m["Data"]["Cmd"] == int(Cmd.PAUSE_PRINT) for m in server.received)
235
+ finally:
236
+ await server.stop()
237
+
238
+
188
239
  async def test_control_enabled_sends_commands(monkeypatch: pytest.MonkeyPatch) -> None:
189
240
  async with _fake_printer() as server:
190
241
  monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
@@ -0,0 +1,63 @@
1
+ """Unit tests for discovery response parsing.
2
+
3
+ We test the parser against canned responses rather than exercising the full
4
+ UDP round-trip — loopback UDP timing is flaky across CI runners (macOS and
5
+ some Python versions drop packets sent to 127.0.0.1 from a wildcard-bound
6
+ socket within a short window). The actual broadcast is verified live against
7
+ a real printer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from pycentauri.discovery import _parse_response
15
+
16
+
17
+ def test_parse_response_populates_fields() -> None:
18
+ raw = json.dumps(
19
+ {
20
+ "Id": "fake",
21
+ "Data": {
22
+ "Name": "fake-carbon",
23
+ "MachineName": "Centauri Carbon",
24
+ "MainboardID": "ffffffff",
25
+ "FirmwareVersion": "V0.0.1",
26
+ },
27
+ }
28
+ ).encode("utf-8")
29
+
30
+ p = _parse_response(raw, "192.168.1.209")
31
+ assert p is not None
32
+ assert p.host == "192.168.1.209"
33
+ assert p.mainboard_id == "ffffffff"
34
+ assert p.name == "fake-carbon"
35
+ assert p.machine_name == "Centauri Carbon"
36
+ assert p.firmware_version == "V0.0.1"
37
+
38
+
39
+ def test_parse_response_handles_missing_keys() -> None:
40
+ """Older firmware may omit some fields — parser should still return a row."""
41
+ raw = json.dumps({"Id": "fake", "Data": {"MainboardID": "aa"}}).encode("utf-8")
42
+ p = _parse_response(raw, "10.0.0.5")
43
+ assert p is not None
44
+ assert p.mainboard_id == "aa"
45
+ assert p.name is None
46
+ assert p.machine_name is None
47
+
48
+
49
+ def test_parse_response_rejects_non_json() -> None:
50
+ assert _parse_response(b"M99999", "10.0.0.5") is None
51
+ assert _parse_response(b"\xff\xfe", "10.0.0.5") is None
52
+
53
+
54
+ def test_parse_response_rejects_non_object() -> None:
55
+ assert _parse_response(b'"just a string"', "10.0.0.5") is None
56
+ assert _parse_response(b"[1, 2, 3]", "10.0.0.5") is None
57
+
58
+
59
+ def test_parse_response_tolerates_missing_data_block() -> None:
60
+ raw = json.dumps({"Id": "fake", "MainboardID": "bb"}).encode("utf-8")
61
+ p = _parse_response(raw, "10.0.0.5")
62
+ assert p is not None
63
+ assert p.mainboard_id == "bb"
@@ -1,21 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented here. This project follows
4
- [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a
5
- Changelog](https://keepachangelog.com/en/1.1.0/).
6
-
7
- ## [Unreleased]
8
-
9
- ## [0.1.0] - TBD
10
-
11
- ### Added
12
- - Async Python client for Elegoo Centauri Carbon printers speaking SDCP v3 over
13
- WebSocket (`ws://<host>:3030/websocket`).
14
- - UDP broadcast discovery on port 3000 with the `M99999` probe.
15
- - MJPEG snapshot grabber for the built-in webcam
16
- (`/network-device-manager/network/camera`).
17
- - `centauri` CLI with `discover`, `status`, `watch`, `snapshot`, `attributes`,
18
- `files`, `print {start,pause,resume,stop}`, `upload`, `mcp`.
19
- - Optional MCP server (`python -m pycentauri.mcp`) with read-only tools by
20
- default; control tools registered only when `--enable-control` is set.
21
- - Apache-2.0 license.
@@ -1,63 +0,0 @@
1
- """Loopback UDP test for the discovery probe."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import contextlib
7
- import json
8
- import socket
9
-
10
- import pytest
11
-
12
- from pycentauri.discovery import DISCOVERY_PROBE, discover
13
-
14
-
15
- @pytest.fixture()
16
- def fake_responder() -> tuple[socket.socket, int]:
17
- """Bind a UDP socket on localhost that replies to the SDCP probe."""
18
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
19
- sock.bind(("127.0.0.1", 0))
20
- _, port = sock.getsockname()
21
- return sock, port
22
-
23
-
24
- async def test_discover_parses_response(fake_responder: tuple[socket.socket, int]) -> None:
25
- sock, port = fake_responder
26
-
27
- async def responder() -> None:
28
- loop = asyncio.get_running_loop()
29
- sock.setblocking(False)
30
- while True:
31
- try:
32
- data, addr = await loop.sock_recvfrom(sock, 1024)
33
- except (asyncio.CancelledError, OSError):
34
- break
35
- if data != DISCOVERY_PROBE:
36
- continue
37
- reply = json.dumps(
38
- {
39
- "Id": "fake",
40
- "Data": {
41
- "Name": "fake-carbon",
42
- "MachineName": "Centauri Carbon",
43
- "MainboardID": "ffffffff",
44
- "FirmwareVersion": "V0.0.1",
45
- },
46
- }
47
- ).encode("utf-8")
48
- sock.sendto(reply, addr)
49
-
50
- task = asyncio.create_task(responder())
51
- try:
52
- found = await discover(timeout=0.3, broadcast_address="127.0.0.1", port=port)
53
- finally:
54
- task.cancel()
55
- with contextlib.suppress(asyncio.CancelledError, Exception):
56
- await task
57
- sock.close()
58
-
59
- assert len(found) == 1
60
- p = found[0]
61
- assert p.mainboard_id == "ffffffff"
62
- assert p.machine_name == "Centauri Carbon"
63
- assert p.firmware_version == "V0.0.1"
File without changes
File without changes
File without changes
File without changes