pycentauri 0.4.1__tar.gz → 0.5.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 (30) hide show
  1. {pycentauri-0.4.1 → pycentauri-0.5.0}/CHANGELOG.md +82 -0
  2. {pycentauri-0.4.1 → pycentauri-0.5.0}/PKG-INFO +1 -1
  3. {pycentauri-0.4.1 → pycentauri-0.5.0}/pyproject.toml +1 -1
  4. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/__init__.py +1 -1
  5. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/client.py +105 -1
  6. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/models.py +21 -4
  7. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/sdcp.py +10 -0
  8. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/server.py +68 -0
  9. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/web/app.js +244 -20
  10. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/web/index.html +79 -0
  11. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/web/styles.css +127 -6
  12. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/test_client.py +63 -0
  13. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/test_server.py +53 -0
  14. {pycentauri-0.4.1 → pycentauri-0.5.0}/.gitignore +0 -0
  15. {pycentauri-0.4.1 → pycentauri-0.5.0}/LICENSE +0 -0
  16. {pycentauri-0.4.1 → pycentauri-0.5.0}/README.md +0 -0
  17. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/camera.py +0 -0
  18. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/cli.py +0 -0
  19. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/discovery.py +0 -0
  20. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/mcp/__init__.py +0 -0
  21. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/mcp/__main__.py +0 -0
  22. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/mcp/server.py +0 -0
  23. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/py.typed +0 -0
  24. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/rtsp.py +0 -0
  25. {pycentauri-0.4.1 → pycentauri-0.5.0}/src/pycentauri/web/__init__.py +0 -0
  26. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/__init__.py +0 -0
  27. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/integration/__init__.py +0 -0
  28. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/test_discovery.py +0 -0
  29. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/test_rtsp.py +0 -0
  30. {pycentauri-0.4.1 → pycentauri-0.5.0}/tests/test_sdcp.py +0 -0
@@ -6,6 +6,88 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-06-17
10
+
11
+ ### Added
12
+ - **Live print-parameter adjust (`Cmd 403` family).** All three payload
13
+ variants confirmed working against firmware V0.3.0-o:
14
+ - `Printer.set_print_speed(mode)` — sets the printer's speed mode.
15
+ Accepts a name (`"silent"`, `"balanced"`, `"sport"`, `"ludicrous"`)
16
+ or its canonical `PrintSpeedPct` value (`50`, `100`, `130`, `160`).
17
+ Only those four values are accepted by the firmware — arbitrary
18
+ intermediate percentages return `Ack=0` but are silently dropped,
19
+ and the mode change only takes effect while a print is actively
20
+ running. Names + values lifted directly from the printer's own
21
+ SPA i18n file at `/app/resources/www/assets/i18n/network-en.json`.
22
+ - `Printer.set_fan_speed(model=, auxiliary=, chamber=)` — set any
23
+ subset of the model / auxiliary / chamber fan (0..100% each).
24
+ - `Printer.set_temperatures(nozzle=, bed=, chamber=)` — heater
25
+ targets with safety caps (nozzle 0..300, bed 0..110, chamber 0..60).
26
+ `0` turns the heater off.
27
+ - HTTP endpoints (only when launched with `--enable-control`):
28
+ `POST /print/speed`, `POST /print/fan`, `POST /print/temperature`.
29
+ Speed body: `{"mode": "silent|balanced|sport|ludicrous"}` (or the
30
+ integer equivalent).
31
+ - Web UI `ADJUST` panel with three sections: a 4-button speed-mode
32
+ selector (the active mode pulses amber based on live status), per-fan
33
+ rows (model / aux / chamber, 0–100%), and per-heater rows with
34
+ per-row APPLY buttons and confirm prompts on high temps
35
+ (nozzle > 240 °C, bed > 85 °C).
36
+ - Web UI auto-hydrates the fan/heater sliders + inputs from each status
37
+ push, so they always start at the printer's actual live values
38
+ instead of zero. Controls that are currently focused are skipped so
39
+ the live update doesn't yank a value out from under a drag/type.
40
+ - Adaptive backup poll: 2-second cadence while the printer is actively
41
+ printing, 10-second cadence when idle/paused/done/errored. Switches
42
+ immediately on state transition. SSE remains the primary update
43
+ path; the poll is a safety net for Firefox's silent SSE stalls.
44
+ - `sdcp.Cmd.CHANGE_PRINT_PARAMS = 403` enum entry, plus the previously-
45
+ unenumerated `GET_FILE_LIST = 258` and `GET_PRINT_HISTORY = 320` for
46
+ reference (no client methods yet — exposing those is queued for a
47
+ later release).
48
+ - `Printer.PRINT_SPEED_MODES` class-level map exposing the canonical
49
+ `{mode_name: PrintSpeedPct}` table for callers that want to render
50
+ their own mode picker.
51
+
52
+ ### Fixed
53
+ - **Footer rail no longer overlaps the ADJUST panel on tall pages.**
54
+ Removing the `min-height: 0` on `.console` lets the grid grow with
55
+ its content (instead of being constrained to the body's flex slot),
56
+ so the bottom rail reflows below the panel instead of hovering over
57
+ it.
58
+ - **Web UI status now stays fresh in Firefox.** SSE remains the
59
+ primary push path, but the new adaptive backup poll runs in parallel
60
+ — Firefox occasionally drops the SSE stream silently and previously
61
+ required a manual refresh to update; the poll keeps the progress
62
+ bar, temps, fan readings, and ADJUST hydration live regardless.
63
+
64
+ ### Documentation
65
+ - `docs/PROTOCOL.md` promotes Cmd 403 (all three payload variants) to
66
+ the confirmed-working table, documents the four canonical
67
+ `PrintSpeedPct` values, captures the printer's internal architecture
68
+ as observed via SSH on OpenCentauri V0.3.0-o (the `app` binary
69
+ embeds a Klipper-derived motion stack rather than running it as a
70
+ separate process, which explains why an `app` crash kills any
71
+ active print), adds the log-line signal table for filament cycles
72
+ (`feed state change`, `M729`, etc.), and the recorded touchscreen
73
+ tap-event sequence for the Goodix `gt9xxnew_ts` driver.
74
+
75
+ ## [0.4.2] - 2026-04-22
76
+
77
+ ### Changed
78
+ - Complete `PrintInfo.Status` code table, lifted verbatim from the
79
+ authoritative enum in Elegoo's `elegoo-link` SDK. Previously the web
80
+ UI (and the `pycentauri.models.PrintStatus` constants) only covered
81
+ about half the codes — which meant users hit `CODE·20` literally
82
+ during the routine PREHEAT-DONE transition between prints, and
83
+ similarly for 11, 14, 15, 16, 17, 19, 21, 22, 23–26.
84
+ - Added a distinct visual class for the ERROR state (code 14) in the
85
+ web UI so it renders in red with a soft glow instead of falling into
86
+ the generic "unknown" bucket.
87
+ - Renamed a couple of previously-incorrect labels: code 12 was shown as
88
+ "PREPARING", it's actually "RESUMING"; code 18 was shown as
89
+ "RESUMED", it's actually "PRINT START" — both per the SDK.
90
+
9
91
  ## [0.4.1] - 2026-04-22
10
92
 
11
93
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Local-network toolkit for Elegoo Centauri Carbon 3D printers: async Python client, CLI, MCP server, REST/SSE HTTP server, web UI, and RTSP bridge
5
5
  Project-URL: Homepage, https://github.com/bjan/pycentauri
6
6
  Project-URL: Repository, https://github.com/bjan/pycentauri
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycentauri"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "Local-network toolkit for Elegoo Centauri Carbon 3D printers: async Python client, CLI, MCP server, REST/SSE HTTP server, web UI, and RTSP bridge"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -25,4 +25,4 @@ __all__ = [
25
25
  "discover",
26
26
  ]
