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.
- {pycentauri-0.2.0 → pycentauri-0.3.0}/CHANGELOG.md +18 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/PKG-INFO +8 -3
- {pycentauri-0.2.0 → pycentauri-0.3.0}/README.md +7 -2
- {pycentauri-0.2.0 → pycentauri-0.3.0}/pyproject.toml +4 -1
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/server.py +76 -12
- pycentauri-0.3.0/src/pycentauri/web/app.js +179 -0
- pycentauri-0.3.0/src/pycentauri/web/index.html +85 -0
- pycentauri-0.3.0/src/pycentauri/web/styles.css +257 -0
- pycentauri-0.3.0/tests/integration/__init__.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_server.py +6 -1
- pycentauri-0.2.0/.github/workflows/ci.yml +0 -42
- pycentauri-0.2.0/.github/workflows/publish.yml +0 -48
- pycentauri-0.2.0/CONTRIBUTING.md +0 -56
- pycentauri-0.2.0/examples/snapshot.py +0 -22
- pycentauri-0.2.0/examples/start_print.py +0 -26
- pycentauri-0.2.0/examples/status_watch.py +0 -27
- {pycentauri-0.2.0 → pycentauri-0.3.0}/.gitignore +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/LICENSE +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/cli.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/client.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/models.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.2.0/tests → pycentauri-0.3.0/src/pycentauri/web}/__init__.py +0 -0
- {pycentauri-0.2.0/tests/integration → pycentauri-0.3.0/tests}/__init__.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_client.py +0 -0
- {pycentauri-0.2.0 → pycentauri-0.3.0}/tests/test_discovery.py +0 -0
- {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.
|
|
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
|
|
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` | `/` |
|
|
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
|
|
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` | `/` |
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
pycentauri-0.2.0/CONTRIBUTING.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|