pycentauri 0.2.0__tar.gz → 0.3.1__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.1}/CHANGELOG.md +33 -0
  2. {pycentauri-0.2.0 → pycentauri-0.3.1}/PKG-INFO +8 -3
  3. {pycentauri-0.2.0 → pycentauri-0.3.1}/README.md +7 -2
  4. {pycentauri-0.2.0 → pycentauri-0.3.1}/pyproject.toml +4 -1
  5. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/server.py +76 -12
  7. pycentauri-0.3.1/src/pycentauri/web/app.js +282 -0
  8. pycentauri-0.3.1/src/pycentauri/web/index.html +210 -0
  9. pycentauri-0.3.1/src/pycentauri/web/styles.css +744 -0
  10. pycentauri-0.3.1/tests/integration/__init__.py +0 -0
  11. {pycentauri-0.2.0 → pycentauri-0.3.1}/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.1}/.gitignore +0 -0
  19. {pycentauri-0.2.0 → pycentauri-0.3.1}/LICENSE +0 -0
  20. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/camera.py +0 -0
  21. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/cli.py +0 -0
  22. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/client.py +0 -0
  23. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/discovery.py +0 -0
  24. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/mcp/__init__.py +0 -0
  25. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/mcp/__main__.py +0 -0
  26. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/mcp/server.py +0 -0
  27. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/models.py +0 -0
  28. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/py.typed +0 -0
  29. {pycentauri-0.2.0 → pycentauri-0.3.1}/src/pycentauri/sdcp.py +0 -0
  30. {pycentauri-0.2.0/tests → pycentauri-0.3.1/src/pycentauri/web}/__init__.py +0 -0
  31. {pycentauri-0.2.0/tests/integration → pycentauri-0.3.1/tests}/__init__.py +0 -0
  32. {pycentauri-0.2.0 → pycentauri-0.3.1}/tests/test_client.py +0 -0
  33. {pycentauri-0.2.0 → pycentauri-0.3.1}/tests/test_discovery.py +0 -0
  34. {pycentauri-0.2.0 → pycentauri-0.3.1}/tests/test_sdcp.py +0 -0
@@ -6,6 +6,39 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] - 2026-04-22
10
+
11
+ ### Changed
12
+ - **Web UI redesign.** Moved from the generic dark-dashboard look to an
13
+ industrial instrument-panel aesthetic — amber-on-near-black, viewfinder
14
+ corner brackets and a faint crosshair over the webcam, ruler-style
15
+ progress bar with diagonal-hatch fill, tabular-numeric temperature
16
+ gauges with per-channel setpoint bars, kinematics readout with X/Y/Z
17
+ cells and fan/Z-offset/mainboard telemetry, keyboard-key-style F1/F2/F3
18
+ control buttons (with real keyboard shortcuts). Typography: Space Mono
19
+ for display numerals, IBM Plex Mono for data, IBM Plex Sans Condensed
20
+ for SCADA-style labels.
21
+ - Webcam MJPEG element now auto-recovers if the stream stalls for 15s.
22
+ - Added a favicon so browsers stop 404-ing `/favicon.ico`.
23
+
24
+ ## [0.3.0] - 2026-04-22
25
+
26
+ ### Added
27
+ - **Web UI** at `GET /ui/` (and `GET /` redirects there). Single static
28
+ dashboard bundled in the wheel — no build step, no framework. Shows
29
+ live webcam (MJPEG), progress bar, layer counter, temperature cards,
30
+ position, fans, and Z-offset. Pause / Resume / Stop buttons auto-appear
31
+ when the server is launched with `--enable-control`.
32
+ - `GET /stream` — MJPEG proxy through the API server to the printer's
33
+ `:3031/video`. Browsers render it directly in an `<img>` tag.
34
+ - `GET /api/info` — JSON health endpoint, matching what `GET /` used to
35
+ return before it was repurposed to redirect to `/ui/`.
36
+
37
+ ### Changed
38
+ - `GET /` now redirects to `/ui/` (307). If the web assets aren't found
39
+ in the wheel (custom build, etc.), `/` falls back to a minimal JSON
40
+ health response so scripted clients don't break.
41
+
9
42
  ## [0.2.0] - 2026-04-22
10
43
 