27
27
 
28
- __version__ = "0.4.1"
28
+ __version__ = "0.5.0"
@@ -14,7 +14,7 @@ import contextlib
14
14
  import logging
15
15
  from collections.abc import AsyncIterator
16
16
  from types import TracebackType
17
- from typing import Any
17
+ from typing import Any, ClassVar
18
18
 
19
19
  import websockets
20
20
  from typing_extensions import Self
@@ -249,6 +249,110 @@ class Printer:
249
249
  mid = await self.wait_for_mainboard()
250
250
  return await self._request(sdcp.Cmd.STOP_PRINT, None, mid)
251
251
 
252
+ #: Canonical ``PrintSpeedPct`` values the Centauri Carbon firmware
253
+ #: actually responds to. Confirmed by reading the printer's own SPA
254
+ #: i18n file at ``/app/resources/www/assets/i18n/network-en.json``:
255
+ #: the speed control there is a discrete 4-option list, not a slider.
256
+ #: Arbitrary intermediate values (e.g. 120, 145, 200) reach the
257
+ #: firmware (``Ack=0``) but are silently dropped.
258
+ PRINT_SPEED_MODES: ClassVar[dict[str, int]] = {
259
+ "silent": 50,
260
+ "balanced": 100,
261
+ "sport": 130,
262
+ "ludicrous": 160,
263
+ }
264
+
265
+ async def set_print_speed(self, mode: str | int) -> sdcp.ParsedMessage:
266
+ """Set the print-speed mode.
267
+
268
+ Pass either a mode name (``"silent"``, ``"balanced"``, ``"sport"``,
269
+ ``"ludicrous"``) or its corresponding ``PrintSpeedPct`` value
270
+ (50, 100, 130, 160). Only those four values are accepted by the
271
+ firmware — anything else returns ``Ack=0`` but does nothing.
272
+
273
+ Only takes effect while a print is actively running; sending it
274
+ at idle is a no-op even with a canonical value.
275
+ """
276
+ self._require_control("set_print_speed")
277
+ if isinstance(mode, str):
278
+ key = mode.strip().lower()
279
+ if key not in self.PRINT_SPEED_MODES:
280
+ raise ValueError(
281
+ f"unknown print mode {mode!r}; expected one of {sorted(self.PRINT_SPEED_MODES)}"
282
+ )
283
+ value = self.PRINT_SPEED_MODES[key]
284
+ else:
285
+ value = int(mode)
286
+ if value not in self.PRINT_SPEED_MODES.values():
287
+ raise ValueError(
288
+ f"PrintSpeedPct {value} not in firmware-accepted "
289
+ f"set {sorted(self.PRINT_SPEED_MODES.values())}"
290
+ )
291
+ mid = await self.wait_for_mainboard()
292
+ return await self._request(sdcp.Cmd.CHANGE_PRINT_PARAMS, {"PrintSpeedPct": value}, mid)
293
+
294
+ async def set_fan_speed(
295
+ self,
296
+ *,
297
+ model: int | None = None,
298
+ auxiliary: int | None = None,
299
+ chamber: int | None = None,
300
+ ) -> sdcp.ParsedMessage:
301
+ """Set fan speeds (each 0..100, optional; only sets the ones supplied).
302
+
303
+ ``chamber`` is the chamber/box fan (firmware key ``BoxFan``). Verified
304
+ working on V0.3.0-o via ``Cmd 403 {"TargetFanSpeed": {...}}``.
305
+ """
306
+ self._require_control("set_fan_speed")
307
+ speeds: dict[str, int] = {}
308
+ for label, key, val in (
309
+ ("model", "ModelFan", model),
310
+ ("auxiliary", "AuxiliaryFan", auxiliary),
311
+ ("chamber", "BoxFan", chamber),
312
+ ):
313
+ if val is None:
314
+ continue
315
+ if not 0 <= int(val) <= 100:
316
+ raise ValueError(f"fan {label} speed {val} must be 0..100")
317
+ speeds[key] = int(val)
318
+ if not speeds:
319
+ raise ValueError("at least one fan speed must be specified")
320
+ mid = await self.wait_for_mainboard()
321
+ return await self._request(sdcp.Cmd.CHANGE_PRINT_PARAMS, {"TargetFanSpeed": speeds}, mid)
322
+
323
+ async def set_temperatures(
324
+ self,
325
+ *,
326
+ nozzle: float | None = None,
327
+ bed: float | None = None,
328
+ chamber: float | None = None,
329
+ ) -> sdcp.ParsedMessage:
330
+ """Set heater target temperatures in °C (each optional).
331
+
332
+ Passing 0 turns the corresponding heater off. Verified working on
333
+ V0.3.0-o via ``Cmd 403 {"TempTargetNozzle/Hotbed/Box": N}``.
334
+
335
+ Safety caps: nozzle 0..300, bed 0..110, chamber 0..60. Pycentauri
336
+ won't issue values outside those bounds even if the firmware
337
+ would accept them.
338
+ """
339
+ self._require_control("set_temperatures")
340
+ targets: dict[str, float] = {}
341
+ for label, key, val, lo, hi in (
342
+ ("nozzle", "TempTargetNozzle", nozzle, 0, 300),
343
+ ("bed", "TempTargetHotbed", bed, 0, 110),
344
+ ("chamber", "TempTargetBox", chamber, 0, 60),
345
+ ):
346
+ if val is None:
347
+ continue
348
+ if not lo <= float(val) <= hi:
349
+ raise ValueError(f"{label} target {val}°C out of safe range {lo}..{hi}")
350
+ targets[key] = float(val)
351
+ if not targets:
352
+ raise ValueError("at least one temperature target must be specified")
353
+ mid = await self.wait_for_mainboard()
354
+ return await self._request(sdcp.Cmd.CHANGE_PRINT_PARAMS, targets, mid)
355
+
252
356
  # --- lifecycle -------------------------------------------------------------
