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.
- pycentauri-0.1.1/CHANGELOG.md +50 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/PKG-INFO +16 -1
- {pycentauri-0.1.0 → pycentauri-0.1.1}/README.md +15 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/pyproject.toml +1 -1
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/cli.py +31 -19
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/client.py +38 -15
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/discovery.py +15 -5
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/server.py +32 -14
- {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/test_client.py +51 -0
- pycentauri-0.1.1/tests/test_discovery.py +63 -0
- pycentauri-0.1.0/CHANGELOG.md +0 -21
- pycentauri-0.1.0/tests/test_discovery.py +0 -63
- {pycentauri-0.1.0 → pycentauri-0.1.1}/.github/workflows/ci.yml +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/.github/workflows/publish.yml +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/.gitignore +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/CONTRIBUTING.md +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/LICENSE +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/snapshot.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/start_print.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/examples/status_watch.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/models.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/__init__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.1.1}/tests/integration/__init__.py +0 -0
- {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.
|
|
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)
|
|
@@ -55,9 +55,21 @@ def _echo_err(msg: str) -> None:
|
|
|
55
55
|
typer.echo(msg, err=True)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
async def
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
76
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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"
|
pycentauri-0.1.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|