11
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.2.0
3
+ Version: 0.3.1
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.1"
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.1"
@@ -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,282 @@
1
+ // pycentauri web console
2
+ // Consumes /api/info, /status, /events/status (SSE), /attributes.
3
+ // Posts to /print/{pause,resume,stop} when --enable-control is on.
4
+
5
+ const $ = (id) => document.getElementById(id);
6
+
7
+ // PrintInfo.Status → display label + semantic class.
8
+ // Codes decoded from CentauriLink + official elegoo-link + live observation.
9
+ const PRINT_STATUS = {
10
+ 0: { label: "IDLE", cls: "state-idle" },
11
+ 1: { label: "HOMING", cls: "state-printing" },
12
+ 2: { label: "DROPPING", cls: "state-printing" },
13
+ 3: { label: "EXPOSING", cls: "state-printing" },
14
+ 4: { label: "LIFTING", cls: "state-printing" },
15
+ 5: { label: "PAUSING", cls: "state-paused" },
16
+ 6: { label: "PAUSED", cls: "state-paused" },
17
+ 7: { label: "STOPPING", cls: "state-stopped" },
18
+ 8: { label: "STOPPED", cls: "state-stopped" },
19
+ 9: { label: "COMPLETED", cls: "state-completed" },
20
+ 10: { label: "CHECKING", cls: "state-printing" },
21
+ 12: { label: "PREPARING", cls: "state-printing" },
22
+ 13: { label: "PRINTING", cls: "state-printing" },
23
+ 18: { label: "RESUMED", cls: "state-printing" },
24
+ };
25
+
26
+ function fmtSeconds(s) {
27
+ if (s == null || Number.isNaN(+s)) return "—";
28
+ s = Math.max(0, Math.round(+s));
29
+ const h = Math.floor(s / 3600);
30
+ const m = Math.floor((s % 3600) / 60);
31
+ const sec = s % 60;
32
+ if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
33
+ if (m > 0) return `${m}m ${sec.toString().padStart(2, "0")}s`;
34
+ return `${sec}s`;
35
+ }
36
+
37
+ function fmtTemp(v, padTo = "---.-") {
38
+ if (v == null || Number.isNaN(+v)) return padTo;
39
+ return (+v).toFixed(1);
40
+ }
41
+
42
+ function fmtCoord(v) {
43
+ if (v == null || Number.isNaN(+v)) return "---.--";
44
+ return (+v).toFixed(2).padStart(6, " ");
45
+ }
46
+
47
+ function tempPct(actual, target) {
48
+ if (actual == null || target == null || target <= 0) return 0;
49
+ return Math.min(100, Math.max(0, (actual / target) * 100));
50
+ }
51
+
52
+ function setStatusPill(kind, label) {
53
+ const pill = $("status-pill");
54
+ pill.classList.remove("warn", "err");
55
+ if (kind === "warn") pill.classList.add("warn");
56
+ if (kind === "err") pill.classList.add("err");
57
+ $("status-text").textContent = label;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+
62
+ async function loadInfo() {
63
+ try {
64
+ const r = await fetch("/api/info");
65
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
66
+ const info = await r.json();
67
+
68
+ $("version").textContent = info.version || "—";
69
+ $("host").textContent = info.printer_host || "—";
70
+ $("mainboard").textContent = info.mainboard_id || "—";
71
+ $("mainboard").title = info.mainboard_id || "";
72
+
73
+ if (info.enable_control) $("controls").hidden = false;
74
+
75
+ try {
76
+ const attrs = await (await fetch("/attributes")).json();
77
+ $("printer-name").textContent = (attrs.machine_name || attrs.name || "Centauri Carbon").toUpperCase();
78
+ $("firmware").textContent = attrs.firmware_version ? `FW ${attrs.firmware_version}` : "";
79
+ } catch (_) { /* firmware may not push in idle */ }
80
+
81
+ if (info.connected) {
82
+ setStatusPill("ok", "LINK OK");
83
+ } else {
84
+ setStatusPill("warn", "LINK DOWN");
85
+ }
86
+ } catch (e) {
87
+ setStatusPill("err", "SRV OFFLINE");
88
+ console.warn("loadInfo failed:", e);
89
+ }
90
+ }
91
+
92
+ function renderStatus(raw) {
93
+ const pi = raw.PrintInfo || {};
94
+ const pstatus = pi.Status;
95
+ const filename = pi.Filename;
96
+
97
+ // Job name
98
+ $("filename").textContent = filename || "— · AWAITING ·";
99
+
100
+ // Progress
101
+ const progress = pi.Progress;
102
+ const pct = (progress != null) ? Math.max(0, Math.min(100, +progress)) : 0;
103
+ $("progress-bar").style.width = pct + "%";
104
+ $("progress-label").textContent = (progress != null) ? `${pct}%` : "—";
105
+
106
+ // State word + code
107
+ const info = PRINT_STATUS[pstatus];
108
+ const sw = $("print-status");
109
+ const sc = $("print-status-code");
110
+ sw.classList.remove("state-idle", "state-printing", "state-paused", "state-stopped", "state-completed");
111
+ if (info) {
112
+ sw.textContent = info.label;
113
+ sw.classList.add(info.cls);
114
+ } else if (pstatus != null) {
115
+ sw.textContent = `CODE·${pstatus}`;
116
+ } else {
117
+ sw.textContent = "—";
118
+ }
119
+ sc.textContent = (pstatus != null) ? String(pstatus).padStart(2, "0") : "--";
120
+
121
+ // Layer
122
+ $("layer").textContent = (pi.CurrentLayer != null && pi.TotalLayer)
123
+ ? `${pi.CurrentLayer} / ${pi.TotalLayer}`
124
+ : "—";
125
+
126
+ // Times (CurrentTicks / TotalTicks are seconds on CC firmware)
127
+ const elapsed = pi.CurrentTicks;
128
+ const total = pi.TotalTicks;
129
+ $("elapsed").textContent = fmtSeconds(elapsed);
130
+ $("remaining").textContent = (total != null && elapsed != null)
131
+ ? fmtSeconds(total - elapsed) : "—";
132
+
133
+ // Speed %
134
+ $("speed").textContent = (pi.PrintSpeedPct != null) ? `${pi.PrintSpeedPct}%` : "—";
135
+
136
+ // Temperatures
137
+ const noz = raw.TempOfNozzle, nozT = raw.TempTargetNozzle;
138
+ const bed = raw.TempOfHotbed, bedT = raw.TempTargetHotbed;
139
+ const cham = raw.TempOfBox, chamT = raw.TempTargetBox;
140
+ $("t-nozzle").textContent = fmtTemp(noz);
141
+ $("t-nozzle-target").textContent = (nozT != null) ? String(Math.round(+nozT)) : "---";
142
+ $("t-nozzle-bar").style.width = tempPct(noz, nozT) + "%";
143
+ $("t-bed").textContent = fmtTemp(bed);
144
+ $("t-bed-target").textContent = (bedT != null) ? String(Math.round(+bedT)) : "---";
145
+ $("t-bed-bar").style.width = tempPct(bed, bedT) + "%";
146
+ $("t-chamber").textContent = fmtTemp(cham);
147
+ $("t-chamber-target").textContent = (chamT != null) ? String(Math.round(+chamT)) : "---";
148
+ // Chamber often targets 0; show the actual vs a reference of 50°C so the bar conveys warmth.
149
+ $("t-chamber-bar").style.width = Math.min(100, Math.max(0, (+(cham ?? 0) / 50) * 100)) + "%";
150
+
151
+ // Coords "x,y,z"
152
+ if (typeof raw.CurrenCoord === "string") {
153
+ const [x, y, z] = raw.CurrenCoord.split(",");
154
+ $("pos-x").textContent = fmtCoord(x);
155
+ $("pos-y").textContent = fmtCoord(y);
156
+ $("pos-z").textContent = fmtCoord(z);
157
+ }
158
+
159
+ // Z offset
160
+ if (raw.ZOffset != null) $("z-offset").textContent = (+raw.ZOffset).toFixed(3);
161
+
162
+ // Fans
163
+ const fans = raw.CurrentFanSpeed || {};
164
+ const setFan = (id, key) => {
165
+ const v = fans[key];
166
+ $(id).textContent = (v != null) ? `${v}%` : "—";
167
+ };
168
+ setFan("fan-model", "ModelFan");
169
+ setFan("fan-aux", "AuxiliaryFan");
170
+ setFan("fan-box", "BoxFan");
171
+
172
+ setStatusPill("ok", "LINK OK");
173
+ }
174
+
175
+ async function pollOnce() {
176
+ try {
177
+ const r = await fetch("/status");
178
+ if (!r.ok) throw new Error("HTTP " + r.status);
179
+ const body = await r.json();
180
+ if (body.raw) renderStatus(body.raw);
181
+ } catch (e) {
182
+ setStatusPill("warn", "LINK WAIT");
183
+ console.warn("poll failed:", e);
184
+ }
185
+ }
186
+
187
+ function connectSSE() {
188
+ let src;
189
+ try { src = new EventSource("/events/status"); }
190
+ catch (_) {
191
+ setStatusPill("warn", "SSE N/A");
192
+ setInterval(pollOnce, 3000);
193
+ return;
194
+ }
195
+ src.addEventListener("status", (ev) => {
196
+ try { renderStatus(JSON.parse(ev.data)); } catch (_) {}
197
+ });
198
+ src.onerror = () => {
199
+ setStatusPill("warn", "LINK WAIT");
200
+ setTimeout(pollOnce, 2000);
201
+ };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Controls
206
+
207
+ function setMsg(text, kind) {
208
+ const el = $("ctrl-msg");
209
+ el.classList.remove("ok", "warn", "err");
210
+ if (kind) el.classList.add(kind);
211
+ el.textContent = text;
212
+ }
213
+
214
+ async function doAction(label, path, confirmMsg) {
215
+ if (confirmMsg && !confirm(confirmMsg)) return;
216
+ setMsg(`» ${label}…`);
217
+ const btns = document.querySelectorAll(".controls button");
218
+ for (const b of btns) b.disabled = true;
219
+ try {
220
+ const r = await fetch(path, { method: "POST" });
221
+ if (!r.ok) throw new Error(`HTTP ${r.status} — ${await r.text()}`);
222
+ setMsg(`✓ ${label} acknowledged`, "ok");
223
+ } catch (e) {
224
+ setMsg(`✗ ${label}: ${e.message}`, "err");
225
+ } finally {
226
+ setTimeout(() => {
227
+ for (const b of btns) b.disabled = false;
228
+ }, 800);
229
+ }
230
+ }
231
+
232
+ function wireControls() {
233
+ $("btn-pause")?.addEventListener("click", () =>
234
+ doAction("PAUSE", "/print/pause", "Pause the current print?")
235
+ );
236
+ $("btn-resume")?.addEventListener("click", () =>
237
+ doAction("RESUME", "/print/resume")
238
+ );
239
+ $("btn-stop")?.addEventListener("click", () =>
240
+ doAction("STOP", "/print/stop",
241
+ "Stop the current print?\n\nThis cannot be undone.")
242
+ );
243
+
244
+ // Keyboard shortcuts when controls are visible.
245
+ document.addEventListener("keydown", (ev) => {
246
+ if ($("controls").hidden) return;
247
+ if (ev.target && /^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) return;
248
+ if (ev.key === "F1") { ev.preventDefault(); $("btn-pause").click(); }
249
+ if (ev.key === "F2") { ev.preventDefault(); $("btn-resume").click(); }
250
+ if (ev.key === "F3") { ev.preventDefault(); $("btn-stop").click(); }
251
+ });
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Webcam resilience — if the MJPEG stream stalls, reload it.
256
+
257
+ function wireWebcamKeepalive() {
258
+ const img = $("webcam");
259
+ let lastChange = Date.now();
260
+ let loads = 0;
261
+ img.addEventListener("load", () => { lastChange = Date.now(); loads++; });
262
+ img.addEventListener("error", () => {
263
+ setTimeout(() => { img.src = `/stream?t=${Date.now()}`; }, 2000);
264
+ });
265
+ setInterval(() => {
266
+ if (Date.now() - lastChange > 15000) {
267
+ img.src = `/stream?t=${Date.now()}`;
268
+ lastChange = Date.now();
269
+ }
270
+ }, 5000);
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+
275
+ (async function main() {
276
+ wireControls();
277
+ wireWebcamKeepalive();
278
+ await loadInfo();
279
+ await pollOnce();
280
+ connectSSE();
281
+ setInterval(loadInfo, 60000);
282
+ })();