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/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,,
|