253
357
 
254
358
  async def close(self) -> None:
@@ -178,10 +178,13 @@ class Status(BaseModel):
178
178
 
179
179
 
180
180
  class PrintStatus:
181
- """Known ``PrintInfo.Status`` codes.
181
+ """``PrintInfo.Status`` codes from the official Elegoo SDK.
182
182
 
183
- Decoded from CentauriLink and the official SDK; not all codes are observed
184
- on every firmware. Treat unknown codes as opaque.
183
+ Sourced verbatim from
184
+ ``src/lan/adapters/elegoo_fdm_cc/elegoo_fdm_cc_message_adapter.cpp``
185
+ in the ``ELEGOO-3D/elegoo-link`` repository. Codes in the 2-4 and 23-26
186
+ ranges are resin-printer / LCD-specific and typically aren't surfaced
187
+ by the Centauri Carbon, but are kept for forward-compatibility.
185
188
  """
186
189
 
187
190
  IDLE = 0
@@ -195,8 +198,22 @@ class PrintStatus:
195
198
  STOPPED = 8
196
199
  COMPLETED = 9
197
200
  FILE_CHECKING = 10
198
- PREPARING = 12
201
+ PRINTER_CHECKING = 11
202
+ RESUMING = 12
199
203
  PRINTING = 13
204
+ ERROR = 14
205
+ AUTO_LEVELING = 15
206
+ PREHEATING = 16
207
+ RESONANCE_TESTING = 17
208
+ PRINT_START = 18
209
+ AUTO_LEVELING_COMPLETED = 19
210
+ PREHEATING_COMPLETED = 20
211
+ HOMING_COMPLETED = 21
212
+ RESONANCE_TESTING_COMPLETED = 22
213
+ AUTO_FEEDING = 23
214
+ UNLOADING = 24
215
+ UNLOADING_ABNORMAL = 25
216
+ UNLOADING_PAUSED = 26
200
217
 
201
218
 
202
219
  class Attributes(BaseModel):
@@ -35,7 +35,17 @@ class Cmd(IntEnum):
35
35
  PAUSE_PRINT = 129
36
36
  STOP_PRINT = 130
37
37
  RESUME_PRINT = 131
38
+ GET_FILE_LIST = 258
39
+ GET_PRINT_HISTORY = 320
38
40
  GET_CANVAS_STATUS = 324
41
+ # Cmd 403 is overloaded — the payload shape dispatches:
42
+ # {"PrintSpeedPct": N} → set print speed
43
+ # {"TargetFanSpeed": {"ModelFan":...,"BoxFan":...,"AuxiliaryFan":...}}
44
+ # → set fan speeds
45
+ # {"TempTargetNozzle": N, "TempTargetHotbed": N, "TempTargetBox": N}
46
+ # → set heater targets
47
+ # Confirmed live against firmware V0.3.0-o on 2026-06-03.
48
+ CHANGE_PRINT_PARAMS = 403
39
49
  SUBSCRIBE = 512
40
50
 
41
51
 
@@ -20,6 +20,8 @@ Surfaces (register with ``centauri server``):
20
20
  * ``GET /events/status`` — Server-Sent Events stream of live status pushes
21
21
  * ``POST /print/{start,pause,resume,stop}`` — only registered when the
22
22
  server is launched with ``--enable-control``.
23
+ * ``POST /print/{speed,fan,temperature}`` — runtime adjust of print speed,
24
+ fan speeds, and heater targets. Same ``--enable-control`` gate.
23
25
  """
24
26
 
25
27
  from __future__ import annotations
@@ -224,6 +226,30 @@ class StartPrintBody(BaseModel):
224
226
  timelapse: bool = False
225
227
 
226
228
 
229
+ class PrintSpeedBody(BaseModel):
230
+ """Print-speed mode. Firmware accepts only 4 discrete values."""
231
+
232
+ mode: str | int = Field(
233
+ ...,
234
+ description=(
235
+ "Mode name ('silent'|'balanced'|'sport'|'ludicrous') or "
236
+ "the corresponding PrintSpeedPct value (50|100|130|160)."
237
+ ),
238
+ )
239
+
240
+
241
+ class FanSpeedBody(BaseModel):
242
+ model: int | None = Field(None, ge=0, le=100)
243
+ auxiliary: int | None = Field(None, ge=0, le=100)
244
+ chamber: int | None = Field(None, ge=0, le=100)
245
+
246
+
247
+ class TemperatureBody(BaseModel):
248
+ nozzle: float | None = Field(None, ge=0, le=300)
249
+ bed: float | None = Field(None, ge=0, le=110)
250
+ chamber: float | None = Field(None, ge=0, le=60)
251
+
252
+
227
253
  # --- Dependency helpers -----------------------------------------------------
228
254
 
229
255
 
