pycentauri 0.1.0__py3-none-any.whl
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/__init__.py +18 -0
- pycentauri/camera.py +66 -0
- pycentauri/cli.py +341 -0
- pycentauri/client.py +328 -0
- pycentauri/discovery.py +96 -0
- pycentauri/mcp/__init__.py +0 -0
- pycentauri/mcp/__main__.py +4 -0
- pycentauri/mcp/server.py +211 -0
- pycentauri/models.py +229 -0
- pycentauri/py.typed +0 -0
- pycentauri/sdcp.py +238 -0
- pycentauri-0.1.0.dist-info/METADATA +153 -0
- pycentauri-0.1.0.dist-info/RECORD +16 -0
- pycentauri-0.1.0.dist-info/WHEEL +4 -0
- pycentauri-0.1.0.dist-info/entry_points.txt +2 -0
- pycentauri-0.1.0.dist-info/licenses/LICENSE +202 -0
pycentauri/client.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""High-level async client for the Elegoo Centauri Carbon.
|
|
2
|
+
|
|
3
|
+
Opens a single WebSocket to ``ws://<host>:3030/websocket``, routes responses
|
|
4
|
+
back to the requesting coroutines by ``RequestID``, and publishes status /
|
|
5
|
+
attribute pushes to any number of subscribers. Control actions are gated
|
|
6
|
+
behind ``enable_control=True`` — attempting a write without it raises
|
|
7
|
+
:class:`ControlDisabledError` before anything is sent over the wire.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import websockets
|
|
20
|
+
from typing_extensions import Self
|
|
21
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
22
|
+
|
|
23
|
+
from pycentauri import camera as camera_module
|
|
24
|
+
from pycentauri import sdcp
|
|
25
|
+
from pycentauri.models import Attributes, Status
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
WS_PORT = 3030
|
|
31
|
+
WS_PATH = "/websocket"
|
|
32
|
+
DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
33
|
+
DEFAULT_REQUEST_TIMEOUT = 15.0
|
|
34
|
+
DEFAULT_PUSH_PERIOD_MS = sdcp.DEFAULT_PUSH_PERIOD_MS
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PrinterError(RuntimeError):
|
|
38
|
+
"""Base for all client-side printer errors."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ControlDisabledError(PrinterError):
|
|
42
|
+
"""Raised when a control action is attempted without ``enable_control``."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RequestTimeoutError(PrinterError):
|
|
46
|
+
"""Raised when a request to the printer does not receive a response in time."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Printer:
|
|
50
|
+
"""Async client for a single Centauri Carbon.
|
|
51
|
+
|
|
52
|
+
Usage::
|
|
53
|
+
|
|
54
|
+
async with await Printer.connect("192.168.1.209") as printer:
|
|
55
|
+
status = await printer.status()
|
|
56
|
+
|
|
57
|
+
Or without the context manager::
|
|
58
|
+
|
|
59
|
+
printer = await Printer.connect("192.168.1.209")
|
|
60
|
+
try:
|
|
61
|
+
...
|
|
62
|
+
finally:
|
|
63
|
+
await printer.close()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
host: str,
|
|
69
|
+
*,
|
|
70
|
+
enable_control: bool = False,
|
|
71
|
+
push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.host = host
|
|
74
|
+
self.enable_control = enable_control
|
|
75
|
+
self.push_period_ms = push_period_ms
|
|
76
|
+
|
|
77
|
+
self._ws: ClientConnection | None = None
|
|
78
|
+
self._reader: asyncio.Task[None] | None = None
|
|
79
|
+
self._mainboard_id: str | None = None
|
|
80
|
+
|
|
81
|
+
self._mainboard_event = asyncio.Event()
|
|
82
|
+
self._latest_status: Status | None = None
|
|
83
|
+
self._latest_status_event = asyncio.Event()
|
|
84
|
+
self._latest_attributes: Attributes | None = None
|
|
85
|
+
self._latest_attributes_event = asyncio.Event()
|
|
86
|
+
|
|
87
|
+
self._pending: dict[str, asyncio.Future[sdcp.ParsedMessage]] = {}
|
|
88
|
+
self._status_queues: set[asyncio.Queue[Status]] = set()
|
|
89
|
+
self._closed = False
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
async def connect(
|
|
93
|
+
cls,
|
|
94
|
+
host: str,
|
|
95
|
+
*,
|
|
96
|
+
enable_control: bool = False,
|
|
97
|
+
push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
|
|
98
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
99
|
+
) -> 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)
|
|
102
|
+
url = f"ws://{host}:{WS_PORT}{WS_PATH}"
|
|
103
|
+
self._ws = await asyncio.wait_for(connect(url, max_size=None), timeout=connect_timeout)
|
|
104
|
+
self._reader = asyncio.create_task(self._read_loop(), name=f"pycentauri-reader-{host}")
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
# --- context-manager sugar -------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> Self:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(
|
|
113
|
+
self,
|
|
114
|
+
exc_type: type[BaseException] | None,
|
|
115
|
+
exc: BaseException | None,
|
|
116
|
+
tb: TracebackType | None,
|
|
117
|
+
) -> None:
|
|
118
|
+
await self.close()
|
|
119
|
+
|
|
120
|
+
# --- public high-level API -------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def mainboard_id(self) -> str | None:
|
|
124
|
+
"""Mainboard ID learned from the printer (set once the first Attributes push arrives)."""
|
|
125
|
+
return self._mainboard_id
|
|
126
|
+
|
|
127
|
+
async def wait_for_mainboard(self, timeout: float = 5.0) -> str:
|
|
128
|
+
"""Block until the printer has reported its mainboard ID."""
|
|
129
|
+
if self._mainboard_id:
|
|
130
|
+
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)
|
|
142
|
+
assert self._mainboard_id is not None
|
|
143
|
+
return self._mainboard_id
|
|
144
|
+
|
|
145
|
+
async def status(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> Status:
|
|
146
|
+
"""Get the current printer status.
|
|
147
|
+
|
|
148
|
+
If we've already received a push, return it immediately. Otherwise
|
|
149
|
+
subscribe briefly (Cmd 512) and wait for the first push.
|
|
150
|
+
"""
|
|
151
|
+
if self._latest_status is not None:
|
|
152
|
+
return self._latest_status
|
|
153
|
+
await self._ensure_subscribed()
|
|
154
|
+
await asyncio.wait_for(self._latest_status_event.wait(), timeout=timeout)
|
|
155
|
+
assert self._latest_status is not None
|
|
156
|
+
return self._latest_status
|
|
157
|
+
|
|
158
|
+
async def attributes(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> Attributes:
|
|
159
|
+
"""Return the printer's attributes (model, firmware, capabilities)."""
|
|
160
|
+
if self._latest_attributes is not None:
|
|
161
|
+
return self._latest_attributes
|
|
162
|
+
mid = await self.wait_for_mainboard(timeout=timeout)
|
|
163
|
+
await self._request(sdcp.Cmd.GET_PRINTER_ATTRIBUTES, None, mid, timeout=timeout)
|
|
164
|
+
await asyncio.wait_for(self._latest_attributes_event.wait(), timeout=timeout)
|
|
165
|
+
assert self._latest_attributes is not None
|
|
166
|
+
return self._latest_attributes
|
|
167
|
+
|
|
168
|
+
async def watch(self) -> AsyncIterator[Status]:
|
|
169
|
+
"""Yield status updates as they arrive from the printer."""
|
|
170
|
+
await self._ensure_subscribed()
|
|
171
|
+
queue: asyncio.Queue[Status] = asyncio.Queue(maxsize=64)
|
|
172
|
+
self._status_queues.add(queue)
|
|
173
|
+
try:
|
|
174
|
+
if self._latest_status is not None:
|
|
175
|
+
queue.put_nowait(self._latest_status)
|
|
176
|
+
while not self._closed:
|
|
177
|
+
yield await queue.get()
|
|
178
|
+
finally:
|
|
179
|
+
self._status_queues.discard(queue)
|
|
180
|
+
|
|
181
|
+
async def snapshot(self, *, timeout: float = camera_module.DEFAULT_TIMEOUT) -> bytes:
|
|
182
|
+
"""Return a single JPEG frame from the built-in webcam."""
|
|
183
|
+
return await camera_module.snapshot(self.host, timeout=timeout)
|
|
184
|
+
|
|
185
|
+
# --- control actions (gated) ----------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def start_print(
|
|
188
|
+
self,
|
|
189
|
+
filename: str,
|
|
190
|
+
*,
|
|
191
|
+
storage: str = "local",
|
|
192
|
+
auto_leveling: bool = True,
|
|
193
|
+
timelapse: bool = False,
|
|
194
|
+
) -> sdcp.ParsedMessage:
|
|
195
|
+
"""Start a print of an existing file on the printer.
|
|
196
|
+
|
|
197
|
+
``storage`` is either ``"local"`` (internal storage) or ``"udisk"``
|
|
198
|
+
(USB). The filename is the name used by the printer, not a local path.
|
|
199
|
+
"""
|
|
200
|
+
self._require_control("start_print")
|
|
201
|
+
path_prefix = "/usb" if storage == "udisk" else "/local"
|
|
202
|
+
data: dict[str, Any] = {
|
|
203
|
+
"Filename": filename,
|
|
204
|
+
"StartLayer": 0,
|
|
205
|
+
"Calibration_switch": 1 if auto_leveling else 0,
|
|
206
|
+
"PrintPlatformType": 0,
|
|
207
|
+
"Tlp_Switch": 1 if timelapse else 0,
|
|
208
|
+
"slot_map": [],
|
|
209
|
+
"path_prefix": path_prefix,
|
|
210
|
+
}
|
|
211
|
+
mid = await self.wait_for_mainboard()
|
|
212
|
+
return await self._request(sdcp.Cmd.START_PRINT, data, mid)
|
|
213
|
+
|
|
214
|
+
async def pause(self) -> sdcp.ParsedMessage:
|
|
215
|
+
self._require_control("pause")
|
|
216
|
+
mid = await self.wait_for_mainboard()
|
|
217
|
+
return await self._request(sdcp.Cmd.PAUSE_PRINT, None, mid)
|
|
218
|
+
|
|
219
|
+
async def resume(self) -> sdcp.ParsedMessage:
|
|
220
|
+
self._require_control("resume")
|
|
221
|
+
mid = await self.wait_for_mainboard()
|
|
222
|
+
return await self._request(sdcp.Cmd.RESUME_PRINT, None, mid)
|
|
223
|
+
|
|
224
|
+
async def stop(self) -> sdcp.ParsedMessage:
|
|
225
|
+
self._require_control("stop")
|
|
226
|
+
mid = await self.wait_for_mainboard()
|
|
227
|
+
return await self._request(sdcp.Cmd.STOP_PRINT, None, mid)
|
|
228
|
+
|
|
229
|
+
# --- lifecycle -------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
async def close(self) -> None:
|
|
232
|
+
"""Close the WebSocket and stop the reader."""
|
|
233
|
+
if self._closed:
|
|
234
|
+
return
|
|
235
|
+
self._closed = True
|
|
236
|
+
if self._reader is not None and not self._reader.done():
|
|
237
|
+
self._reader.cancel()
|
|
238
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
239
|
+
await self._reader
|
|
240
|
+
if self._ws is not None:
|
|
241
|
+
with contextlib.suppress(Exception):
|
|
242
|
+
await self._ws.close()
|
|
243
|
+
for fut in self._pending.values():
|
|
244
|
+
if not fut.done():
|
|
245
|
+
fut.set_exception(PrinterError("connection closed"))
|
|
246
|
+
|
|
247
|
+
# --- internals -------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _require_control(self, action: str) -> None:
|
|
250
|
+
if not self.enable_control:
|
|
251
|
+
raise ControlDisabledError(
|
|
252
|
+
f"{action!r} requires enable_control=True; "
|
|
253
|
+
"Printer.connect(..., enable_control=True) to allow write actions"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def _send_raw(self, text: str) -> None:
|
|
257
|
+
if self._ws is None:
|
|
258
|
+
raise PrinterError("not connected")
|
|
259
|
+
await self._ws.send(text)
|
|
260
|
+
|
|
261
|
+
async def _ensure_subscribed(self) -> None:
|
|
262
|
+
mid = await self.wait_for_mainboard()
|
|
263
|
+
pkt = sdcp.build_subscribe(mid, period_ms=self.push_period_ms)
|
|
264
|
+
await self._send_raw(sdcp.encode(pkt))
|
|
265
|
+
|
|
266
|
+
async def _request(
|
|
267
|
+
self,
|
|
268
|
+
cmd: int,
|
|
269
|
+
data: dict[str, Any] | None,
|
|
270
|
+
mainboard_id: str,
|
|
271
|
+
*,
|
|
272
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
273
|
+
) -> sdcp.ParsedMessage:
|
|
274
|
+
pkt = sdcp.build_request(cmd, data, mainboard_id)
|
|
275
|
+
request_id = pkt["Data"]["RequestID"]
|
|
276
|
+
loop = asyncio.get_running_loop()
|
|
277
|
+
fut: asyncio.Future[sdcp.ParsedMessage] = loop.create_future()
|
|
278
|
+
self._pending[request_id] = fut
|
|
279
|
+
try:
|
|
280
|
+
await self._send_raw(sdcp.encode(pkt))
|
|
281
|
+
return await asyncio.wait_for(fut, timeout=timeout)
|
|
282
|
+
except asyncio.TimeoutError as err:
|
|
283
|
+
raise RequestTimeoutError(f"cmd {cmd} timed out after {timeout}s") from err
|
|
284
|
+
finally:
|
|
285
|
+
self._pending.pop(request_id, None)
|
|
286
|
+
|
|
287
|
+
async def _read_loop(self) -> None:
|
|
288
|
+
assert self._ws is not None
|
|
289
|
+
try:
|
|
290
|
+
async for raw in self._ws:
|
|
291
|
+
try:
|
|
292
|
+
self._handle_frame(raw)
|
|
293
|
+
except Exception:
|
|
294
|
+
log.exception("failed to handle frame: %r", raw[:200] if raw else raw)
|
|
295
|
+
except websockets.ConnectionClosed:
|
|
296
|
+
log.debug("ws closed by peer")
|
|
297
|
+
except Exception:
|
|
298
|
+
log.exception("ws reader crashed")
|
|
299
|
+
finally:
|
|
300
|
+
self._closed = True
|
|
301
|
+
for fut in self._pending.values():
|
|
302
|
+
if not fut.done():
|
|
303
|
+
fut.set_exception(PrinterError("connection closed"))
|
|
304
|
+
|
|
305
|
+
def _handle_frame(self, raw: str | bytes) -> None:
|
|
306
|
+
msg = sdcp.parse_message(raw)
|
|
307
|
+
|
|
308
|
+
if msg.mainboard_id and self._mainboard_id is None:
|
|
309
|
+
self._mainboard_id = msg.mainboard_id
|
|
310
|
+
self._mainboard_event.set()
|
|
311
|
+
log.debug("learned mainboard id: %s", msg.mainboard_id)
|
|
312
|
+
|
|
313
|
+
if msg.type == sdcp.MessageType.STATUS and msg.status is not None:
|
|
314
|
+
status = Status.from_payload(msg.status)
|
|
315
|
+
self._latest_status = status
|
|
316
|
+
self._latest_status_event.set()
|
|
317
|
+
for q in list(self._status_queues):
|
|
318
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
319
|
+
q.put_nowait(status)
|
|
320
|
+
|
|
321
|
+
elif msg.type == sdcp.MessageType.ATTRIBUTES and msg.attributes is not None:
|
|
322
|
+
self._latest_attributes = Attributes.from_payload(msg.attributes)
|
|
323
|
+
self._latest_attributes_event.set()
|
|
324
|
+
|
|
325
|
+
if msg.request_id and msg.request_id in self._pending:
|
|
326
|
+
fut = self._pending[msg.request_id]
|
|
327
|
+
if not fut.done():
|
|
328
|
+
fut.set_result(msg)
|
pycentauri/discovery.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""LAN discovery for Elegoo Centauri Carbon printers.
|
|
2
|
+
|
|
3
|
+
The original Centauri Carbon listens on UDP port 3000 and responds to the
|
|
4
|
+
magic probe string ``M99999`` with a JSON payload describing itself. The
|
|
5
|
+
newer Centauri Carbon 2 uses a different JSON-RPC probe and is not supported
|
|
6
|
+
here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DISCOVERY_PORT = 3000
|
|
18
|
+
DISCOVERY_PROBE = b"M99999"
|
|
19
|
+
DEFAULT_TIMEOUT = 3.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class DiscoveredPrinter:
|
|
24
|
+
"""A printer that answered a discovery broadcast."""
|
|
25
|
+
|
|
26
|
+
host: str
|
|
27
|
+
mainboard_id: str | None
|
|
28
|
+
name: str | None
|
|
29
|
+
machine_name: str | None
|
|
30
|
+
firmware_version: str | None
|
|
31
|
+
raw: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_response(data: bytes, host: str) -> DiscoveredPrinter | None:
|
|
35
|
+
try:
|
|
36
|
+
obj = json.loads(data.decode("utf-8", "replace"))
|
|
37
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
38
|
+
return None
|
|
39
|
+
if not isinstance(obj, dict):
|
|
40
|
+
return None
|
|
41
|
+
inner_raw = obj.get("Data")
|
|
42
|
+
inner: dict[str, Any] = inner_raw if isinstance(inner_raw, dict) else {}
|
|
43
|
+
return DiscoveredPrinter(
|
|
44
|
+
host=host,
|
|
45
|
+
mainboard_id=inner.get("MainboardID") or obj.get("MainboardID"),
|
|
46
|
+
name=inner.get("Name"),
|
|
47
|
+
machine_name=inner.get("MachineName"),
|
|
48
|
+
firmware_version=inner.get("FirmwareVersion"),
|
|
49
|
+
raw=obj,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.results: dict[str, DiscoveredPrinter] = {}
|
|
56
|
+
|
|
57
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
58
|
+
host = addr[0]
|
|
59
|
+
if host in self.results:
|
|
60
|
+
return
|
|
61
|
+
parsed = _parse_response(data, host)
|
|
62
|
+
if parsed is not None:
|
|
63
|
+
self.results[host] = parsed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def discover(
|
|
67
|
+
*,
|
|
68
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
69
|
+
broadcast_address: str = "255.255.255.255",
|
|
70
|
+
port: int = DISCOVERY_PORT,
|
|
71
|
+
) -> list[DiscoveredPrinter]:
|
|
72
|
+
"""Broadcast the SDCP discovery probe and collect responders.
|
|
73
|
+
|
|
74
|
+
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.
|
|
77
|
+
"""
|
|
78
|
+
loop = asyncio.get_running_loop()
|
|
79
|
+
|
|
80
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
81
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
82
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
83
|
+
sock.bind(("", 0))
|
|
84
|
+
sock.setblocking(False)
|
|
85
|
+
|
|
86
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
87
|
+
_DiscoveryProtocol,
|
|
88
|
+
sock=sock,
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
|
|
92
|
+
await asyncio.sleep(timeout)
|
|
93
|
+
finally:
|
|
94
|
+
transport.close()
|
|
95
|
+
|
|
96
|
+
return list(protocol.results.values())
|
|
File without changes
|
pycentauri/mcp/server.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""FastMCP server exposing the printer to MCP-speaking agents.
|
|
2
|
+
|
|
3
|
+
Register with an agent as a stdio server. Examples:
|
|
4
|
+
|
|
5
|
+
.. code-block:: sh
|
|
6
|
+
|
|
7
|
+
# Read-only tools only:
|
|
8
|
+
claude mcp add pycentauri -- python -m pycentauri.mcp
|
|
9
|
+
|
|
10
|
+
# With control actions (start/pause/resume/stop):
|
|
11
|
+
claude mcp add pycentauri -- python -m pycentauri.mcp --enable-control
|
|
12
|
+
|
|
13
|
+
The printer host is read from ``PYCENTAURI_HOST`` (preferred) or from a
|
|
14
|
+
``--host`` argument at launch time — it is **not** a per-tool parameter, so
|
|
15
|
+
an LLM cannot be tricked into targeting an arbitrary IP through prompt
|
|
16
|
+
injection.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import os
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
26
|
+
|
|
27
|
+
from pycentauri.client import Printer
|
|
28
|
+
from pycentauri.discovery import discover as _lan_discover
|
|
29
|
+
|
|
30
|
+
_HOST_ENV = "PYCENTAURI_HOST"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_host() -> str:
|
|
34
|
+
host = os.environ.get(_HOST_ENV)
|
|
35
|
+
if not host:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"{_HOST_ENV} is not set; launch the server with --host IP or "
|
|
38
|
+
"export PYCENTAURI_HOST first"
|
|
39
|
+
)
|
|
40
|
+
return host
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_server(*, enable_control: bool = False) -> FastMCP:
|
|
44
|
+
"""Construct the FastMCP server, registering tools per the control flag.
|
|
45
|
+
|
|
46
|
+
Control tools are not registered at all when ``enable_control=False`` —
|
|
47
|
+
they never appear in the tool list the LLM sees.
|
|
48
|
+
"""
|
|
49
|
+
mcp = FastMCP("pycentauri")
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
async def get_status() -> dict[str, Any]:
|
|
53
|
+
"""Return the current printer status.
|
|
54
|
+
|
|
55
|
+
Includes state code, job filename, progress %, layer, temperatures
|
|
56
|
+
(nozzle / bed / chamber), fan speeds, and the raw SDCP payload.
|
|
57
|
+
"""
|
|
58
|
+
host = _resolve_host()
|
|
59
|
+
async with await Printer.connect(host) as printer:
|
|
60
|
+
st = await printer.status()
|
|
61
|
+
return {
|
|
62
|
+
"host": host,
|
|
63
|
+
"state": st.state,
|
|
64
|
+
"print_status": st.print_status,
|
|
65
|
+
"progress": st.progress,
|
|
66
|
+
"filename": st.filename,
|
|
67
|
+
"layer": {
|
|
68
|
+
"current": st.print_info.current_layer if st.print_info else None,
|
|
69
|
+
"total": st.print_info.total_layer if st.print_info else None,
|
|
70
|
+
},
|
|
71
|
+
"temperatures": {
|
|
72
|
+
"nozzle": {"actual": st.temp_nozzle, "target": st.temp_nozzle_target},
|
|
73
|
+
"bed": {"actual": st.temp_bed, "target": st.temp_bed_target},
|
|
74
|
+
"chamber": {"actual": st.temp_chamber, "target": st.temp_chamber_target},
|
|
75
|
+
},
|
|
76
|
+
"position": st.coord,
|
|
77
|
+
"z_offset": st.z_offset,
|
|
78
|
+
"fans": st.fan_speed,
|
|
79
|
+
"raw": st.raw,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
async def get_attributes() -> dict[str, Any]:
|
|
84
|
+
"""Return printer attributes: model, firmware, mainboard ID, capabilities."""
|
|
85
|
+
host = _resolve_host()
|
|
86
|
+
async with await Printer.connect(host) as printer:
|
|
87
|
+
attrs = await printer.attributes()
|
|
88
|
+
return {
|
|
89
|
+
"host": host,
|
|
90
|
+
"mainboard_id": attrs.mainboard_id,
|
|
91
|
+
"name": attrs.name,
|
|
92
|
+
"machine_name": attrs.machine_name,
|
|
93
|
+
"firmware_version": attrs.firmware_version,
|
|
94
|
+
"capabilities": attrs.capabilities,
|
|
95
|
+
"raw": attrs.raw,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
async def get_snapshot() -> Image:
|
|
100
|
+
"""Return a JPEG snapshot of the built-in webcam.
|
|
101
|
+
|
|
102
|
+
The image is returned inline so the agent can see what the printer
|
|
103
|
+
is currently doing (e.g. to spot layer shifts or spaghetti).
|
|
104
|
+
"""
|
|
105
|
+
host = _resolve_host()
|
|
106
|
+
async with await Printer.connect(host) as printer:
|
|
107
|
+
jpeg = await printer.snapshot()
|
|
108
|
+
return Image(data=jpeg, format="jpeg")
|
|
109
|
+
|
|
110
|
+
@mcp.tool()
|
|
111
|
+
async def discover_printers() -> list[dict[str, Any]]:
|
|
112
|
+
"""Broadcast the SDCP discovery probe and return responding printers.
|
|
113
|
+
|
|
114
|
+
Useful to verify the configured host matches what's actually on the
|
|
115
|
+
LAN, or to find a newly-added printer's IP.
|
|
116
|
+
"""
|
|
117
|
+
found = await _lan_discover(timeout=2.5)
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
"host": p.host,
|
|
121
|
+
"mainboard_id": p.mainboard_id,
|
|
122
|
+
"name": p.name,
|
|
123
|
+
"machine_name": p.machine_name,
|
|
124
|
+
"firmware_version": p.firmware_version,
|
|
125
|
+
}
|
|
126
|
+
for p in found
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
if not enable_control:
|
|
130
|
+
return mcp
|
|
131
|
+
|
|
132
|
+
# --- Control tools (registered only when explicitly enabled) --------------
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
async def start_print(
|
|
136
|
+
filename: str,
|
|
137
|
+
storage: str = "local",
|
|
138
|
+
auto_leveling: bool = True,
|
|
139
|
+
timelapse: bool = False,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""DESTRUCTIVE. Start a print of ``filename`` (already on the printer).
|
|
142
|
+
|
|
143
|
+
Requires a file that has been uploaded to the printer. ``storage`` is
|
|
144
|
+
``"local"`` (default) or ``"udisk"``. Ask the user for confirmation
|
|
145
|
+
before invoking — running a print unattended is the user's risk.
|
|
146
|
+
"""
|
|
147
|
+
host = _resolve_host()
|
|
148
|
+
async with await Printer.connect(host, enable_control=True) as printer:
|
|
149
|
+
result = await printer.start_print(
|
|
150
|
+
filename,
|
|
151
|
+
storage=storage,
|
|
152
|
+
auto_leveling=auto_leveling,
|
|
153
|
+
timelapse=timelapse,
|
|
154
|
+
)
|
|
155
|
+
return {"ok": True, "response": result.inner}
|
|
156
|
+
|
|
157
|
+
@mcp.tool()
|
|
158
|
+
async def pause_print() -> dict[str, Any]:
|
|
159
|
+
"""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:
|
|
162
|
+
result = await printer.pause()
|
|
163
|
+
return {"ok": True, "response": result.inner}
|
|
164
|
+
|
|
165
|
+
@mcp.tool()
|
|
166
|
+
async def resume_print() -> dict[str, Any]:
|
|
167
|
+
"""Resume a paused print."""
|
|
168
|
+
host = _resolve_host()
|
|
169
|
+
async with await Printer.connect(host, enable_control=True) as printer:
|
|
170
|
+
result = await printer.resume()
|
|
171
|
+
return {"ok": True, "response": result.inner}
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
async def stop_print() -> dict[str, Any]:
|
|
175
|
+
"""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:
|
|
178
|
+
result = await printer.stop()
|
|
179
|
+
return {"ok": True, "response": result.inner}
|
|
180
|
+
|
|
181
|
+
return mcp
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run_stdio(*, enable_control: bool = False) -> None:
|
|
185
|
+
"""Run the MCP server over stdio until the transport closes."""
|
|
186
|
+
mcp = build_server(enable_control=enable_control)
|
|
187
|
+
mcp.run()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cli() -> None:
|
|
191
|
+
parser = argparse.ArgumentParser(
|
|
192
|
+
prog="python -m pycentauri.mcp",
|
|
193
|
+
description="MCP server for pycentauri (stdio transport)",
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--enable-control",
|
|
197
|
+
action="store_true",
|
|
198
|
+
help="Register destructive tools (start/pause/resume/stop).",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--host",
|
|
202
|
+
help="Printer host/IP. Overrides $PYCENTAURI_HOST for this process.",
|
|
203
|
+
)
|
|
204
|
+
args = parser.parse_args()
|
|
205
|
+
if args.host:
|
|
206
|
+
os.environ[_HOST_ENV] = args.host
|
|
207
|
+
run_stdio(enable_control=args.enable_control)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
_cli()
|