pycentauri 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {pycentauri-0.2.0 → pycentauri-0.3.0}/CHANGELOG.md +18 -0
  2. {pycentauri-0.2.0 → pycentauri-0.3.0}/PKG-INFO +8 -3
  3. {pycentauri-0.2.0 → pycentauri-0.3.0}/README.md +7 -2
  4. {pycentauri-0.2.0 → pycentauri-0.3.0}/pyproject.toml +4 -1
  5. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/server.py +76 -12
  7. pycentauri-0.3.0/src/pycentauri/web/app.js +179 -0
  8. pycentauri-0.3.0/src/pycentauri/web/index.html +85 -0
  9. pycentauri-0.3.0/src/pycentauri/web/styles.css +257 -0
  10. pycentauri-0.3.0/tests/integration/__init__.py +0 -0
  11. {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_server.py +6 -1
  12. pycentauri-0.2.0/.github/workflows/ci.yml +0 -42
  13. pycentauri-0.2.0/.github/workflows/publish.yml +0 -48
  14. pycentauri-0.2.0/CONTRIBUTING.md +0 -56
  15. pycentauri-0.2.0/examples/snapshot.py +0 -22
  16. pycentauri-0.2.0/examples/start_print.py +0 -26
  17. pycentauri-0.2.0/examples/status_watch.py +0 -27
  18. {pycentauri-0.2.0 → pycentauri-0.3.0}/.gitignore +0 -0
  19. {pycentauri-0.2.0 → pycentauri-0.3.0}/LICENSE +0 -0
  20. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/camera.py +0 -0
  21. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/cli.py +0 -0
  22. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/client.py +0 -0
  23. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/discovery.py +0 -0
  24. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/__init__.py +0 -0
  25. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/__main__.py +0 -0
  26. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/server.py +0 -0
  27. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/models.py +0 -0
  28. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/py.typed +0 -0
  29. {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/sdcp.py +0 -0
  30. {pycentauri-0.2.0/tests → pycentauri-0.3.0/src/pycentauri/web}/__init__.py +0 -0
  31. {pycentauri-0.2.0/tests/integration → pycentauri-0.3.0/tests}/__init__.py +0 -0
  32. {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_client.py +0 -0
  33. {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_discovery.py +0 -0
  34. {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_sdcp.py +0 -0
@@ -6,6 +6,24 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-04-22
10
+
11
+ ### Added
12
+ - **Web UI** at `GET /ui/` (and `GET /` redirects there). Single static
13
+ dashboard bundled in the wheel — no build step, no framework. Shows
14
+ live webcam (MJPEG), progress bar, layer counter, temperature cards,
15
+ position, fans, and Z-offset. Pause / Resume / Stop buttons auto-appear
16
+ when the server is launched with `--enable-control`.
17
+ - `GET /stream` — MJPEG proxy through the API server to the printer's
18
+ `:3031/video`. Browsers render it directly in an `<img>` tag.
19
+ - `GET /api/info` — JSON health endpoint, matching what `GET /` used to
20
+ return before it was repurposed to redirect to `/ui/`.
21
+
22
+ ### Changed
23
+ - `GET /` now redirects to `/ui/` (307). If the web assets aren't found
24
+ in the wheel (custom build, etc.), `/` falls back to a minimal JSON
25
+ health response so scripted clients don't break.
26
+
9
27
  ## [0.2.0] - 2026-04-22
10
28
 
11
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python client and MCP server for Elegoo Centauri Carbon 3D printers
5
5
  Project-URL: Homepage, https://github.com/bjan/pycentauri
6
6
  Project-URL: Repository, https://github.com/bjan/pycentauri
@@ -52,7 +52,7 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
52
52
  Carbon](https://www.elegoo.com/) 3D printer.
53
53
 
54
54
  `pycentauri` speaks the printer's native SDCP v3 protocol over its local
55
- WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
55
+ WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
56
56
 
57
57
  1. An **async Python library** for direct integration.
58
58
  2. A **`centauri` CLI** for quick status checks, snapshots, and control.
@@ -60,6 +60,8 @@ WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
60
60
  MCP-compatible client) can monitor and drive the printer as a tool.
61
61
  4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
62
62
  anything that wants a plain REST API.
63
+ 5. A **built-in web UI** (dark theme, mobile-friendly) served at `/ui/` by
64
+ the HTTP server — live webcam, progress, temperatures, and control.
63
65
 
64
66
  > **Status:** alpha. The protocol has been reverse-engineered from the official
65
67
  > [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
@@ -137,10 +139,13 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
137
139
 
138
140
  | Method | Path | Notes |
139
141
  |---|---|---|
140
- | `GET` | `/` | Health + version |
142
+ | `GET` | `/` | Redirects to `/ui/` |
143
+ | `GET` | `/ui/` | Built-in web dashboard |
144
+ | `GET` | `/api/info` | Health + version (JSON) |
141
145
  | `GET` | `/status` | Latest status push (cached; updates every ~5s) |
142
146
  | `GET` | `/attributes` | Printer attributes |
143
147
  | `GET` | `/snapshot` | `image/jpeg` response |
148
+ | `GET` | `/stream` | MJPEG stream proxied from the printer (embeds in `<img>`) |
144
149
  | `GET` | `/discover` | LAN scan |
145
150
  | `GET` | `/events/status` | Server-Sent Events stream of pushes |
146
151
  | `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
@@ -4,7 +4,7 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
4
4
  Carbon](https://www.elegoo.com/) 3D printer.
5
5
 
6
6
  `pycentauri` speaks the printer's native SDCP v3 protocol over its local
7
- WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
7
+ WebSocket (port 3030) — no cloud account required. It exposes five surfaces:
8
8
 
9
9
  1. An **async Python library** for direct integration.
10
10
  2. A **`centauri` CLI** for quick status checks, snapshots, and control.
@@ -12,6 +12,8 @@ WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
12
12
  MCP-compatible client) can monitor and drive the printer as a tool.
13
13
  4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
14
14
  anything that wants a plain REST API.
15
+ 5. A **built-in web UI** (dark theme, mobile-friendly) served at `/ui/` by
16
+ the HTTP server — live webcam, progress, temperatures, and control.
15
17
 
16
18
  > **Status:** alpha. The protocol has been reverse-engineered from the official
17
19
  > [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
@@ -89,10 +91,13 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
89
91
 
90
92
  | Method | Path | Notes |
91
93
  |---|---|---|
92
- | `GET` | `/` | Health + version |
94
+ | `GET` | `/` | Redirects to `/ui/` |
95
+ | `GET` | `/ui/` | Built-in web dashboard |
96
+ | `GET` | `/api/info` | Health + version (JSON) |
93
97
  | `GET` | `/status` | Latest status push (cached; updates every ~5s) |
94
98
  | `GET` | `/attributes` | Printer attributes |
95
99
  | `GET` | `/snapshot` | `image/jpeg` response |
100
+ | `GET` | `/stream` | MJPEG stream proxied from the printer (embeds in `<img>`) |
96
101
  | `GET` | `/discover` | LAN scan |
97
102
  | `GET` | `/events/status` | Server-Sent Events stream of pushes |
98
103
  | `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycentauri"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python client and MCP server for Elegoo Centauri Carbon 3D printers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -58,6 +58,9 @@ Changelog = "https://github.com/bjan/pycentauri/blob/main/CHANGELOG.md"
58
58
  [tool.hatch.build.targets.wheel]
59
59
  packages = ["src/pycentauri"]
60
60
 
61
+ [tool.hatch.build.targets.sdist]
62
+ include = ["src/pycentauri", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
63
+
61
64
  [tool.ruff]
62
65
  line-length = 100
63
66
  target-version = "py310"
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "discover",
16
16
  ]
17
17
 
18
- __version__ = "0.2.0"
18
+ __version__ = "0.3.0"
@@ -30,14 +30,18 @@ import json
30
30
  import logging
31
31
  from collections.abc import AsyncIterator
32
32
  from contextlib import asynccontextmanager
33
+ from importlib.resources import files as resource_files
33
34
  from typing import Any
34
35
 
36
+ import httpx
35
37
  from fastapi import Depends, FastAPI, HTTPException, Request
36
- from fastapi.responses import JSONResponse, Response
38
+ from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
39
+ from fastapi.staticfiles import StaticFiles
37
40
  from pydantic import BaseModel, Field
38
41
  from sse_starlette.sse import EventSourceResponse
39
42
 
40
43
  from pycentauri import __version__
44
+ from pycentauri.camera import CAMERA_PATH, CAMERA_PORT
41
45
  from pycentauri.client import ControlDisabledError, Printer, PrinterError
42
46
  from pycentauri.discovery import DiscoveredPrinter
43
47
  from pycentauri.discovery import discover as lan_discover
@@ -208,17 +212,6 @@ def create_app(
208
212
  lifespan=lifespan,
209
213
  )
210
214
 
211
- @app.get("/", tags=["meta"])
212
- async def root(manager: PrinterManager = Depends(get_manager)) -> dict[str, Any]:
213
- return {
214
- "service": "pycentauri",
215
- "version": __version__,
216
- "printer_host": manager.host,
217
- "mainboard_id": manager._mainboard_id,
218
- "connected": manager._printer is not None and not manager._printer._closed,
219
- "enable_control": manager.enable_control,
220
- }
221
-
222
215
  @app.get("/status", tags=["read"])
223
216
  async def status_endpoint(
224
217
  manager: PrinterManager = Depends(get_manager),
@@ -282,6 +275,38 @@ def create_app(
282
275
  headers={"Cache-Control": "no-store"},
283
276
  )
284
277
 
278
+ @app.get("/stream", tags=["read"])
279
+ async def stream_endpoint(
280
+ manager: PrinterManager = Depends(get_manager),
281
+ ) -> StreamingResponse:
282
+ """Proxy the printer's MJPEG stream.
283
+
284
+ The printer serves ``multipart/x-mixed-replace`` on port 3031.
285
+ Browsers can render this directly in an ``<img src=...>`` tag. We
286
+ proxy it through the API server so the UI never needs to know the
287
+ printer's IP and so cross-origin isn't an issue.
288
+ """
289
+ url = f"http://{manager.host}:{CAMERA_PORT}{CAMERA_PATH}"
290
+ client = httpx.AsyncClient(timeout=None)
291
+
292
+ async def body() -> AsyncIterator[bytes]:
293
+ try:
294
+ async with client.stream("GET", url) as upstream:
295
+ if upstream.status_code != 200:
296
+ return
297
+ async for chunk in upstream.aiter_raw():
298
+ yield chunk
299
+ except Exception as err:
300
+ log.warning("MJPEG proxy error: %r", err)
301
+ finally:
302
+ with contextlib.suppress(Exception):
303
+ await client.aclose()
304
+
305
+ return StreamingResponse(
306
+ body(),
307
+ media_type="multipart/x-mixed-replace; boundary=--foo",
308
+ )
309
+
285
310
  @app.get("/discover", tags=["read"])
286
311
  async def discover_endpoint() -> list[dict[str, Any]]:
287
312
  found: list[DiscoveredPrinter] = await lan_discover(timeout=2.0, retries=2)
@@ -317,6 +342,45 @@ def create_app(
317
342
 
318
343
  return EventSourceResponse(gen())
319
344
 
345
+ # --- Meta / health ------------------------------------------------------
346
+
347
+ @app.get("/api/info", tags=["meta"])
348
+ async def api_info(
349
+ manager: PrinterManager = Depends(get_manager),
350
+ ) -> dict[str, Any]:
351
+ return {
352
+ "service": "pycentauri",
353
+ "version": __version__,
354
+ "printer_host": manager.host,
355
+ "mainboard_id": manager._mainboard_id,
356
+ "connected": manager._printer is not None and not manager._printer._closed,
357
+ "enable_control": manager.enable_control,
358
+ }
359
+
360
+ # --- Static web UI ------------------------------------------------------
361
+
362
+ _web_root = resource_files("pycentauri").joinpath("web")
363
+ if _web_root.is_dir():
364
+ app.mount("/ui", StaticFiles(directory=str(_web_root), html=True), name="ui")
365
+
366
+ @app.get("/", include_in_schema=False)
367
+ async def root_redirect() -> RedirectResponse:
368
+ return RedirectResponse(url="/ui/", status_code=307)
369
+ else:
370
+
371
+ @app.get("/", tags=["meta"])
372
+ async def root_fallback(
373
+ manager: PrinterManager = Depends(get_manager),
374
+ ) -> dict[str, Any]:
375
+ # Web UI assets not present — fall back to JSON health.
376
+ return {
377
+ "service": "pycentauri",
378
+ "version": __version__,
379
+ "printer_host": manager.host,
380
+ "enable_control": manager.enable_control,
381
+ "ui": "not installed",
382
+ }
383
+
320
384
  if not enable_control:
321
385
  return app
322
386
 
@@ -0,0 +1,179 @@
1
+ // pycentauri web UI
2
+ // Consumes /api/info, /status (one-shot), /events/status (SSE), and POSTs to
3
+ // /print/{pause,resume,stop} when control is enabled.
4
+
5
+ const $ = (id) => document.getElementById(id);
6
+
7
+ const PRINT_STATUS = {
8
+ 0: { label: "idle", cls: "state-idle" },
9
+ 1: { label: "homing", cls: "state-printing" },
10
+ 2: { label: "dropping", cls: "state-printing" },
11
+ 3: { label: "exposing", cls: "state-printing" },
12
+ 4: { label: "lifting", cls: "state-printing" },
13
+ 5: { label: "pausing", cls: "state-paused" },
14
+ 6: { label: "paused", cls: "state-paused" },
15
+ 7: { label: "stopping", cls: "state-stopped" },
16
+ 8: { label: "stopped", cls: "state-stopped" },
17
+ 9: { label: "completed", cls: "state-idle" },
18
+ 10: { label: "checking", cls: "state-printing" },
19
+ 12: { label: "preparing", cls: "state-printing" },
20
+ 13: { label: "printing", cls: "state-printing" },
21
+ 18: { label: "resumed", cls: "state-printing" },
22
+ };
23
+
24
+ function fmtSeconds(s) {
25
+ if (s == null || Number.isNaN(s)) return "—";
26
+ s = Math.max(0, Math.round(s));
27
+ const h = Math.floor(s / 3600);
28
+ const m = Math.floor((s % 3600) / 60);
29
+ const sec = s % 60;
30
+ if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
31
+ if (m > 0) return `${m}m ${sec.toString().padStart(2, "0")}s`;
32
+ return `${sec}s`;
33
+ }
34
+
35
+ function fmtTemp(v) {
36
+ return (v == null || Number.isNaN(v)) ? "—" : v.toFixed(1);
37
+ }
38
+
39
+ async function loadInfo() {
40
+ try {
41
+ const r = await fetch("/api/info");
42
+ if (!r.ok) return;
43
+ const info = await r.json();
44
+ $("version").textContent = info.version || "—";
45
+ $("host").textContent = info.printer_host || "—";
46
+ $("mainboard").textContent = info.mainboard_id || "—";
47
+ if (info.enable_control) $("controls").hidden = false;
48
+
49
+ try {
50
+ const attrs = await (await fetch("/attributes")).json();
51
+ $("printer-name").textContent = attrs.machine_name || attrs.name || "Centauri Carbon";
52
+ $("firmware").textContent = attrs.firmware_version ? `· ${attrs.firmware_version}` : "";
53
+ } catch (_) { /* printer may still be warming up */ }
54
+ } catch (e) {
55
+ console.warn("loadInfo failed:", e);
56
+ }
57
+ }
58
+
59
+ function renderStatus(raw) {
60
+ // raw is the printer's Status dict (SDCP payload).
61
+ const pi = raw.PrintInfo || {};
62
+ const pstatus = pi.Status;
63
+ const filename = pi.Filename || "—";
64
+
65
+ $("filename").textContent = filename;
66
+
67
+ const progress = pi.Progress;
68
+ $("progress-bar").style.width = (progress != null ? progress : 0) + "%";
69
+ const layerStr = pi.CurrentLayer != null && pi.TotalLayer
70
+ ? ` · layer ${pi.CurrentLayer}/${pi.TotalLayer}`
71
+ : "";
72
+ $("progress-label").textContent = (progress != null ? progress + "%" : "—") + layerStr;
73
+
74
+ const info = PRINT_STATUS[pstatus];
75
+ const ps = $("print-status");
76
+ ps.textContent = info ? info.label : (pstatus != null ? `code ${pstatus}` : "—");
77
+ ps.className = info ? info.cls : "";
78
+
79
+ $("layer").textContent = (pi.CurrentLayer != null && pi.TotalLayer)
80
+ ? `${pi.CurrentLayer}/${pi.TotalLayer}` : "—";
81
+
82
+ // SDCP Ticks are reported in seconds on this firmware.
83
+ const elapsed = pi.CurrentTicks;
84
+ const total = pi.TotalTicks;
85
+ $("elapsed").textContent = fmtSeconds(elapsed);
86
+ $("remaining").textContent = (total != null && elapsed != null)
87
+ ? fmtSeconds(total - elapsed) : "—";
88
+
89
+ // Temperatures — firmware gives scalars for actuals and TempTarget* for targets.
90
+ const noz = raw.TempOfNozzle, nozT = raw.TempTargetNozzle;
91
+ const bed = raw.TempOfHotbed, bedT = raw.TempTargetHotbed;
92
+ const cham = raw.TempOfBox, chamT = raw.TempTargetBox;
93
+ $("t-nozzle").textContent = fmtTemp(noz);
94
+ $("t-nozzle-target").textContent = fmtTemp(nozT);
95
+ $("t-bed").textContent = fmtTemp(bed);
96
+ $("t-bed-target").textContent = fmtTemp(bedT);
97
+ $("t-chamber").textContent = fmtTemp(cham);
98
+ $("t-chamber-target").textContent = fmtTemp(chamT);
99
+
100
+ if (raw.CurrenCoord) $("position").textContent = raw.CurrenCoord;
101
+ if (raw.ZOffset != null) $("z-offset").textContent = raw.ZOffset.toFixed(3);
102
+ if (raw.CurrentFanSpeed) {
103
+ $("fans").textContent = Object.entries(raw.CurrentFanSpeed)
104
+ .map(([k, v]) => `${k}=${v}%`).join(" ");
105
+ }
106
+
107
+ $("status-dot").className = "dot ok";
108
+ }
109
+
110
+ async function pollOnce() {
111
+ try {
112
+ const r = await fetch("/status");
113
+ if (!r.ok) throw new Error("HTTP " + r.status);
114
+ const body = await r.json();
115
+ if (body.raw) renderStatus(body.raw);
116
+ } catch (e) {
117
+ $("status-dot").className = "dot err";
118
+ console.warn("poll failed:", e);
119
+ }
120
+ }
121
+
122
+ function connectSSE() {
123
+ let src;
124
+ try {
125
+ src = new EventSource("/events/status");
126
+ } catch (e) {
127
+ $("status-dot").className = "dot warn";
128
+ setInterval(pollOnce, 3000);
129
+ return;
130
+ }
131
+ src.addEventListener("status", (ev) => {
132
+ try { renderStatus(JSON.parse(ev.data)); } catch (_) {}
133
+ });
134
+ src.onerror = () => {
135
+ $("status-dot").className = "dot warn";
136
+ // EventSource auto-reconnects; keep a slow poll as a safety net.
137
+ setTimeout(pollOnce, 2000);
138
+ };
139
+ }
140
+
141
+ // --- Controls (only wired when visible) -----------------------------------
142
+
143
+ async function doAction(name, path) {
144
+ const msg = $("ctrl-msg");
145
+ msg.textContent = `${name}…`;
146
+ for (const b of document.querySelectorAll(".controls button")) b.disabled = true;
147
+ try {
148
+ const r = await fetch(path, { method: "POST" });
149
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
150
+ msg.textContent = `${name} ✓`;
151
+ } catch (e) {
152
+ msg.textContent = `${name} failed — ${e.message}`;
153
+ } finally {
154
+ setTimeout(() => {
155
+ for (const b of document.querySelectorAll(".controls button")) b.disabled = false;
156
+ }, 800);
157
+ }
158
+ }
159
+
160
+ function wireControls() {
161
+ $("btn-pause").addEventListener("click", () => {
162
+ if (confirm("Pause the current print?")) doAction("pause", "/print/pause");
163
+ });
164
+ $("btn-resume").addEventListener("click", () => doAction("resume", "/print/resume"));
165
+ $("btn-stop").addEventListener("click", () => {
166
+ if (confirm("Stop the current print? This cannot be undone.")) doAction("stop", "/print/stop");
167
+ });
168
+ }
169
+
170
+ // --- Boot ------------------------------------------------------------------
171
+
172
+ (async function main() {
173
+ await loadInfo();
174
+ wireControls();
175
+ await pollOnce();
176
+ connectSSE();
177
+ // Safety net: refresh info every 60s in case the server was restarted.
178
+ setInterval(loadInfo, 60000);
179
+ })();
@@ -0,0 +1,85 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>pycentauri</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>pycentauri</h1>
12
+ <div id="header-meta">
13
+ <span id="printer-name">…</span>
14
+ <span id="firmware"></span>
15
+ <span id="status-dot" class="dot" title="Connection state"></span>
16
+ </div>
17
+ </header>
18
+
19
+ <main>
20
+ <section class="card webcam">
21
+ <img id="webcam" alt="Webcam" src="/stream">
22
+ <a class="snap-link" href="/snapshot" download="snapshot.jpg">Snapshot</a>
23
+ </section>
24
+
25
+ <section class="card job">
26
+ <div id="filename" class="filename">—</div>
27
+ <div class="progress-wrap">
28
+ <div id="progress-bar" class="progress-bar"></div>
29
+ <span id="progress-label" class="progress-label">—</span>
30
+ </div>
31
+ <div class="job-meta">
32
+ <div><span class="k">state</span><span id="print-status">—</span></div>
33
+ <div><span class="k">layer</span><span id="layer">—</span></div>
34
+ <div><span class="k">elapsed</span><span id="elapsed">—</span></div>
35
+ <div><span class="k">remaining</span><span id="remaining">—</span></div>
36
+ </div>
37
+ </section>
38
+
39
+ <section class="temps">
40
+ <div class="card temp">
41
+ <div class="k">nozzle</div>
42
+ <div class="t"><span id="t-nozzle">—</span><span class="unit">°C</span></div>
43
+ <div class="sub">target <span id="t-nozzle-target">—</span>°C</div>
44
+ </div>
45
+ <div class="card temp">
46
+ <div class="k">bed</div>
47
+ <div class="t"><span id="t-bed">—</span><span class="unit">°C</span></div>
48
+ <div class="sub">target <span id="t-bed-target">—</span>°C</div>
49
+ </div>
50
+ <div class="card temp">
51
+ <div class="k">chamber</div>
52
+ <div class="t"><span id="t-chamber">—</span><span class="unit">°C</span></div>
53
+ <div class="sub">target <span id="t-chamber-target">—</span>°C</div>
54
+ </div>
55
+ </section>
56
+
57
+ <section id="controls" class="card controls" hidden>
58
+ <h2>Control</h2>
59
+ <div class="btn-row">
60
+ <button id="btn-pause" class="btn warn">Pause</button>
61
+ <button id="btn-resume" class="btn">Resume</button>
62
+ <button id="btn-stop" class="btn danger">Stop</button>
63
+ </div>
64
+ <div id="ctrl-msg" class="ctrl-msg"></div>
65
+ </section>
66
+
67
+ <section class="card position">
68
+ <div class="row"><span class="k">position</span><span id="position">—</span></div>
69
+ <div class="row"><span class="k">z-offset</span><span id="z-offset">—</span></div>
70
+ <div class="row"><span class="k">fans</span><span id="fans">—</span></div>
71
+ <div class="row"><span class="k">mainboard</span><span id="mainboard">—</span></div>
72
+ </section>
73
+ </main>
74
+
75
+ <footer>
76
+ <span>pycentauri <span id="version">—</span></span>
77
+ <span>·</span>
78
+ <span id="host">—</span>
79
+ <span>·</span>
80
+ <a href="/docs">API docs</a>
81
+ </footer>
82
+
83
+ <script src="app.js"></script>
84
+ </body>
85
+ </html>
@@ -0,0 +1,257 @@
1
+ :root {
2
+ --bg: #0d0f12;
3
+ --bg-card: #171a1f;
4
+ --bg-card-alt: #1f242b;
5
+ --border: #262d36;
6
+ --fg: #e7ecf2;
7
+ --fg-muted: #8a94a3;
8
+ --accent: #4fb3ff;
9
+ --accent-warn: #f3b54a;
10
+ --accent-danger: #ef5b5b;
11
+ --accent-ok: #56d28a;
12
+ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
13
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+ html, body { margin: 0; padding: 0; }
18
+ body {
19
+ background: var(--bg);
20
+ color: var(--fg);
21
+ font-family: var(--font);
22
+ font-size: 14px;
23
+ line-height: 1.45;
24
+ min-height: 100vh;
25
+ display: flex;
26
+ flex-direction: column;
27
+ }
28
+
29
+ header {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: space-between;
33
+ padding: 14px 20px;
34
+ border-bottom: 1px solid var(--border);
35
+ background: var(--bg-card);
36
+ }
37
+ header h1 {
38
+ font-size: 16px;
39
+ font-weight: 600;
40
+ margin: 0;
41
+ letter-spacing: 0.02em;
42
+ }
43
+ #header-meta {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 10px;
47
+ font-size: 12px;
48
+ color: var(--fg-muted);
49
+ }
50
+ #header-meta #printer-name { color: var(--fg); }
51
+ .dot {
52
+ width: 9px; height: 9px; border-radius: 50%;
53
+ background: var(--fg-muted);
54
+ display: inline-block;
55
+ transition: background .2s;
56
+ }
57
+ .dot.ok { background: var(--accent-ok); box-shadow: 0 0 6px var(--accent-ok); }
58
+ .dot.warn { background: var(--accent-warn); }
59
+ .dot.err { background: var(--accent-danger); }
60
+
61
+ main {
62
+ flex: 1;
63
+ padding: 16px;
64
+ display: grid;
65
+ gap: 16px;
66
+ grid-template-columns: 1fr;
67
+ max-width: 1200px;
68
+ margin: 0 auto;
69
+ width: 100%;
70
+ }
71
+
72
+ @media (min-width: 900px) {
73
+ main {
74
+ grid-template-columns: 2fr 1fr;
75
+ grid-template-areas:
76
+ "webcam job"
77
+ "webcam temps"
78
+ "controls temps"
79
+ "position position";
80
+ }
81
+ .webcam { grid-area: webcam; }
82
+ .job { grid-area: job; }
83
+ .temps { grid-area: temps; }
84
+ .controls { grid-area: controls; }
85
+ .position { grid-area: position; }
86
+ }
87
+
88
+ .card {
89
+ background: var(--bg-card);
90
+ border: 1px solid var(--border);
91
+ border-radius: 10px;
92
+ padding: 14px 16px;
93
+ }
94
+
95
+ .card h2 {
96
+ margin: 0 0 10px;
97
+ font-size: 13px;
98
+ text-transform: uppercase;
99
+ letter-spacing: 0.08em;
100
+ color: var(--fg-muted);
101
+ }
102
+
103
+ .webcam {
104
+ position: relative;
105
+ padding: 0;
106
+ overflow: hidden;
107
+ aspect-ratio: 16 / 9;
108
+ background: #000;
109
+ }
110
+ .webcam img {
111
+ width: 100%;
112
+ height: 100%;
113
+ object-fit: contain;
114
+ display: block;
115
+ }
116
+ .snap-link {
117
+ position: absolute;
118
+ top: 10px;
119
+ right: 10px;
120
+ padding: 4px 10px;
121
+ background: rgba(0,0,0,0.55);
122
+ color: var(--fg);
123
+ border: 1px solid var(--border);
124
+ border-radius: 6px;
125
+ font-size: 12px;
126
+ text-decoration: none;
127
+ }
128
+ .snap-link:hover { background: rgba(0,0,0,0.75); }
129
+
130
+ .filename {
131
+ font-family: var(--font-mono);
132
+ font-size: 13px;
133
+ color: var(--fg);
134
+ margin-bottom: 10px;
135
+ word-break: break-all;
136
+ }
137
+
138
+ .progress-wrap {
139
+ position: relative;
140
+ height: 22px;
141
+ background: var(--bg-card-alt);
142
+ border-radius: 5px;
143
+ overflow: hidden;
144
+ margin-bottom: 14px;
145
+ }
146
+ .progress-bar {
147
+ height: 100%;
148
+ width: 0%;
149
+ background: linear-gradient(90deg, #3a9bdc, var(--accent));
150
+ transition: width .6s ease-out;
151
+ }
152
+ .progress-label {
153
+ position: absolute;
154
+ inset: 0;
155
+ display: flex;
156
+ align-items: center;
157
+ justify-content: center;
158
+ font-size: 12px;
159
+ font-variant-numeric: tabular-nums;
160
+ color: var(--fg);
161
+ mix-blend-mode: difference;
162
+ }
163
+
164
+ .job-meta {
165
+ display: grid;
166
+ grid-template-columns: repeat(2, 1fr);
167
+ gap: 6px 16px;
168
+ font-size: 12px;
169
+ }
170
+ .job-meta .k { color: var(--fg-muted); margin-right: 8px; }
171
+
172
+ .temps {
173
+ display: grid;
174
+ grid-template-columns: repeat(3, 1fr);
175
+ gap: 10px;
176
+ }
177
+ .temp { padding: 12px; text-align: center; }
178
+ .temp .k {
179
+ display: block;
180
+ color: var(--fg-muted);
181
+ font-size: 11px;
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.08em;
184
+ margin-bottom: 6px;
185
+ }
186
+ .temp .t {
187
+ font-size: 22px;
188
+ font-weight: 600;
189
+ font-variant-numeric: tabular-nums;
190
+ font-family: var(--font-mono);
191
+ }
192
+ .temp .t .unit {
193
+ font-size: 13px;
194
+ color: var(--fg-muted);
195
+ margin-left: 2px;
196
+ }
197
+ .temp .sub { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
198
+
199
+ .controls .btn-row {
200
+ display: flex;
201
+ gap: 10px;
202
+ flex-wrap: wrap;
203
+ }
204
+ .btn {
205
+ padding: 8px 16px;
206
+ background: var(--bg-card-alt);
207
+ color: var(--fg);
208
+ border: 1px solid var(--border);
209
+ border-radius: 6px;
210
+ font-size: 13px;
211
+ font-family: var(--font);
212
+ cursor: pointer;
213
+ transition: background .15s, border-color .15s;
214
+ }
215
+ .btn:hover:not(:disabled) { background: #262d36; border-color: #3b4552; }
216
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; }
217
+ .btn.warn { border-color: #6a561c; color: var(--accent-warn); }
218
+ .btn.danger { border-color: #6b2c2c; color: var(--accent-danger); }
219
+ .ctrl-msg {
220
+ margin-top: 10px;
221
+ font-size: 12px;
222
+ color: var(--fg-muted);
223
+ font-family: var(--font-mono);
224
+ min-height: 1.2em;
225
+ }
226
+
227
+ .position .row {
228
+ display: flex;
229
+ justify-content: space-between;
230
+ gap: 10px;
231
+ padding: 4px 0;
232
+ border-bottom: 1px solid var(--border);
233
+ font-size: 12px;
234
+ font-family: var(--font-mono);
235
+ }
236
+ .position .row:last-child { border-bottom: none; }
237
+ .position .k { color: var(--fg-muted); }
238
+
239
+ footer {
240
+ padding: 10px 20px;
241
+ text-align: center;
242
+ color: var(--fg-muted);
243
+ font-size: 11px;
244
+ border-top: 1px solid var(--border);
245
+ display: flex;
246
+ justify-content: center;
247
+ gap: 10px;
248
+ flex-wrap: wrap;
249
+ }
250
+ footer a { color: var(--accent); text-decoration: none; }
251
+ footer a:hover { text-decoration: underline; }
252
+
253
+ /* Print status codes — mapped in JS to a human label + color */
254
+ .state-printing { color: var(--accent-ok); }
255
+ .state-paused { color: var(--accent-warn); }
256
+ .state-stopped { color: var(--accent-danger); }
257
+ .state-idle { color: var(--fg-muted); }
File without changes
@@ -32,12 +32,17 @@ async def test_read_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
32
32
  # Mimic the lifespan manager manually: the async-context machinery
33
33
  # under httpx.AsyncClient doesn't drive lifespan, so do it ourselves.
34
34
  async with app.router.lifespan_context(app), await _asgi_client(app) as client:
35
- r = await client.get("/")
35
+ # /api/info is the JSON health endpoint; / redirects to /ui/.
36
+ r = await client.get("/api/info")
36
37
  assert r.status_code == 200
37
38
  body = r.json()
38
39
  assert body["service"] == "pycentauri"
39
40
  assert body["printer_host"] == "127.0.0.1"
40
41
 
42
+ r = await client.get("/ui/", follow_redirects=False)
43
+ assert r.status_code == 200
44
+ assert "<!DOCTYPE html>" in r.text
45
+
41
46
  r = await client.get("/status")
42
47
  assert r.status_code == 200
43
48
  s = r.json()
@@ -1,42 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- workflow_dispatch:
8
-
9
- jobs:
10
- test:
11
- name: "test (py${{ matrix.python-version }}, ${{ matrix.os }})"
12
- runs-on: ${{ matrix.os }}
13
- strategy:
14
- fail-fast: false
15
- matrix:
16
- os: [ubuntu-latest, macos-latest]
17
- python-version: ["3.10", "3.11", "3.12", "3.13"]
18
-
19
- steps:
20
- - uses: actions/checkout@v4
21
-
22
- - name: Set up Python
23
- uses: actions/setup-python@v5
24
- with:
25
- python-version: ${{ matrix.python-version }}
26
-
27
- - name: Install
28
- run: |
29
- python -m pip install --upgrade pip
30
- pip install -e ".[mcp,dev]"
31
-
32
- - name: Ruff check
33
- run: ruff check .
34
-
35
- - name: Ruff format check
36
- run: ruff format --check .
37
-
38
- - name: Mypy
39
- run: mypy src
40
-
41
- - name: Pytest
42
- run: pytest --cov=pycentauri --cov-report=term-missing
@@ -1,48 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- push:
5
- tags: ["v*"]
6
- workflow_dispatch:
7
-
8
- jobs:
9
- build:
10
- name: Build sdist & wheel
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/checkout@v4
14
-
15
- - name: Set up Python
16
- uses: actions/setup-python@v5
17
- with:
18
- python-version: "3.12"
19
-
20
- - name: Install build tooling
21
- run: |
22
- python -m pip install --upgrade pip
23
- pip install build
24
-
25
- - name: Build
26
- run: python -m build
27
-
28
- - uses: actions/upload-artifact@v4
29
- with:
30
- name: dist
31
- path: dist/
32
-
33
- publish:
34
- name: Publish to PyPI
35
- needs: build
36
- runs-on: ubuntu-latest
37
- environment: release
38
- permissions:
39
- id-token: write # required for PyPI trusted publishing (OIDC)
40
-
41
- steps:
42
- - uses: actions/download-artifact@v4
43
- with:
44
- name: dist
45
- path: dist/
46
-
47
- - name: Publish
48
- uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,56 +0,0 @@
1
- # Contributing to pycentauri
2
-
3
- Thanks for your interest. This is a community-driven library for the Elegoo
4
- Centauri Carbon; contributions of every size are welcome.
5
-
6
- ## Dev setup
7
-
8
- ```sh
9
- git clone https://github.com/bjan/pycentauri
10
- cd pycentauri
11
- python -m venv .venv
12
- . .venv/bin/activate
13
- pip install -e ".[mcp,dev]"
14
- ```
15
-
16
- ## Running checks
17
-
18
- ```sh
19
- ruff check .
20
- ruff format --check .
21
- mypy src
22
- pytest
23
- ```
24
-
25
- ## Integration tests against a real printer
26
-
27
- Unit tests are fully offline. To exercise the client against a real Centauri
28
- Carbon on your LAN:
29
-
30
- ```sh
31
- PYCENTAURI_TEST_HOST=192.168.1.x pytest tests/integration
32
- ```
33
-
34
- These are skipped by default in CI.
35
-
36
- ## Protocol references
37
-
38
- The Centauri Carbon speaks Elegoo's SDCP v3 over a WebSocket on port 3030.
39
- Two projects helped decode the wire protocol:
40
-
41
- - [`ELEGOO-3D/elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) — the
42
- official C++ SDK; `src/lan/adapters/elegoo_fdm_cc/` is the authoritative
43
- reference for the original Centauri Carbon.
44
- - [`CentauriLink/Centauri-Link`](https://github.com/CentauriLink/Centauri-Link)
45
- — a Kivy desktop/mobile GUI; its `main.py` documents the SDCP envelope and
46
- subscription dance in detail.
47
-
48
- When adding a new command code, update `pycentauri/sdcp.py` `Cmd` with a
49
- comment linking to the upstream reference.
50
-
51
- ## Pull requests
52
-
53
- - Keep changes small and focused.
54
- - Add unit tests for any new parsing or envelope logic.
55
- - Run `ruff format` before submitting.
56
- - Update `CHANGELOG.md` under `[Unreleased]`.
@@ -1,22 +0,0 @@
1
- """Save a webcam snapshot to a JPEG file."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import sys
7
-
8
- from pycentauri import Printer
9
-
10
-
11
- async def main(host: str, out: str) -> None:
12
- async with await Printer.connect(host) as printer:
13
- jpeg = await printer.snapshot()
14
- with open(out, "wb") as f:
15
- f.write(jpeg)
16
- print(f"wrote {len(jpeg)} bytes to {out}")
17
-
18
-
19
- if __name__ == "__main__":
20
- host = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.209"
21
- out = sys.argv[2] if len(sys.argv) > 2 else "shot.jpg"
22
- asyncio.run(main(host, out))
@@ -1,26 +0,0 @@
1
- """Start a print of a file that is already on the printer.
2
-
3
- The library refuses write actions unless ``enable_control=True`` is passed
4
- explicitly. Run the printer's own screen or web UI to confirm the file is
5
- staged before you call this.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import asyncio
11
- import sys
12
-
13
- from pycentauri import Printer
14
-
15
-
16
- async def main(host: str, filename: str) -> None:
17
- async with await Printer.connect(host, enable_control=True) as printer:
18
- result = await printer.start_print(filename)
19
- print("start_print response:", result.inner)
20
-
21
-
22
- if __name__ == "__main__":
23
- if len(sys.argv) < 3:
24
- print("usage: python start_print.py <host> <filename>", file=sys.stderr)
25
- raise SystemExit(2)
26
- asyncio.run(main(sys.argv[1], sys.argv[2]))
@@ -1,27 +0,0 @@
1
- """Stream live status pushes from the printer until Ctrl-C."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import contextlib
7
- import sys
8
-
9
- from pycentauri import Printer
10
-
11
-
12
- async def main(host: str) -> None:
13
- async with await Printer.connect(host) as printer:
14
- async for st in printer.watch():
15
- print(
16
- f"state={st.print_status or '-':>3} "
17
- f"progress={st.progress or 0:>3}% "
18
- f"nozzle={st.temp_nozzle or 0:5.1f}°C "
19
- f"bed={st.temp_bed or 0:5.1f}°C "
20
- f"file={st.filename or '-'}"
21
- )
22
-
23
-
24
- if __name__ == "__main__":
25
- host = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.209"
26
- with contextlib.suppress(KeyboardInterrupt):
27
- asyncio.run(main(host))
File without changes
File without changes