@@ -566,6 +592,48 @@ def create_app(
566
592
  raise HTTPException(status_code=502, detail=str(err)) from err
567
593
  return {"ok": True, "response": result.inner}
568
594
 
595
+ @app.post("/print/speed", tags=["control"])
596
+ async def set_speed(
597
+ body: PrintSpeedBody, manager: PrinterManager = Depends(require_control)
598
+ ) -> dict[str, Any]:
599
+ try:
600
+ result = await manager.printer.set_print_speed(body.mode)
601
+ except ValueError as err:
602
+ raise HTTPException(status_code=400, detail=str(err)) from err
603
+ except PrinterError as err:
604
+ raise HTTPException(status_code=502, detail=str(err)) from err
605
+ return {"ok": True, "response": result.inner}
606
+
607
+ @app.post("/print/fan", tags=["control"])
608
+ async def set_fan(
609
+ body: FanSpeedBody, manager: PrinterManager = Depends(require_control)
610
+ ) -> dict[str, Any]:
611
+ try:
612
+ result = await manager.printer.set_fan_speed(
613
+ model=body.model,
614
+ auxiliary=body.auxiliary,
615
+ chamber=body.chamber,
616
+ )
617
+ except ValueError as err:
618
+ raise HTTPException(status_code=400, detail=str(err)) from err
619
+ except PrinterError as err:
620
+ raise HTTPException(status_code=502, detail=str(err)) from err
621
+ return {"ok": True, "response": result.inner}
622
+
623
+ @app.post("/print/temperature", tags=["control"])
624
+ async def set_temperature(
625
+ body: TemperatureBody, manager: PrinterManager = Depends(require_control)
626
+ ) -> dict[str, Any]:
627
+ try:
628
+ result = await manager.printer.set_temperatures(
629
+ nozzle=body.nozzle, bed=body.bed, chamber=body.chamber
630
+ )
631
+ except ValueError as err:
632
+ raise HTTPException(status_code=400, detail=str(err)) from err
633
+ except PrinterError as err:
634
+ raise HTTPException(status_code=502, detail=str(err)) from err
635
+ return {"ok": True, "response": result.inner}
636
+
569
637
  return app
570
638
 
571
639
 
@@ -1,26 +1,43 @@
1
1
  // pycentauri web console
2
2
  // Consumes /api/info, /status, /events/status (SSE), /attributes.
3
- // Posts to /print/{pause,resume,stop} when --enable-control is on.
3
+ // Posts to /print/{pause,resume,stop} and /print/{speed,fan,temperature}
4
+ // when --enable-control is on.
4
5
 
5
6
  const $ = (id) => document.getElementById(id);
6
7
 
7
8
  // PrintInfo.Status → display label + semantic class.
8
- // Codes decoded from CentauriLink + official elegoo-link + live observation.
9
+ // Table is authoritative, lifted from Elegoo's own elegoo-link SDK
10
+ // (src/lan/adapters/elegoo_fdm_cc/elegoo_fdm_cc_message_adapter.cpp:33–62).
11
+ // Codes 2/3/4 and 23–26 are resin-printer / LCD-specific — kept here for
12
+ // completeness in case a future firmware surfaces them on the Carbon.
9
13
  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" },
14
+ 0: { label: "IDLE", cls: "state-idle" },
15
+ 1: { label: "HOMING", cls: "state-printing" },
16
+ 2: { label: "DROPPING", cls: "state-printing" },
17
+ 3: { label: "EXPOSING", cls: "state-printing" },
18
+ 4: { label: "LIFTING", cls: "state-printing" },
19
+ 5: { label: "PAUSING", cls: "state-paused" },
20
+ 6: { label: "PAUSED", cls: "state-paused" },
21
+ 7: { label: "STOPPING", cls: "state-stopped" },
22
+ 8: { label: "STOPPED", cls: "state-stopped" },
23
+ 9: { label: "COMPLETED", cls: "state-completed" },
24
+ 10: { label: "FILE CHECK", cls: "state-printing" },
25
+ 11: { label: "SELF CHECK", cls: "state-printing" },
26
+ 12: { label: "RESUMING", cls: "state-printing" },
27
+ 13: { label: "PRINTING", cls: "state-printing" },
28
+ 14: { label: "ERROR", cls: "state-err" },
29
+ 15: { label: "LEVELING", cls: "state-printing" },
30
+ 16: { label: "PREHEATING", cls: "state-printing" },
31
+ 17: { label: "RESONANCE", cls: "state-printing" },
32
+ 18: { label: "PRINT START", cls: "state-printing" },
33
+ 19: { label: "LEVEL DONE", cls: "state-printing" },
34
+ 20: { label: "PREHEAT DONE", cls: "state-printing" },
35
+ 21: { label: "HOMING DONE", cls: "state-printing" },
36
+ 22: { label: "RESONANCE OK", cls: "state-printing" },
37
+ 23: { label: "AUTO FEED", cls: "state-printing" },
38
+ 24: { label: "UNLOADING", cls: "state-printing" },
39
+ 25: { label: "UNLOAD ERR", cls: "state-err" },
40
+ 26: { label: "UNLOAD PAUSE", cls: "state-paused" },
24
41
  };
25
42
 
