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 ADDED
@@ -0,0 +1,18 @@
1
+ """Python client for Elegoo Centauri Carbon 3D printers."""
2
+
3
+ from pycentauri.client import ControlDisabledError, Printer, PrinterError
4
+ from pycentauri.discovery import DiscoveredPrinter, discover
5
+ from pycentauri.models import Attributes, PrintInfo, Status
6
+
7
+ __all__ = [
8
+ "Attributes",
9
+ "ControlDisabledError",
10
+ "DiscoveredPrinter",
11
+ "PrintInfo",
12
+ "Printer",
13
+ "PrinterError",
14
+ "Status",
15
+ "discover",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
pycentauri/camera.py ADDED
@@ -0,0 +1,66 @@
1
+ """Grab JPEG snapshots from the Centauri Carbon's built-in webcam.
2
+
3
+ The printer exposes an MJPEG stream at ``http://<host>:3031/video``
4
+ (``multipart/x-mixed-replace``). A snapshot is the first complete JPEG
5
+ frame (SOI ``FF D8`` through EOI ``FF D9``) we can read, after which we
6
+ close the connection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+ CAMERA_PORT = 3031
14
+ CAMERA_PATH = "/video"
15
+ SOI = b"\xff\xd8"
16
+ EOI = b"\xff\xd9"
17
+ DEFAULT_TIMEOUT = 10.0
18
+ MAX_FRAME_BYTES = 8 * 1024 * 1024 # 8 MB safety cap
19
+
20
+
21
+ class SnapshotError(RuntimeError):
22
+ """Raised when the webcam endpoint is unreachable or returns no frame."""
23
+
24
+
25
+ async def snapshot(
26
+ host: str,
27
+ *,
28
+ timeout: float = DEFAULT_TIMEOUT,
29
+ port: int = CAMERA_PORT,
30
+ path: str = CAMERA_PATH,
31
+ client: httpx.AsyncClient | None = None,
32
+ ) -> bytes:
33
+ """Return a single JPEG frame from the webcam as bytes.
34
+
35
+ ``client`` can be supplied to reuse a configured AsyncClient (e.g. for
36
+ tests against a mock server); otherwise we create one per call.
37
+ """
38
+ url = f"http://{host}:{port}{path}"
39
+ owns_client = client is None
40
+ if client is None:
41
+ client = httpx.AsyncClient(timeout=timeout)
42
+
43
+ try:
44
+ buf = bytearray()
45
+ start: int | None = None
46
+ async with client.stream("GET", url, timeout=timeout) as response:
47
+ if response.status_code != 200:
48
+ raise SnapshotError(f"webcam returned HTTP {response.status_code} from {url}")
49
+ async for chunk in response.aiter_bytes():
50
+ if not chunk:
51
+ continue
52
+ buf.extend(chunk)
53
+ if start is None:
54
+ i = buf.find(SOI)
55
+ if i >= 0:
56
+ start = i
57
+ if start is not None:
58
+ j = buf.find(EOI, start + 2)
59
+ if j >= 0:
60
+ return bytes(buf[start : j + 2])
61
+ if len(buf) > MAX_FRAME_BYTES:
62
+ raise SnapshotError("webcam frame exceeded size cap")
63
+ raise SnapshotError("webcam stream ended before a complete JPEG arrived")
64
+ finally:
65
+ if owns_client:
66
+ await client.aclose()
pycentauri/cli.py ADDED
@@ -0,0 +1,341 @@
1
+ """Typer-based CLI for pycentauri.
2
+
3
+ All commands accept ``--host`` (or ``PYCENTAURI_HOST``); if none is given,
4
+ we run discovery and bail out if there isn't exactly one printer on the
5
+ LAN. Control actions additionally require ``--enable-control``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+
20
+ from pycentauri import __version__
21
+ from pycentauri.client import Printer
22
+ from pycentauri.discovery import discover as discover_printers
23
+
24
+ app = typer.Typer(
25
+ name="centauri",
26
+ help="Control and monitor Elegoo Centauri Carbon 3D printers.",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ )
30
+ print_cmd = typer.Typer(name="print", help="Start, pause, resume, or stop a print.")
31
+ app.add_typer(print_cmd, name="print")
32
+
33
+
34
+ HostOpt = Annotated[
35
+ str | None,
36
+ typer.Option(
37
+ "--host",
38
+ "-H",
39
+ envvar="PYCENTAURI_HOST",
40
+ help="Printer IP/hostname. If unset, auto-discover on the LAN.",
41
+ ),
42
+ ]
43
+ ControlOpt = Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--enable-control",
47
+ envvar="PYCENTAURI_ENABLE_CONTROL",
48
+ help="Required for write actions. Off by default for safety.",
49
+ ),
50
+ ]
51
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")]
52
+
53
+
54
+ def _echo_err(msg: str) -> None:
55
+ typer.echo(msg, err=True)
56
+
57
+
58
+ async def _resolve_host(host: str | None) -> str:
59
+ if host:
60
+ return host
61
+ found = await discover_printers(timeout=2.5)
62
+ if not found:
63
+ _echo_err("No printers found on the LAN. Pass --host explicitly.")
64
+ raise typer.Exit(code=2)
65
+ if len(found) > 1:
66
+ _echo_err(f"Multiple printers found ({len(found)}); pass --host explicitly.")
67
+ for p in found:
68
+ _echo_err(f" {p.host} {p.machine_name or '?'} {p.firmware_version or '?'}")
69
+ raise typer.Exit(code=2)
70
+ return found[0].host
71
+
72
+
73
+ def _run(coro: asyncio.coroutines.Coroutine[object, object, object]) -> object: # type: ignore[name-defined]
74
+ return asyncio.run(coro)
75
+
76
+
77
+ @app.callback(invoke_without_command=True)
78
+ def _main(
79
+ ctx: typer.Context,
80
+ version: Annotated[
81
+ bool, typer.Option("--version", help="Print version and exit.", is_eager=True)
82
+ ] = False,
83
+ ) -> None:
84
+ if version:
85
+ typer.echo(__version__)
86
+ raise typer.Exit(code=0)
87
+ if ctx.invoked_subcommand is None:
88
+ typer.echo(ctx.get_help())
89
+ raise typer.Exit(code=0)
90
+
91
+
92
+ @app.command("discover")
93
+ def cmd_discover(
94
+ timeout: float = typer.Option(2.5, "--timeout", "-t", help="Seconds to wait."),
95
+ as_json: JsonOpt = False,
96
+ ) -> None:
97
+ """Find Centauri Carbon printers on the local network."""
98
+
99
+ async def run() -> None:
100
+ found = await discover_printers(timeout=timeout)
101
+ if as_json:
102
+ payload = [
103
+ {
104
+ "host": p.host,
105
+ "mainboard_id": p.mainboard_id,
106
+ "name": p.name,
107
+ "machine_name": p.machine_name,
108
+ "firmware_version": p.firmware_version,
109
+ }
110
+ for p in found
111
+ ]
112
+ typer.echo(json.dumps(payload, indent=2))
113
+ else:
114
+ if not found:
115
+ typer.echo("(no printers responded)")
116
+ return
117
+ for p in found:
118
+ typer.echo(
119
+ f"{p.host:<15s} {p.machine_name or '?':<18s} "
120
+ f"fw={p.firmware_version or '?':<8s} id={p.mainboard_id or '?'}"
121
+ )
122
+
123
+ _run(run())
124
+
125
+
126
+ @app.command("status")
127
+ def cmd_status(host: HostOpt = None, as_json: JsonOpt = False) -> None:
128
+ """Print the printer's current status once and exit."""
129
+
130
+ async def run() -> None:
131
+ h = await _resolve_host(host)
132
+ async with await Printer.connect(h) as printer:
133
+ st = await printer.status()
134
+ if as_json:
135
+ typer.echo(json.dumps(st.raw, indent=2, default=str))
136
+ else:
137
+ pi = st.print_info
138
+ typer.echo(f"state : {st.state} (print_status={st.print_status})")
139
+ typer.echo(
140
+ f"progress : {st.progress}%" if st.progress is not None else "progress : ?"
141
+ )
142
+ typer.echo(f"filename : {st.filename or '-'}")
143
+ typer.echo(
144
+ f"nozzle : {st.temp_nozzle:.1f}°C / {st.temp_nozzle_target or 0:.0f}°C"
145
+ if st.temp_nozzle is not None
146
+ else "nozzle : ?"
147
+ )
148
+ typer.echo(
149
+ f"bed : {st.temp_bed:.1f}°C / {st.temp_bed_target or 0:.0f}°C"
150
+ if st.temp_bed is not None
151
+ else "bed : ?"
152
+ )
153
+ typer.echo(
154
+ f"chamber : {st.temp_chamber:.1f}°C / {st.temp_chamber_target or 0:.0f}°C"
155
+ if st.temp_chamber is not None
156
+ else "chamber : ?"
157
+ )
158
+ if pi is not None and pi.total_layer:
159
+ typer.echo(f"layer : {pi.current_layer}/{pi.total_layer}")
160
+ if st.coord is not None:
161
+ x, y, z = st.coord
162
+ typer.echo(f"position : X={x:.2f} Y={y:.2f} Z={z:.2f}")
163
+ if st.z_offset is not None:
164
+ typer.echo(f"z-offset : {st.z_offset:.3f}")
165
+ if st.fan_speed:
166
+ fans = " ".join(f"{k}={v}%" for k, v in st.fan_speed.items())
167
+ typer.echo(f"fans : {fans}")
168
+
169
+ _run(run())
170
+
171
+
172
+ @app.command("watch")
173
+ def cmd_watch(
174
+ host: HostOpt = None,
175
+ period_ms: int = typer.Option(2000, "--period-ms", help="Push interval."),
176
+ as_json: JsonOpt = False,
177
+ ) -> None:
178
+ """Stream live status updates until interrupted (Ctrl-C)."""
179
+
180
+ async def run() -> None:
181
+ h = await _resolve_host(host)
182
+ async with await Printer.connect(h, push_period_ms=period_ms) as printer:
183
+ async for st in printer.watch():
184
+ if as_json:
185
+ typer.echo(json.dumps(st.raw, default=str))
186
+ else:
187
+ noz = f"{st.temp_nozzle:.1f}" if st.temp_nozzle is not None else "?"
188
+ bed = f"{st.temp_bed:.1f}" if st.temp_bed is not None else "?"
189
+ prog = f"{st.progress}%" if st.progress is not None else "?"
190
+ typer.echo(
191
+ f"[{st.print_status or '-'}] {prog:>4s} nozzle={noz} bed={bed} "
192
+ f"file={st.filename or '-'}"
193
+ )
194
+
195
+ with contextlib.suppress(KeyboardInterrupt):
196
+ _run(run())
197
+
198
+
199
+ @app.command("attributes")
200
+ def cmd_attributes(host: HostOpt = None, as_json: JsonOpt = False) -> None:
201
+ """Print the printer's attributes (model, firmware, capabilities)."""
202
+
203
+ async def run() -> None:
204
+ h = await _resolve_host(host)
205
+ async with await Printer.connect(h) as printer:
206
+ attrs = await printer.attributes()
207
+ if as_json:
208
+ typer.echo(json.dumps(attrs.raw, indent=2, default=str))
209
+ else:
210
+ typer.echo(f"mainboard_id : {attrs.mainboard_id}")
211
+ typer.echo(f"name : {attrs.name}")
212
+ typer.echo(f"machine_name : {attrs.machine_name}")
213
+ typer.echo(f"firmware : {attrs.firmware_version}")
214
+ if attrs.capabilities:
215
+ typer.echo(f"capabilities : {', '.join(attrs.capabilities)}")
216
+
217
+ _run(run())
218
+
219
+
220
+ @app.command("snapshot")
221
+ def cmd_snapshot(
222
+ out: Annotated[Path, typer.Argument(help="Output JPEG path, or '-' for stdout.")],
223
+ host: HostOpt = None,
224
+ timeout: float = typer.Option(10.0, "--timeout", "-t"),
225
+ ) -> None:
226
+ """Save a JPEG snapshot from the built-in webcam."""
227
+
228
+ async def run() -> None:
229
+ h = await _resolve_host(host)
230
+ async with await Printer.connect(h) as printer:
231
+ jpeg = await printer.snapshot(timeout=timeout)
232
+ if str(out) == "-":
233
+ sys.stdout.buffer.write(jpeg)
234
+ else:
235
+ out.write_bytes(jpeg)
236
+ typer.echo(f"wrote {len(jpeg)} bytes to {out}")
237
+
238
+ _run(run())
239
+
240
+
241
+ @app.command("files")
242
+ def cmd_files(host: HostOpt = None, as_json: JsonOpt = False) -> None:
243
+ """List files stored on the printer.
244
+
245
+ Note: the file-list command isn't wired in v0.1 (the upstream SDK also
246
+ marks it as not-implemented on CC); this is a stub so the command name
247
+ is reserved.
248
+ """
249
+
250
+ _echo_err("file listing is not yet supported on the original Centauri Carbon firmware")
251
+ raise typer.Exit(code=1)
252
+
253
+
254
+ @print_cmd.command("start")
255
+ def cmd_print_start(
256
+ filename: Annotated[str, typer.Argument(help="File name as it appears on the printer.")],
257
+ host: HostOpt = None,
258
+ enable_control: ControlOpt = False,
259
+ storage: str = typer.Option("local", "--storage", help="'local' or 'udisk'."),
260
+ auto_leveling: bool = typer.Option(True, "--auto-level/--no-auto-level"),
261
+ timelapse: bool = typer.Option(False, "--timelapse/--no-timelapse"),
262
+ ) -> None:
263
+ """Start a print of a file already on the printer."""
264
+ if not enable_control:
265
+ _echo_err("Refusing to send a write action without --enable-control.")
266
+ raise typer.Exit(code=2)
267
+
268
+ async def run() -> None:
269
+ h = await _resolve_host(host)
270
+ async with await Printer.connect(h, enable_control=True) as printer:
271
+ result = await printer.start_print(
272
+ filename, storage=storage, auto_leveling=auto_leveling, timelapse=timelapse
273
+ )
274
+ typer.echo(f"start_print sent; response: {result.inner}")
275
+
276
+ _run(run())
277
+
278
+
279
+ @print_cmd.command("pause")
280
+ def cmd_print_pause(host: HostOpt = None, enable_control: ControlOpt = False) -> None:
281
+ """Pause the current print."""
282
+ if not enable_control:
283
+ _echo_err("Refusing to send a write action without --enable-control.")
284
+ raise typer.Exit(code=2)
285
+
286
+ async def run() -> None:
287
+ h = await _resolve_host(host)
288
+ async with await Printer.connect(h, enable_control=True) as printer:
289
+ await printer.pause()
290
+ typer.echo("paused")
291
+
292
+ _run(run())
293
+
294
+
295
+ @print_cmd.command("resume")
296
+ def cmd_print_resume(host: HostOpt = None, enable_control: ControlOpt = False) -> None:
297
+ """Resume a paused print."""
298
+ if not enable_control:
299
+ _echo_err("Refusing to send a write action without --enable-control.")
300
+ raise typer.Exit(code=2)
301
+
302
+ async def run() -> None:
303
+ h = await _resolve_host(host)
304
+ async with await Printer.connect(h, enable_control=True) as printer:
305
+ await printer.resume()
306
+ typer.echo("resumed")
307
+
308
+ _run(run())
309
+
310
+
311
+ @print_cmd.command("stop")
312
+ def cmd_print_stop(host: HostOpt = None, enable_control: ControlOpt = False) -> None:
313
+ """Stop the current print."""
314
+ if not enable_control:
315
+ _echo_err("Refusing to send a write action without --enable-control.")
316
+ raise typer.Exit(code=2)
317
+
318
+ async def run() -> None:
319
+ h = await _resolve_host(host)
320
+ async with await Printer.connect(h, enable_control=True) as printer:
321
+ await printer.stop()
322
+ typer.echo("stop sent")
323
+
324
+ _run(run())
325
+
326
+
327
+ @app.command("mcp")
328
+ def cmd_mcp(
329
+ enable_control: ControlOpt = False,
330
+ host: HostOpt = None,
331
+ ) -> None:
332
+ """Run the MCP stdio server (`python -m pycentauri.mcp` does the same)."""
333
+ try:
334
+ from pycentauri.mcp.server import run_stdio
335
+ except ImportError as e:
336
+ _echo_err("MCP support not installed. Install with: pip install 'pycentauri[mcp]'")
337
+ _echo_err(f"(missing dependency: {e})")
338
+ raise typer.Exit(code=1) from e
339
+ if host:
340
+ os.environ["PYCENTAURI_HOST"] = host
341
+ run_stdio(enable_control=enable_control)