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/models.py ADDED
@@ -0,0 +1,229 @@
1
+ """Typed views over SDCP status and attribute payloads.
2
+
3
+ The printer emits JSON with ``PascalCase`` keys and temperatures as
4
+ ``[target, actual]`` pairs. These models present a Python-friendly facade
5
+ while keeping the raw dict around (``.raw``) for forward-compatibility when
6
+ the firmware adds fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class PrintInfo(BaseModel):
17
+ """Inner ``PrintInfo`` block describing the current print job.
18
+
19
+ The printer mixes int and float for tick/time fields across firmware
20
+ revisions, so the numeric fields here accept both.
21
+ """
22
+
23
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
24
+
25
+ status: int | None = Field(default=None, alias="Status")
26
+ filename: str | None = Field(default=None, alias="Filename")
27
+ current_layer: int | None = Field(default=None, alias="CurrentLayer")
28
+ total_layer: int | None = Field(default=None, alias="TotalLayer")
29
+ current_ticks: float | None = Field(default=None, alias="CurrentTicks")
30
+ total_ticks: float | None = Field(default=None, alias="TotalTicks")
31
+ progress: int | None = Field(default=None, alias="Progress")
32
+ err_num: int | None = Field(default=None, alias="ErrNum")
33
+ print_speed: int | None = Field(default=None, alias="PrintSpeedPct")
34
+ task_id: str | None = Field(default=None, alias="TaskId")
35
+
36
+
37
+ def _extract_temp(
38
+ payload: dict[str, Any], actual_key: str, target_key: str
39
+ ) -> tuple[float | None, float | None]:
40
+ """Read a temperature field.
41
+
42
+ Two wire formats exist in the wild:
43
+
44
+ * Current Centauri Carbon firmware (V1.1.x): ``TempOfNozzle`` is a scalar
45
+ and ``TempTargetNozzle`` is a separate scalar.
46
+ * Older / CentauriLink-documented firmware: ``TempOfNozzle`` is a
47
+ ``[target, actual]`` pair and no ``TempTarget*`` is sent.
48
+ """
49
+ raw_actual = payload.get(actual_key)
50
+ raw_target = payload.get(target_key)
51
+ actual: float | None = None
52
+ target: float | None = None
53
+ if isinstance(raw_actual, (int, float)):
54
+ actual = float(raw_actual)
55
+ elif isinstance(raw_actual, (list, tuple)) and len(raw_actual) >= 2:
56
+ with _suppress_convert():
57
+ target = float(raw_actual[0])
58
+ with _suppress_convert():
59
+ actual = float(raw_actual[1])
60
+ if target is None and isinstance(raw_target, (int, float)):
61
+ target = float(raw_target)
62
+ return actual, target
63
+
64
+
65
+ class _suppress_convert:
66
+ def __enter__(self) -> None:
67
+ return None
68
+
69
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
70
+ return isinstance(exc, (TypeError, ValueError))
71
+
72
+
73
+ def _parse_coord(raw: Any) -> tuple[float, float, float] | None:
74
+ """Parse a ``"x,y,z"`` coord string into a tuple of floats."""
75
+ if not isinstance(raw, str):
76
+ return None
77
+ parts = raw.split(",")
78
+ if len(parts) != 3:
79
+ return None
80
+ try:
81
+ return (float(parts[0]), float(parts[1]), float(parts[2]))
82
+ except (TypeError, ValueError):
83
+ return None
84
+
85
+
86
+ class Status(BaseModel):
87
+ """Normalised view of the ``Status`` payload from an SDCP push.
88
+
89
+ The raw payload is always kept on ``.raw`` — new firmware fields show up
90
+ there automatically even if we haven't added a typed accessor yet.
91
+ """
92
+
93
+ model_config = ConfigDict(extra="allow")
94
+
95
+ raw: dict[str, Any]
96
+ current_status: list[int] = Field(default_factory=list)
97
+ print_info: PrintInfo | None = None
98
+
99
+ temp_nozzle: float | None = None
100
+ temp_nozzle_target: float | None = None
101
+ temp_bed: float | None = None
102
+ temp_bed_target: float | None = None
103
+ temp_chamber: float | None = None
104
+ temp_chamber_target: float | None = None
105
+
106
+ coord: tuple[float, float, float] | None = None
107
+ z_offset: float | None = None
108
+ fan_speed: dict[str, int] = Field(default_factory=dict)
109
+ light: dict[str, Any] = Field(default_factory=dict)
110
+ time_lapse: int | None = None
111
+ platform_type: int | None = None
112
+
113
+ @classmethod
114
+ def from_payload(cls, payload: dict[str, Any]) -> Status:
115
+ pi_raw = payload.get("PrintInfo")
116
+ print_info = PrintInfo.model_validate(pi_raw) if isinstance(pi_raw, dict) else None
117
+
118
+ current_status_raw = payload.get("CurrentStatus") or payload.get("CurrenStatus") or []
119
+ current_status = (
120
+ [int(x) for x in current_status_raw if isinstance(x, (int, float))]
121
+ if isinstance(current_status_raw, list)
122
+ else []
123
+ )
124
+
125
+ noz, noz_t = _extract_temp(payload, "TempOfNozzle", "TempTargetNozzle")
126
+ bed, bed_t = _extract_temp(payload, "TempOfHotbed", "TempTargetHotbed")
127
+ box, box_t = _extract_temp(payload, "TempOfBox", "TempTargetBox")
128
+
129
+ fans_raw = payload.get("CurrentFanSpeed") or {}
130
+ fans = {
131
+ str(k): int(v)
132
+ for k, v in (fans_raw.items() if isinstance(fans_raw, dict) else [])
133
+ if isinstance(v, (int, float))
134
+ }
135
+
136
+ light_raw = payload.get("LightStatus") or {}
137
+ light = light_raw if isinstance(light_raw, dict) else {}
138
+
139
+ z_off = payload.get("ZOffset")
140
+ z_off_f = float(z_off) if isinstance(z_off, (int, float)) else None
141
+
142
+ return cls(
143
+ raw=payload,
144
+ current_status=current_status,
145
+ print_info=print_info,
146
+ temp_nozzle=noz,
147
+ temp_nozzle_target=noz_t,
148
+ temp_bed=bed,
149
+ temp_bed_target=bed_t,
150
+ temp_chamber=box,
151
+ temp_chamber_target=box_t,
152
+ coord=_parse_coord(payload.get("CurrenCoord") or payload.get("CurrentCoord")),
153
+ z_offset=z_off_f,
154
+ fan_speed=fans,
155
+ light=light,
156
+ time_lapse=payload.get("TimeLapseStatus"),
157
+ platform_type=payload.get("PlatFormType") or payload.get("PlatformType"),
158
+ )
159
+
160
+ @property
161
+ def state(self) -> int | None:
162
+ """Primary printer state code (first entry in ``CurrentStatus``)."""
163
+ return self.current_status[0] if self.current_status else None
164
+
165
+ @property
166
+ def progress(self) -> int | None:
167
+ """Convenience accessor for the current job's progress (%)."""
168
+ return self.print_info.progress if self.print_info else None
169
+
170
+ @property
171
+ def filename(self) -> str | None:
172
+ return self.print_info.filename if self.print_info else None
173
+
174
+ @property
175
+ def print_status(self) -> int | None:
176
+ """The ``PrintInfo.Status`` code (e.g. 13 = printing). See :class:`PrintStatus`."""
177
+ return self.print_info.status if self.print_info else None
178
+
179
+
180
+ class PrintStatus:
181
+ """Known ``PrintInfo.Status`` codes.
182
+
183
+ Decoded from CentauriLink and the official SDK; not all codes are observed
184
+ on every firmware. Treat unknown codes as opaque.
185
+ """
186
+
187
+ IDLE = 0
188
+ HOMING = 1
189
+ DROPPING = 2
190
+ EXPOSING = 3
191
+ LIFTING = 4
192
+ PAUSING = 5
193
+ PAUSED = 6
194
+ STOPPING = 7
195
+ STOPPED = 8
196
+ COMPLETED = 9
197
+ FILE_CHECKING = 10
198
+ PREPARING = 12
199
+ PRINTING = 13
200
+
201
+
202
+ class Attributes(BaseModel):
203
+ """Normalised view of the ``Attributes`` payload."""
204
+
205
+ model_config = ConfigDict(extra="allow")
206
+
207
+ raw: dict[str, Any]
208
+ mainboard_id: str | None = None
209
+ name: str | None = None
210
+ machine_name: str | None = None
211
+ brand_name: str | None = None
212
+ firmware_version: str | None = None
213
+ protocol_version: str | None = None
214
+ capabilities: list[str] = Field(default_factory=list)
215
+
216
+ @classmethod
217
+ def from_payload(cls, payload: dict[str, Any]) -> Attributes:
218
+ caps_raw = payload.get("Capabilities") or payload.get("SupportedFeatures") or []
219
+ caps = [str(c) for c in caps_raw] if isinstance(caps_raw, list) else []
220
+ return cls(
221
+ raw=payload,
222
+ mainboard_id=payload.get("MainboardID"),
223
+ name=payload.get("Name"),
224
+ machine_name=payload.get("MachineName"),
225
+ brand_name=payload.get("BrandName"),
226
+ firmware_version=payload.get("FirmwareVersion"),
227
+ protocol_version=payload.get("ProtocolVersion"),
228
+ capabilities=caps,
229
+ )
pycentauri/py.typed ADDED
File without changes
pycentauri/sdcp.py ADDED
@@ -0,0 +1,238 @@
1
+ """SDCP v3 wire protocol for the Elegoo Centauri Carbon.
2
+
3
+ The Centauri Carbon exposes a WebSocket at ``ws://<host>:3030/websocket`` that
4
+ speaks Elegoo's Smart Device Control Protocol (SDCP). Messages are JSON; the
5
+ envelope wraps a command and routing topic.
6
+
7
+ Reference implementations consulted:
8
+
9
+ * ``ELEGOO-3D/elegoo-link`` — official C++ SDK, ``elegoo_fdm_cc_message_adapter.cpp``
10
+ * ``CentauriLink/Centauri-Link`` — community Python/Kivy client, ``main.py``
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import secrets
17
+ import time
18
+ import uuid
19
+ from enum import IntEnum
20
+ from typing import Any
21
+
22
+
23
+ class Cmd(IntEnum):
24
+ """SDCP command codes for the original Centauri Carbon.
25
+
26
+ Codes confirmed against the official elegoo-link C++ SDK's
27
+ ``COMMAND_MAPPING_TABLE`` in ``elegoo_fdm_cc_message_adapter.cpp``. Cmd 512
28
+ is documented by CentauriLink and OctoEverywhere as the status-push
29
+ subscribe command.
30
+ """
31
+
32
+ GET_PRINTER_STATUS = 0
33
+ GET_PRINTER_ATTRIBUTES = 1
34
+ START_PRINT = 128
35
+ PAUSE_PRINT = 129
36
+ STOP_PRINT = 130
37
+ RESUME_PRINT = 131
38
+ GET_CANVAS_STATUS = 324
39
+ SUBSCRIBE = 512
40
+
41
+
42
+ DEFAULT_PUSH_PERIOD_MS = 5000
43
+
44
+
45
+ class MessageType(IntEnum):
46
+ """Parsed message categories, based on the ``Topic`` field."""
47
+
48
+ UNKNOWN = 0
49
+ RESPONSE = 1
50
+ STATUS = 2
51
+ ATTRIBUTES = 3
52
+ NOTICE = 4
53
+
54
+
55
+ def _now_ms() -> int:
56
+ return int(time.time() * 1000)
57
+
58
+
59
+ def _new_request_id() -> str:
60
+ return secrets.token_hex(8)
61
+
62
+
63
+ def _new_envelope_id() -> str:
64
+ return uuid.uuid4().hex
65
+
66
+
67
+ def build_request(
68
+ cmd: int,
69
+ data: dict[str, Any] | None,
70
+ mainboard_id: str,
71
+ *,
72
+ request_id: str | None = None,
73
+ envelope_id: str | None = None,
74
+ ) -> dict[str, Any]:
75
+ """Build a fully-formed SDCP request packet.
76
+
77
+ The packet structure mirrors what ``ElegooFdmCCMessageAdapter`` emits:
78
+
79
+ .. code-block:: json
80
+
81
+ {
82
+ "Id": "<mainboard id or uuid>",
83
+ "Data": {
84
+ "Cmd": 0,
85
+ "Data": {},
86
+ "RequestID": "<hex>",
87
+ "MainboardID": "<serial>",
88
+ "TimeStamp": 1687069655000,
89
+ "From": 1
90
+ },
91
+ "Topic": "sdcp/request/<mainboard id>"
92
+ }
93
+
94
+ ``MainboardID`` is mandatory for every outbound command; obtain it from a
95
+ discovery response or from the first ``Attributes`` push the printer sends
96
+ after the WebSocket connects.
97
+ """
98
+ if not mainboard_id:
99
+ raise ValueError("mainboard_id is required for SDCP commands")
100
+ return {
101
+ "Id": envelope_id or mainboard_id,
102
+ "Data": {
103
+ "Cmd": int(cmd),
104
+ "Data": data or {},
105
+ "RequestID": request_id or _new_request_id(),
106
+ "MainboardID": mainboard_id,
107
+ "TimeStamp": _now_ms(),
108
+ "From": 1,
109
+ },
110
+ "Topic": f"sdcp/request/{mainboard_id}",
111
+ }
112
+
113
+
114
+ def build_subscribe(mainboard_id: str, period_ms: int = DEFAULT_PUSH_PERIOD_MS) -> dict[str, Any]:
115
+ """Cmd 512 — request status pushes every ``period_ms`` milliseconds."""
116
+ return build_request(Cmd.SUBSCRIBE, {"TimePeriod": int(period_ms)}, mainboard_id)
117
+
118
+
119
+ def encode(packet: dict[str, Any]) -> str:
120
+ """Serialize an envelope to JSON for transmission over the WebSocket."""
121
+ return json.dumps(packet, separators=(",", ":"))
122
+
123
+
124
+ def _classify(topic: str, payload: dict[str, Any]) -> MessageType:
125
+ if "sdcp/status" in topic or "Status" in payload:
126
+ return MessageType.STATUS
127
+ if "sdcp/attributes" in topic or "Attributes" in payload:
128
+ return MessageType.ATTRIBUTES
129
+ if "sdcp/response" in topic:
130
+ return MessageType.RESPONSE
131
+ if "sdcp/notice" in topic:
132
+ return MessageType.NOTICE
133
+ return MessageType.UNKNOWN
134
+
135
+
136
+ class ParsedMessage:
137
+ """Result of :func:`parse_message`, flattening the nested envelope.
138
+
139
+ ``raw`` is the top-level dict; ``inner`` is ``raw["Data"]`` when present
140
+ (command responses put the useful payload there). ``status`` and
141
+ ``attributes`` are non-None for the matching topics. ``request_id`` links
142
+ the message back to the request that triggered it.
143
+ """
144
+
145
+ __slots__ = ("attributes", "inner", "mainboard_id", "raw", "request_id", "status", "type")
146
+
147
+ def __init__(
148
+ self,
149
+ *,
150
+ type: MessageType,
151
+ raw: dict[str, Any],
152
+ inner: dict[str, Any] | None = None,
153
+ status: dict[str, Any] | None = None,
154
+ attributes: dict[str, Any] | None = None,
155
+ request_id: str | None = None,
156
+ mainboard_id: str | None = None,
157
+ ) -> None:
158
+ self.type = type
159
+ self.raw = raw
160
+ self.inner = inner
161
+ self.status = status
162
+ self.attributes = attributes
163
+ self.request_id = request_id
164
+ self.mainboard_id = mainboard_id
165
+
166
+ def __repr__(self) -> str:
167
+ return (
168
+ f"ParsedMessage(type={self.type.name}, request_id={self.request_id!r}, "
169
+ f"mainboard_id={self.mainboard_id!r})"
170
+ )
171
+
172
+
173
+ def parse_message(raw: str | bytes | dict[str, Any]) -> ParsedMessage:
174
+ """Parse an incoming SDCP message from the printer.
175
+
176
+ Accepts either a raw text/bytes frame or an already-parsed dict.
177
+ Unrecognised or malformed payloads come back as
178
+ ``ParsedMessage(type=UNKNOWN)`` with ``raw`` populated so callers can log.
179
+ """
180
+ if isinstance(raw, dict):
181
+ obj = raw
182
+ else:
183
+ text = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else raw
184
+ # Some SDCP variants prefix messages with a decimal length.
185
+ text = text.lstrip()
186
+ if text and text[0].isdigit():
187
+ first_brace = text.find("{")
188
+ if first_brace > 0:
189
+ text = text[first_brace:]
190
+ try:
191
+ obj = json.loads(text)
192
+ except (json.JSONDecodeError, TypeError):
193
+ return ParsedMessage(type=MessageType.UNKNOWN, raw={"_raw": raw})
194
+ if not isinstance(obj, dict):
195
+ return ParsedMessage(type=MessageType.UNKNOWN, raw={"_raw": obj})
196
+
197
+ topic = obj.get("Topic") or ""
198
+ msg_type = _classify(topic if isinstance(topic, str) else "", obj)
199
+
200
+ data = obj.get("Data") if isinstance(obj.get("Data"), dict) else None
201
+ mainboard_id = obj.get("MainboardID")
202
+ if mainboard_id is None and data is not None:
203
+ mainboard_id = data.get("MainboardID")
204
+ request_id = None
205
+ if data is not None:
206
+ request_id = data.get("RequestID")
207
+
208
+ status_payload: dict[str, Any] | None = None
209
+ attributes_payload: dict[str, Any] | None = None
210
+
211
+ if msg_type == MessageType.STATUS:
212
+ if isinstance(obj.get("Status"), dict):
213
+ status_payload = obj["Status"]
214
+ elif data is not None and isinstance(data.get("Status"), dict):
215
+ status_payload = data["Status"]
216
+ elif data is not None:
217
+ status_payload = {k: v for k, v in data.items() if k != "MainboardID"}
218
+
219
+ if msg_type == MessageType.ATTRIBUTES:
220
+ if isinstance(obj.get("Attributes"), dict):
221
+ attributes_payload = obj["Attributes"]
222
+ elif data is not None and isinstance(data.get("Attributes"), dict):
223
+ attributes_payload = data["Attributes"]
224
+ elif data is not None:
225
+ attributes_payload = {k: v for k, v in data.items() if k != "MainboardID"}
226
+ # Attributes carries MainboardID too.
227
+ if mainboard_id is None and attributes_payload is not None:
228
+ mainboard_id = attributes_payload.get("MainboardID")
229
+
230
+ return ParsedMessage(
231
+ type=msg_type,
232
+ raw=obj,
233
+ inner=data,
234
+ status=status_payload,
235
+ attributes=attributes_payload,
236
+ request_id=str(request_id) if request_id is not None else None,
237
+ mainboard_id=str(mainboard_id) if mainboard_id is not None else None,
238
+ )
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycentauri
3
+ Version: 0.1.0
4
+ Summary: Python client and MCP server for Elegoo Centauri Carbon 3D printers
5
+ Project-URL: Homepage, https://github.com/bjan/pycentauri
6
+ Project-URL: Repository, https://github.com/bjan/pycentauri
7
+ Project-URL: Issues, https://github.com/bjan/pycentauri/issues
8
+ Project-URL: Changelog, https://github.com/bjan/pycentauri/blob/main/CHANGELOG.md
9
+ Author: Brandon
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: 3d-printing,centauri,centauri-carbon,elegoo,mcp,sdcp
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Printing
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: pydantic>=2.7
28
+ Requires-Dist: typer>=0.12
29
+ Requires-Dist: websockets>=12.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: mypy>=1.10; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: ruff>=0.5; extra == 'dev'
36
+ Provides-Extra: mcp
37
+ Requires-Dist: mcp>=1.2; extra == 'mcp'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # pycentauri
41
+
42
+ Python client, CLI, and MCP server for the [Elegoo Centauri
43
+ Carbon](https://www.elegoo.com/) 3D printer.
44
+
45
+ `pycentauri` speaks the printer's native SDCP v3 protocol over its local
46
+ WebSocket (port 3030) — no cloud account required. It exposes three surfaces:
47
+
48
+ 1. An **async Python library** for direct integration.
49
+ 2. A **`centauri` CLI** for quick status checks, snapshots, and control.
50
+ 3. An **MCP server** so AI agents (Claude Code, Claude Desktop, Cursor, any
51
+ MCP-compatible client) can monitor and drive the printer as a tool.
52
+
53
+ > **Status:** alpha. The protocol has been reverse-engineered from the official
54
+ > [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
55
+ > [`CentauriLink`](https://github.com/CentauriLink/Centauri-Link) project. It
56
+ > works against the original Centauri Carbon on current firmware. The newer
57
+ > Centauri Carbon 2 (which uses MQTT) is not supported.
58
+
59
+ ## Install
60
+
61
+ ```sh
62
+ pip install pycentauri # library + CLI
63
+ pip install "pycentauri[mcp]" # + MCP server dependencies
64
+ ```
65
+
66
+ ## Quick start — CLI
67
+
68
+ ```sh
69
+ # Find printers on your LAN
70
+ centauri discover
71
+
72
+ # One-shot status (pretty or JSON)
73
+ centauri status --host 192.168.1.209
74
+ centauri status --host 192.168.1.209 --json
75
+
76
+ # Stream live status updates
77
+ centauri watch --host 192.168.1.209
78
+
79
+ # Grab a webcam snapshot
80
+ centauri snapshot --host 192.168.1.209 shot.jpg
81
+
82
+ # Files on the printer
83
+ centauri files --host 192.168.1.209
84
+
85
+ # Control actions require --enable-control
86
+ centauri print start cube.gcode --host 192.168.1.209 --enable-control
87
+ centauri print pause --host 192.168.1.209 --enable-control
88
+ centauri print resume --host 192.168.1.209 --enable-control
89
+ centauri print stop --host 192.168.1.209 --enable-control
90
+ ```
91
+
92
+ The host can also come from `PYCENTAURI_HOST` or `~/.config/pycentauri/config.toml`.
93
+
94
+ ## Quick start — Python
95
+
96
+ ```python
97
+ import asyncio
98
+ from pycentauri import Printer
99
+
100
+ async def main():
101
+ async with await Printer.connect("192.168.1.209") as printer:
102
+ status = await printer.status()
103
+ print(status.state, status.progress, status.temp_nozzle)
104
+
105
+ jpeg = await printer.snapshot()
106
+ with open("shot.jpg", "wb") as f:
107
+ f.write(jpeg)
108
+
109
+ async for update in printer.watch():
110
+ print(update.state, update.progress)
111
+
112
+ asyncio.run(main())
113
+ ```
114
+
115
+ ## Quick start — MCP
116
+
117
+ Register the server with your agent. With Claude Code:
118
+
119
+ ```sh
120
+ claude mcp add pycentauri -- python -m pycentauri.mcp
121
+ # or, with control actions enabled (gives the agent start/pause/stop/upload):
122
+ claude mcp add pycentauri -- python -m pycentauri.mcp --enable-control
123
+ ```
124
+
125
+ Set `PYCENTAURI_HOST` in your MCP server env so the agent can't target an
126
+ arbitrary IP. The server exposes these tools:
127
+
128
+ | Tool | Always available | Description |
129
+ |---|---|---|
130
+ | `get_status` | yes | State, temperatures, progress, elapsed/remaining |
131
+ | `get_attributes` | yes | Model, firmware, mainboard ID |
132
+ | `list_files` | yes | Files stored on the printer |
133
+ | `get_snapshot` | yes | Webcam frame as MCP `Image` content |
134
+ | `discover_printers` | yes | LAN scan |
135
+ | `start_print` | only with `--enable-control` | Starts a print |
136
+ | `pause_print` | only with `--enable-control` | Pauses the current print |
137
+ | `resume_print` | only with `--enable-control` | Resumes a paused print |
138
+ | `stop_print` | only with `--enable-control` | Stops the current print |
139
+ | `upload_file` | only with `--enable-control` | Uploads a file to the printer |
140
+
141
+ ## Safety
142
+
143
+ Control actions are gated behind an explicit `enable_control=True` (library) or
144
+ `--enable-control` flag (CLI / MCP). Destructive MCP tools are not even
145
+ registered when the flag is off, so an LLM never sees them. Still: leaving a
146
+ printer running unattended with write-capable agents is your responsibility.
147
+
148
+ ## Credits & licensing
149
+
150
+ - Protocol reference: [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link)
151
+ (Apache-2.0) and [`CentauriLink`](https://github.com/CentauriLink/Centauri-Link).
152
+ - `pycentauri` is licensed under Apache-2.0. See [LICENSE](LICENSE).
153
+ - Not affiliated with or endorsed by Elegoo.
@@ -0,0 +1,16 @@
1
+ pycentauri/__init__.py,sha256=05FNMPVENQO5lkMRgcIjScodsZH-zQQUZdSYWWx6G5U,447
2
+ pycentauri/camera.py,sha256=i4Ej4GBhUM4PnY_VdOiUdQQPaXR-mupj7tneevc5aV4,2199
3
+ pycentauri/cli.py,sha256=6B6z7zDeMpuByVxx9r3qzZo1Y5zU57zyHn8jef54YdU,11704
4
+ pycentauri/client.py,sha256=Cc0AuckZxOZTbUjjV-YPdIKTYtPeudBDO6BBDZQ-JK8,12302
5
+ pycentauri/discovery.py,sha256=jjjvvj_sizvxmV5sw_-7uIxwsxIuUmyuSG8pq3sZkgI,2864
6
+ pycentauri/models.py,sha256=bYAXb9L_NvRgQNcpSQ1yZg4JPknGzx31DDXZl2KkRqk,8077
7
+ pycentauri/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pycentauri/sdcp.py,sha256=0nE93oZmImPGngFdPPjWseBc_72bUjwGSDUonELDGcw,7730
9
+ pycentauri/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pycentauri/mcp/__main__.py,sha256=4V93dBkS0D2Z9iIKxlWY_GpxMHHp3I6y2vqTZDaDxvg,78
11
+ pycentauri/mcp/server.py,sha256=gQ7ye7AEvOqVTJ1Z_GY75xIfnKlhMkvtRWarG3Wlogs,7233
12
+ pycentauri-0.1.0.dist-info/METADATA,sha256=jSA0wQcR8rUyv28z9zAYTrS5q3w8m8wfb0YyheSOvDQ,5693
13
+ pycentauri-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ pycentauri-0.1.0.dist-info/entry_points.txt,sha256=W5EcM3mxlAKgEBdDl7aBobMq_Wd7wJRb9b6ipgb5SJM,48
15
+ pycentauri-0.1.0.dist-info/licenses/LICENSE,sha256=s_jLy09CPmpz5rCJGiB4EF-vD_IWI8H0_2jR9bj-DiM,11325
16
+ pycentauri-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ centauri = pycentauri.cli:app