26
43
  function fmtSeconds(s) {
@@ -70,7 +87,10 @@ async function loadInfo() {
70
87
  $("mainboard").textContent = info.mainboard_id || "—";
71
88
  $("mainboard").title = info.mainboard_id || "";
72
89
 
73
- if (info.enable_control) $("controls").hidden = false;
90
+ if (info.enable_control) {
91
+ $("controls").hidden = false;
92
+ $("adjust").hidden = false;
93
+ }
74
94
 
75
95
  try {
76
96
  const attrs = await (await fetch("/attributes")).json();
@@ -107,7 +127,10 @@ function renderStatus(raw) {
107
127
  const info = PRINT_STATUS[pstatus];
108
128
  const sw = $("print-status");
109
129
  const sc = $("print-status-code");
110
- sw.classList.remove("state-idle", "state-printing", "state-paused", "state-stopped", "state-completed");
130
+ sw.classList.remove(
131
+ "state-idle", "state-printing", "state-paused",
132
+ "state-stopped", "state-completed", "state-err",
133
+ );
111
134
  if (info) {
112
135
  sw.textContent = info.label;
113
136
  sw.classList.add(info.cls);
@@ -132,6 +155,11 @@ function renderStatus(raw) {
132
155
 
133
156
  // Speed %
134
157
  $("speed").textContent = (pi.PrintSpeedPct != null) ? `${pi.PrintSpeedPct}%` : "—";
158
+ reflectSpeedMode(pi.PrintSpeedPct);
159
+
160
+ // Hydrate ADJUST controls from live values + retune poll cadence.
161
+ hydrateAdjust(raw);
162
+ noteStatusForPoll(pstatus);
135
163
 
136
164
  // Temperatures
137
165
  const noz = raw.TempOfNozzle, nozT = raw.TempTargetNozzle;
@@ -189,15 +217,17 @@ function connectSSE() {
189
217
  try { src = new EventSource("/events/status"); }
190
218
  catch (_) {
191
219
  setStatusPill("warn", "SSE N/A");
192
- setInterval(pollOnce, 3000);
193
220
  return;
194
221
  }
195
222
  src.addEventListener("status", (ev) => {
196
223
  try { renderStatus(JSON.parse(ev.data)); } catch (_) {}
197
224
  });
225
+ // SSE in Firefox is prone to silent stalls — the connection appears
226
+ // open but no events arrive. `onerror` fires once and the browser's
227
+ // auto-reconnect doesn't always kick in. The pollOnce safety net in
228
+ // main() compensates regardless; here we just surface the wait state.
198
229
  src.onerror = () => {
199
230
  setStatusPill("warn", "LINK WAIT");
200
- setTimeout(pollOnce, 2000);
201
231
  };
202
232
  }
203
233
 
@@ -251,6 +281,166 @@ function wireControls() {
251
281
  });
252
282
  }
253
283
 
284
+ // ---------------------------------------------------------------------------
285
+ // Live adjust (Cmd 403 family) — print speed, fan, temperature
286
+
287
+ function setAdjMsg(text, kind) {
288
+ const el = $("adj-msg");
289
+ el.classList.remove("ok", "warn", "err");
290
+ if (kind) el.classList.add(kind);
291
+ el.textContent = text || "";
292
+ }
293
+
294
+ // Two-way bind a slider/number pair sharing a base id (`${base}` for number,
295
+ // `${base}-slider` for slider). Returns a getter for the current value.
296
+ function bindPair(base) {
297
+ const num = $(`adj-${base}`);
298
+ const slider = $(`adj-${base}-slider`);
299
+ if (!num || !slider) return () => 0;
300
+ const clamp = (v) =>
301
+ Math.max(+num.min, Math.min(+num.max, Number.isFinite(+v) ? +v : 0));
302
+ slider.addEventListener("input", () => { num.value = String(clamp(slider.value)); });
303
+ num.addEventListener("input", () => { slider.value = String(clamp(num.value)); });
304
+ return () => clamp(num.value);
305
+ }
306
+
307
+ const ADJUST_TARGETS = {
308
+ "fan-model": {
309
+ label: "MODEL FAN",
310
+ path: "/print/fan",
311
+ build: (g) => ({ model: g("fan-model") }),
312
+ },
313
+ "fan-aux": {
314
+ label: "AUX FAN",
315
+ path: "/print/fan",
316
+ build: (g) => ({ auxiliary: g("fan-aux") }),
317
+ },
318
+ "fan-chamber": {
319
+ label: "CHAMBER FAN",
320
+ path: "/print/fan",
321
+ build: (g) => ({ chamber: g("fan-chamber") }),
322
+ },
323
+ "temp-nozzle": {
324
+ label: "NOZZLE TEMP",
325
+ path: "/print/temperature",
326
+ build: (g) => ({ nozzle: g("temp-nozzle") }),
327
+ confirm: (g) => g("temp-nozzle") > 240
328
+ ? `Set nozzle target to ${g("temp-nozzle")}°C ? High-temp; check filament rating.`
329
+ : null,
330
+ },
331
+ "temp-bed": {
332
+ label: "BED TEMP",
333
+ path: "/print/temperature",
334
+ build: (g) => ({ bed: g("temp-bed") }),
335
+ confirm: (g) => g("temp-bed") > 85
336
+ ? `Set bed target to ${g("temp-bed")}°C ? High-temp; check bed adhesive.`
337
+ : null,
338
+ },
339
+ "temp-chamber": {
340
+ label: "CHAMBER TEMP",
341
+ path: "/print/temperature",
342
+ build: (g) => ({ chamber: g("temp-chamber") }),
343
+ },
344
+ };
345
+
346
+ async function applyAdjust(target, getters) {
347
+ const spec = ADJUST_TARGETS[target];
348
+ if (!spec) return;
349
+ const get = (k) => getters[k] ? getters[k]() : 0;
350
+ const confirmMsg = spec.confirm ? spec.confirm(get) : null;
351
+ if (confirmMsg && !confirm(confirmMsg)) return;
352
+
353
+ setAdjMsg(`» ${spec.label}…`);
354
+ const btns = document.querySelectorAll("#adjust .kbd-btn");
355
+ for (const b of btns) b.disabled = true;
356
+
357
+ try {
358
+ const r = await fetch(spec.path, {
359
+ method: "POST",
360
+ headers: { "Content-Type": "application/json" },
361
+ body: JSON.stringify(spec.build(get)),
362
+ });
363
+ if (!r.ok) throw new Error(`HTTP ${r.status} — ${await r.text()}`);
364
+ setAdjMsg(`✓ ${spec.label} acknowledged`, "ok");
365
+ } catch (e) {
366
+ setAdjMsg(`✗ ${spec.label}: ${e.message}`, "err");
367
+ } finally {
368
+ setTimeout(() => { for (const b of btns) b.disabled = false; }, 600);
369
+ }
370
+ }
371
+
372
+ // Hydrate the ADJUST sliders/inputs from a status push. Skips any control
373
+ // that currently has focus (so a live SSE update doesn't yank a value out
374
+ // from under the user mid-drag or mid-type).
375
+ function hydrateAdjust(raw) {
376
+ if (!$("adjust")) return;
377
+ const fans = raw.CurrentFanSpeed || {};
378
+ const targets = [
379
+ ["fan-model", fans.ModelFan],
380
+ ["fan-aux", fans.AuxiliaryFan],
381
+ ["fan-chamber", fans.BoxFan],
382
+ ["temp-nozzle", raw.TempTargetNozzle],
383
+ ["temp-bed", raw.TempTargetHotbed],
384
+ ["temp-chamber", raw.TempTargetBox],
385
+ ];
386
+ for (const [base, v] of targets) {
387
+ if (v == null) continue;
388
+ const num = $(`adj-${base}`);
389
+ const slider = $(`adj-${base}-slider`);
390
+ if (!num || !slider) continue;
391
+ if (document.activeElement === num || document.activeElement === slider) continue;
392
+ const rounded = String(Math.round(+v));
393
+ num.value = rounded;
394
+ slider.value = rounded;
395
+ }
396
+ }
397
+
398
+ async function applySpeedMode(mode) {
399
+ setAdjMsg(`» SPEED MODE → ${mode.toUpperCase()}…`);
400
+ const btns = document.querySelectorAll("#adjust .kbd-btn");
401
+ for (const b of btns) b.disabled = true;
402
+ try {
403
+ const r = await fetch("/print/speed", {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify({ mode }),
407
+ });
408
+ if (!r.ok) throw new Error(`HTTP ${r.status} — ${await r.text()}`);
409
+ setAdjMsg(`✓ SPEED MODE → ${mode.toUpperCase()} acknowledged · only effective mid-print`, "ok");
410
+ } catch (e) {
411
+ setAdjMsg(`✗ SPEED MODE: ${e.message}`, "err");
412
+ } finally {
413
+ setTimeout(() => { for (const b of btns) b.disabled = false; }, 600);
414
+ }
415
+ }
416
+
417
+ // Visually highlight the mode that matches the live PrintSpeedPct.
418
+ function reflectSpeedMode(pct) {
419
+ if (pct == null) return;
420
+ for (const btn of document.querySelectorAll("#adj-speed-modes .adj-mode")) {
421
+ const v = { silent: 50, balanced: 100, sport: 130, ludicrous: 160 }[btn.dataset.mode];
422
+ btn.classList.toggle("active", +pct === v);
423
+ }
424
+ }
425
+
426
+ function wireAdjust() {
427
+ if (!$("adjust")) return;
428
+ const getters = {
429
+ "fan-model": bindPair("fan-model"),
430
+ "fan-aux": bindPair("fan-aux"),
431
+ "fan-chamber": bindPair("fan-chamber"),
432
+ "temp-nozzle": bindPair("temp-nozzle"),
433
+ "temp-bed": bindPair("temp-bed"),
434
+ "temp-chamber": bindPair("temp-chamber"),
435
+ };
436
+ for (const btn of document.querySelectorAll("#adjust .adj-apply")) {
437
+ btn.addEventListener("click", () => applyAdjust(btn.dataset.target, getters));
438
+ }
439
+ for (const btn of document.querySelectorAll("#adj-speed-modes .adj-mode")) {
440
+ btn.addEventListener("click", () => applySpeedMode(btn.dataset.mode));
441
+ }
442
+ }
443
+
254
444
  // ---------------------------------------------------------------------------
255
445
  // RTSP bridge
256
446
 
@@ -386,8 +576,41 @@ function wireWebcamKeepalive() {
386
576
 
387
577
  // ---------------------------------------------------------------------------
388
578
 
579
+ // Adaptive backup poll. SSE is the primary update path; this poll keeps
580
+ // the UI alive when SSE silently stalls (Firefox in particular). Rate
581
+ // follows the printer's state: fast while actively printing, slow when
582
+ // idle/paused/done/errored, so we're not hammering the firmware at
583
+ // 2 Hz when nothing is happening.
584
+ const IDLE_STATUSES = new Set([0, 6, 8, 9, 14]); // idle, paused, stopped, completed, error
585
+ const POLL_FAST_MS = 2000;
586
+ const POLL_SLOW_MS = 10000;
587
+ let pollTimer = null;
588
+ let lastPrintStatus = null;
589
+
590
+ function pollIntervalMs() {
591
+ return (lastPrintStatus != null && !IDLE_STATUSES.has(lastPrintStatus))
592
+ ? POLL_FAST_MS : POLL_SLOW_MS;
593
+ }
594
+
595
+ function schedulePoll() {
596
+ if (pollTimer) clearTimeout(pollTimer);
597
+ pollTimer = setTimeout(async () => {
598
+ await pollOnce();
599
+ schedulePoll();
600
+ }, pollIntervalMs());
601
+ }
602
+
603
+ function noteStatusForPoll(pstatus) {
604
+ const prev = lastPrintStatus;
605
+ lastPrintStatus = pstatus;
606
+ const wasActive = prev != null && !IDLE_STATUSES.has(prev);
607
+ const nowActive = pstatus != null && !IDLE_STATUSES.has(pstatus);
608
+ if (wasActive !== nowActive) schedulePoll(); // retune immediately
609
+ }
610
+
389
611
  (async function main() {
390
612
  wireControls();
613
+ wireAdjust();
391
614
  wireRtsp();
392
615
  wireWebcamKeepalive();
393
616
  await loadInfo();
@@ -397,4 +620,5 @@ function wireWebcamKeepalive() {
397
620
  setInterval(loadInfo, 60000);
398
621
  // RTSP state changes are external, poll occasionally.
399
622
  setInterval(loadRtsp, 8000);
623
+ schedulePoll();
400
624
  })();
@@ -111,6 +111,85 @@
111
111
  </div>
112
112
  <div class="ctrl-msg" id="ctrl-msg" aria-live="polite"></div>
113
113
  </div>
114
+
115
+ <!-- Live-adjust panel (Cmd 403 family) -->
116
+ <div class="panel adjust" id="adjust" hidden>
117
+ <header class="panel-header">
118
+ <span class="panel-tag">ADJUST</span>
119
+ <span class="panel-rule"></span>
120
+ <span class="panel-idx">07</span>
121
+ </header>
122
+
123
+ <div class="adj-sub">SPEED MODE · ACTIVE PRINT ONLY</div>
124
+ <div class="adj-modes" id="adj-speed-modes">
125
+ <button class="kbd-btn adj-mode" data-mode="silent" type="button">
126
+ <span class="kbd-key">50%</span><span class="kbd-label">SILENT</span>
127
+ </button>
128
+ <button class="kbd-btn adj-mode" data-mode="balanced" type="button">
129
+ <span class="kbd-key">100%</span><span class="kbd-label">BALANCED</span>
130
+ </button>
131
+ <button class="kbd-btn adj-mode" data-mode="sport" type="button">
132
+ <span class="kbd-key">130%</span><span class="kbd-label">SPORT</span>
133
+ </button>
134
+ <button class="kbd-btn adj-mode" data-mode="ludicrous" type="button">
135
+ <span class="kbd-key">160%</span><span class="kbd-label">LUDICROUS</span>
136
+ </button>
137
+ </div>
138
+
139
+ <div class="adj-sub">FANS · 0–100%</div>
140
+ <div class="adj-row">
141
+ <label class="adj-lbl" for="adj-fan-model">MODEL</label>
142
+ <input type="range" id="adj-fan-model-slider" min="0" max="100" step="1" value="0">
143
+ <input type="number" id="adj-fan-model" min="0" max="100" step="1" value="0">
144
+ <button class="kbd-btn adj-apply" data-target="fan-model" type="button">
145
+ <span class="kbd-label">APPLY</span>
146
+ </button>
147
+ </div>
148
+ <div class="adj-row">
149
+ <label class="adj-lbl" for="adj-fan-aux">AUX</label>
150
+ <input type="range" id="adj-fan-aux-slider" min="0" max="100" step="1" value="0">
151
+ <input type="number" id="adj-fan-aux" min="0" max="100" step="1" value="0">
152
+ <button class="kbd-btn adj-apply" data-target="fan-aux" type="button">
153
+ <span class="kbd-label">APPLY</span>
154
+ </button>
155
+ </div>
156
+ <div class="adj-row">
157
+ <label class="adj-lbl" for="adj-fan-chamber">CHAMBER</label>
158
+ <input type="range" id="adj-fan-chamber-slider" min="0" max="100" step="1" value="0">
159
+ <input type="number" id="adj-fan-chamber" min="0" max="100" step="1" value="0">
160
+ <button class="kbd-btn adj-apply" data-target="fan-chamber" type="button">
161
+ <span class="kbd-label">APPLY</span>
162
+ </button>
163
+ </div>
164
+
165
+ <div class="adj-sub">HEATERS · 0 = OFF</div>
166
+ <div class="adj-row">
167
+ <label class="adj-lbl" for="adj-temp-nozzle">NOZZLE <span class="adj-unit">°C</span></label>
168
+ <input type="range" id="adj-temp-nozzle-slider" min="0" max="300" step="5" value="0">
169
+ <input type="number" id="adj-temp-nozzle" min="0" max="300" step="1" value="0">
170
+ <button class="kbd-btn adj-apply" data-target="temp-nozzle" type="button">
171
+ <span class="kbd-label">APPLY</span>
172
+ </button>
173
+ </div>
174
+ <div class="adj-row">
175
+ <label class="adj-lbl" for="adj-temp-bed">BED <span class="adj-unit">°C</span></label>
176
+ <input type="range" id="adj-temp-bed-slider" min="0" max="110" step="5" value="0">
177
+ <input type="number" id="adj-temp-bed" min="0" max="110" step="1" value="0">
178
+ <button class="kbd-btn adj-apply" data-target="temp-bed" type="button">
179
+ <span class="kbd-label">APPLY</span>
180
+ </button>
181
+ </div>
182
+ <div class="adj-row">
183
+ <label class="adj-lbl" for="adj-temp-chamber">CHAMBER <span class="adj-unit">°C</span></label>
184
+ <input type="range" id="adj-temp-chamber-slider" min="0" max="60" step="1" value="0">
185
+ <input type="number" id="adj-temp-chamber" min="0" max="60" step="1" value="0">
186
+ <button class="kbd-btn adj-apply" data-target="temp-chamber" type="button">
187
+ <span class="kbd-label">APPLY</span>
188
+ </button>
189
+ </div>
190
+
191
+ <div class="ctrl-msg" id="adj-msg" aria-live="polite"></div>
192
+ </div>
114
193
  </section>
115
194
 
116
195
  <!-- RIGHT column — instruments -->
@@ -162,7 +162,6 @@ body {
162
162
  background: var(--rule-soft);
163
163
  position: relative;
164
164
  z-index: 1;
165
- min-height: 0;
166
165
  }
167
166
 
168
167
  .viewport-col,
@@ -480,11 +479,15 @@ body {
480
479
  text-transform: uppercase;
481
480
  line-height: 1;
482
481
  }
483
- .state-word.state-idle { color: var(--fg-dim); }
484
- .state-word.state-printing { color: var(--ok); }
485
- .state-word.state-paused { color: var(--warn); }
486
- .state-word.state-stopped { color: var(--danger); }
487
- .state-word.state-completed{ color: var(--cyan); }
482
+ .state-word.state-idle { color: var(--fg-dim); }
483
+ .state-word.state-printing { color: var(--ok); }
484
+ .state-word.state-paused { color: var(--warn); }
485
+ .state-word.state-stopped { color: var(--danger); }
486
+ .state-word.state-completed { color: var(--cyan); }
487
+ .state-word.state-err {
488
+ color: var(--danger);
489
+ text-shadow: 0 0 10px rgba(235, 92, 78, 0.45);
490
+ }
488
491
 
489
492
  /* ---- Thermal gauges --------------------------------------- */
490
493
 
@@ -707,6 +710,124 @@ body {
707
710
  .ctrl-msg.warn { border-left-color: var(--warn); color: var(--warn); }
708
711
  .ctrl-msg.err { border-left-color: var(--danger); color: var(--danger); }
709
712
 
713
+ /* ---- Adjust panel (Cmd 403 family) ------------------------ */
714
+
715
+ .panel.adjust { padding-bottom: 14px; }
716
+
717
+ .adj-row {
718
+ display: grid;
719
+ grid-template-columns: 90px 1fr 64px auto;
720
+ gap: 8px;
721
+ align-items: center;
722
+ margin-top: 8px;
723
+ }
724
+
725
+ .adj-lbl {
726
+ font-family: var(--f-label);
727
+ font-size: 11px;
728
+ font-weight: 600;
729
+ letter-spacing: 0.18em;
730
+ color: var(--fg-dim);
731
+ }
732
+ .adj-lbl .adj-unit {
733
+ color: var(--fg-faint);
734
+ margin-left: 4px;
735
+ font-weight: 400;
736
+ }
737
+
738
+ .adj-sub {
739
+ margin: 16px 0 -2px;
740
+ padding-bottom: 4px;
741
+ border-bottom: 1px dashed var(--rule);
742
+ font-family: var(--f-label);
743
+ font-size: 10px;
744
+ font-weight: 600;
745
+ letter-spacing: 0.22em;
746
+ color: var(--amber-deep);
747
+ }
748
+
749
+ .adj-row input[type="range"] {
750
+ -webkit-appearance: none;
751
+ appearance: none;
752
+ width: 100%;
753
+ height: 4px;
754
+ background: var(--bg-inset);
755
+ border: 1px solid var(--rule);
756
+ border-radius: 0;
757
+ outline: none;
758
+ }
759
+ .adj-row input[type="range"]::-webkit-slider-thumb {
760
+ -webkit-appearance: none;
761
+ appearance: none;
762
+ width: 12px;
763
+ height: 18px;
764
+ background: var(--amber);
765
+ border: 1px solid var(--amber-deep);
766
+ cursor: pointer;
767
+ border-radius: 0;
768
+ }
769
+ .adj-row input[type="range"]::-moz-range-thumb {
770
+ width: 12px;
771
+ height: 18px;
772
+ background: var(--amber);
773
+ border: 1px solid var(--amber-deep);
774
+ cursor: pointer;
775
+ border-radius: 0;
776
+ }
777
+
778
+ .adj-row input[type="number"] {
779
+ background: var(--bg-inset);
780
+ border: 1px solid var(--rule);
781
+ color: var(--fg);
782
+ font-family: var(--f-mono);
783
+ font-size: 13px;
784
+ text-align: right;
785
+ padding: 6px 8px;
786
+ width: 100%;
787
+ border-radius: 0;
788
+ outline: none;
789
+ -moz-appearance: textfield;
790
+ }
791
+ .adj-row input[type="number"]::-webkit-outer-spin-button,
792
+ .adj-row input[type="number"]::-webkit-inner-spin-button {
793
+ -webkit-appearance: none;
794
+ margin: 0;
795
+ }
796
+ .adj-row input[type="number"]:focus,
797
+ .adj-row input[type="range"]:focus {
798
+ border-color: var(--amber);
799
+ }
800
+
801
+ .adj-row .adj-apply {
802
+ padding: 8px 10px;
803
+ border-bottom-width: 2px;
804
+ }
805
+ .adj-row .adj-apply .kbd-label {
806
+ font-size: 11px;
807
+ letter-spacing: 0.18em;
808
+ }
809
+
810
+ #adj-msg { margin-top: 14px; }
811
+
812
+ .adj-modes {
813
+ display: grid;
814
+ grid-template-columns: repeat(4, 1fr);
815
+ gap: 6px;
816
+ margin-top: 8px;
817
+ }
818
+ .adj-modes .adj-mode {
819
+ padding: 10px 6px;
820
+ border-bottom-width: 2px;
821
+ }
822
+ .adj-modes .adj-mode .kbd-key { font-size: 10px; color: var(--fg-faint); }
823
+ .adj-modes .adj-mode .kbd-label { font-size: 10.5px; letter-spacing: 0.14em; }
824
+ .adj-modes .adj-mode.active {
825
+ border-color: var(--amber);
826
+ color: var(--amber);
827
+ background: #1a1812;
828
+ }
829
+ .adj-modes .adj-mode.active .kbd-key { color: var(--amber-deep); }
830
+
710
831
  /* ---- RTSP panel ------------------------------------------- */
711
832
 
712
833
  .rtsp-panel { padding-bottom: 14px; }
@@ -247,3 +247,66 @@ async def test_control_enabled_sends_commands(monkeypatch: pytest.MonkeyPatch) -
247
247
 
248
248
  cmds = [m["Data"]["Cmd"] for m in server.received]
249
249
  assert int(Cmd.PAUSE_PRINT) in cmds
250
+
251
+
252
+ async def test_adjust_methods_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
253
+ async with _fake_printer() as server:
254
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
255
+ async with await Printer.connect("127.0.0.1", enable_control=True) as printer:
256
+ await asyncio.wait_for(printer.wait_for_mainboard(), timeout=2)
257
+
258
+ await asyncio.wait_for(printer.set_print_speed("sport"), timeout=3)
259
+ await asyncio.wait_for(printer.set_print_speed(100), timeout=3)
260
+ await asyncio.wait_for(printer.set_fan_speed(model=50, chamber=25), timeout=3)
261
+ await asyncio.wait_for(printer.set_temperatures(nozzle=210, bed=60), timeout=3)
262
+
263
+ sent = [m["Data"] for m in server.received if m["Data"]["Cmd"] == 403]
264
+ assert len(sent) == 4
265
+ speed_named, speed_int, fan, temp = sent
266
+ assert speed_named["Data"] == {"PrintSpeedPct": 130}
267
+ assert speed_int["Data"] == {"PrintSpeedPct": 100}
268
+ assert fan["Data"] == {"TargetFanSpeed": {"ModelFan": 50, "BoxFan": 25}}
269
+ assert temp["Data"] == {"TempTargetNozzle": 210.0, "TempTargetHotbed": 60.0}
270
+
271
+
272
+ async def test_adjust_validation_rejects_out_of_range(
273
+ monkeypatch: pytest.MonkeyPatch,
274
+ ) -> None:
275
+ async with _fake_printer() as server:
276
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
277
+ async with await Printer.connect("127.0.0.1", enable_control=True) as printer:
278
+ await asyncio.wait_for(printer.wait_for_mainboard(), timeout=2)
279
+
280
+ import pytest as _pt
281
+
282
+ with _pt.raises(ValueError):
283
+ await printer.set_print_speed(120) # not a canonical mode value
284
+ with _pt.raises(ValueError):
285
+ await printer.set_print_speed("turbo") # unknown mode name
286
+ with _pt.raises(ValueError):
287
+ await printer.set_fan_speed(model=150)
288
+ with _pt.raises(ValueError):
289
+ await printer.set_temperatures(nozzle=400)
290
+ with _pt.raises(ValueError):
291
+ await printer.set_fan_speed()
292
+ with _pt.raises(ValueError):
293
+ await printer.set_temperatures()
294
+
295
+
296
+ async def test_adjust_disabled_without_control(monkeypatch: pytest.MonkeyPatch) -> None:
297
+ from pycentauri.client import ControlDisabledError
298
+
299
+ async with _fake_printer() as server:
300
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
301
+ async with await Printer.connect("127.0.0.1") as printer:
302
+ await asyncio.wait_for(printer.wait_for_mainboard(), timeout=2)
303
+ for call in (
304
+ lambda: printer.set_print_speed(100),
305
+ lambda: printer.set_fan_speed(model=50),
306
+ lambda: printer.set_temperatures(nozzle=200),
307
+ ):
308
+ try:
309
+ await call()
310
+ except ControlDisabledError:
311
+ continue
312
+ raise AssertionError("expected ControlDisabledError")
@@ -97,6 +97,59 @@ async def test_control_endpoints_registered_when_enabled(monkeypatch: pytest.Mon
97
97
  await server.stop()
98
98
 
99
99
 
100
+ async def test_adjust_endpoints_registered_when_enabled(
101
+ monkeypatch: pytest.MonkeyPatch,
102
+ ) -> None:
103
+ server = _FakePrinter()
104
+ await server.start()
105
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
106
+
107
+ app = server_module.create_app("127.0.0.1", enable_control=True, mainboard_id=MAINBOARD)
108
+ async with app.router.lifespan_context(app), await _asgi_client(app) as client:
109
+ r = await client.post("/print/speed", json={"mode": "sport"})
110
+ assert r.status_code == 200
111
+ assert r.json()["ok"] is True
112
+
113
+ r = await client.post("/print/fan", json={"model": 30, "chamber": 50})
114
+ assert r.status_code == 200
115
+
116
+ r = await client.post("/print/temperature", json={"nozzle": 200, "bed": 55})
117
+ assert r.status_code == 200
118
+
119
+ # No-target → 400 from the library
120
+ r = await client.post("/print/fan", json={})
121
+ assert r.status_code == 400
122
+
123
+ # Pydantic-level bounds (≥0, ≤100) → 422
124
+ r = await client.post("/print/fan", json={"model": 150})
125
+ assert r.status_code == 422
126
+
127
+ from pycentauri.sdcp import Cmd as _Cmd
128
+
129
+ sent = [m["Data"] for m in server.received if m["Data"]["Cmd"] == int(_Cmd.CHANGE_PRINT_PARAMS)]
130
+ assert len(sent) == 3
131
+ speed, fan, temp = sent
132
+ assert speed["Data"] == {"PrintSpeedPct": 130}
133
+ assert fan["Data"] == {"TargetFanSpeed": {"ModelFan": 30, "BoxFan": 50}}
134
+ assert temp["Data"] == {"TempTargetNozzle": 200.0, "TempTargetHotbed": 55.0}
135
+ await server.stop()
136
+
137
+
138
+ async def test_adjust_endpoints_404_without_control(
139
+ monkeypatch: pytest.MonkeyPatch,
140
+ ) -> None:
141
+ server = _FakePrinter()
142
+ await server.start()
143
+ monkeypatch.setattr("pycentauri.client.WS_PORT", server.port)
144
+
145
+ app = server_module.create_app("127.0.0.1", mainboard_id=MAINBOARD)
146
+ async with app.router.lifespan_context(app), await _asgi_client(app) as client:
147
+ for path in ("/print/speed", "/print/fan", "/print/temperature"):
148
+ r = await client.post(path, json={})
149
+ assert r.status_code == 404, path
150
+ await server.stop()
151
+
152
+
100
153
  async def test_rtsp_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
101
154
  server = _FakePrinter()
102
155
  await server.start()
File without changes
File without changes
File without changes
File without changes