pycentauri 0.4.2__tar.gz → 0.5.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.
- {pycentauri-0.4.2 → pycentauri-0.5.1}/CHANGELOG.md +87 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/PKG-INFO +25 -3
- {pycentauri-0.4.2 → pycentauri-0.5.1}/README.md +24 -2
- {pycentauri-0.4.2 → pycentauri-0.5.1}/pyproject.toml +1 -1
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/cli.py +113 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/client.py +105 -1
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/mcp/server.py +47 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/sdcp.py +10 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/server.py +68 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/web/app.js +209 -4
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/web/index.html +79 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/web/styles.css +118 -1
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/test_client.py +63 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/test_server.py +53 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/.gitignore +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/LICENSE +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/models.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/__init__.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/integration/__init__.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/test_discovery.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/test_rtsp.py +0 -0
- {pycentauri-0.4.2 → pycentauri-0.5.1}/tests/test_sdcp.py +0 -0
|
@@ -6,6 +6,93 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.1] - 2026-06-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **CLI parity with the new live-adjust API.** Three new top-level
|
|
13
|
+
commands matching the library and HTTP surfaces shipped in 0.5.0:
|
|
14
|
+
- `centauri speed <silent|balanced|sport|ludicrous|50|100|130|160>`
|
|
15
|
+
- `centauri fan [--model N] [--aux N] [--chamber N]`
|
|
16
|
+
- `centauri temp [--nozzle N] [--bed N] [--chamber N]`
|
|
17
|
+
All require `--enable-control` and accept any subset of fan/heater
|
|
18
|
+
flags (omitted axes are left untouched).
|
|
19
|
+
- **MCP tool parity:** three new tools (only registered with
|
|
20
|
+
`--enable-control`) — `set_print_speed`, `set_fan_speed`,
|
|
21
|
+
`set_temperatures`. Same shapes as the HTTP endpoints; not marked
|
|
22
|
+
"DESTRUCTIVE" because live runtime adjustment is their entire
|
|
23
|
+
purpose.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- 0.5.0 shipped the live-adjust API to the library, HTTP server, and
|
|
27
|
+
web UI but missed the CLI and MCP server. This release closes that
|
|
28
|
+
parity gap — every surface now exposes the same control set.
|
|
29
|
+
|
|
30
|
+
## [0.5.0] - 2026-06-17
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Live print-parameter adjust (`Cmd 403` family).** All three payload
|
|
34
|
+
variants confirmed working against firmware V0.3.0-o:
|
|
35
|
+
- `Printer.set_print_speed(mode)` — sets the printer's speed mode.
|
|
36
|
+
Accepts a name (`"silent"`, `"balanced"`, `"sport"`, `"ludicrous"`)
|
|
37
|
+
or its canonical `PrintSpeedPct` value (`50`, `100`, `130`, `160`).
|
|
38
|
+
Only those four values are accepted by the firmware — arbitrary
|
|
39
|
+
intermediate percentages return `Ack=0` but are silently dropped,
|
|
40
|
+
and the mode change only takes effect while a print is actively
|
|
41
|
+
running. Names + values lifted directly from the printer's own
|
|
42
|
+
SPA i18n file at `/app/resources/www/assets/i18n/network-en.json`.
|
|
43
|
+
- `Printer.set_fan_speed(model=, auxiliary=, chamber=)` — set any
|
|
44
|
+
subset of the model / auxiliary / chamber fan (0..100% each).
|
|
45
|
+
- `Printer.set_temperatures(nozzle=, bed=, chamber=)` — heater
|
|
46
|
+
targets with safety caps (nozzle 0..300, bed 0..110, chamber 0..60).
|
|
47
|
+
`0` turns the heater off.
|
|
48
|
+
- HTTP endpoints (only when launched with `--enable-control`):
|
|
49
|
+
`POST /print/speed`, `POST /print/fan`, `POST /print/temperature`.
|
|
50
|
+
Speed body: `{"mode": "silent|balanced|sport|ludicrous"}` (or the
|
|
51
|
+
integer equivalent).
|
|
52
|
+
- Web UI `ADJUST` panel with three sections: a 4-button speed-mode
|
|
53
|
+
selector (the active mode pulses amber based on live status), per-fan
|
|
54
|
+
rows (model / aux / chamber, 0–100%), and per-heater rows with
|
|
55
|
+
per-row APPLY buttons and confirm prompts on high temps
|
|
56
|
+
(nozzle > 240 °C, bed > 85 °C).
|
|
57
|
+
- Web UI auto-hydrates the fan/heater sliders + inputs from each status
|
|
58
|
+
push, so they always start at the printer's actual live values
|
|
59
|
+
instead of zero. Controls that are currently focused are skipped so
|
|
60
|
+
the live update doesn't yank a value out from under a drag/type.
|
|
61
|
+
- Adaptive backup poll: 2-second cadence while the printer is actively
|
|
62
|
+
printing, 10-second cadence when idle/paused/done/errored. Switches
|
|
63
|
+
immediately on state transition. SSE remains the primary update
|
|
64
|
+
path; the poll is a safety net for Firefox's silent SSE stalls.
|
|
65
|
+
- `sdcp.Cmd.CHANGE_PRINT_PARAMS = 403` enum entry, plus the previously-
|
|
66
|
+
unenumerated `GET_FILE_LIST = 258` and `GET_PRINT_HISTORY = 320` for
|
|
67
|
+
reference (no client methods yet — exposing those is queued for a
|
|
68
|
+
later release).
|
|
69
|
+
- `Printer.PRINT_SPEED_MODES` class-level map exposing the canonical
|
|
70
|
+
`{mode_name: PrintSpeedPct}` table for callers that want to render
|
|
71
|
+
their own mode picker.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
- **Footer rail no longer overlaps the ADJUST panel on tall pages.**
|
|
75
|
+
Removing the `min-height: 0` on `.console` lets the grid grow with
|
|
76
|
+
its content (instead of being constrained to the body's flex slot),
|
|
77
|
+
so the bottom rail reflows below the panel instead of hovering over
|
|
78
|
+
it.
|
|
79
|
+
- **Web UI status now stays fresh in Firefox.** SSE remains the
|
|
80
|
+
primary push path, but the new adaptive backup poll runs in parallel
|
|
81
|
+
— Firefox occasionally drops the SSE stream silently and previously
|
|
82
|
+
required a manual refresh to update; the poll keeps the progress
|
|
83
|
+
bar, temps, fan readings, and ADJUST hydration live regardless.
|
|
84
|
+
|
|
85
|
+
### Documentation
|
|
86
|
+
- `docs/PROTOCOL.md` promotes Cmd 403 (all three payload variants) to
|
|
87
|
+
the confirmed-working table, documents the four canonical
|
|
88
|
+
`PrintSpeedPct` values, captures the printer's internal architecture
|
|
89
|
+
as observed via SSH on OpenCentauri V0.3.0-o (the `app` binary
|
|
90
|
+
embeds a Klipper-derived motion stack rather than running it as a
|
|
91
|
+
separate process, which explains why an `app` crash kills any
|
|
92
|
+
active print), adds the log-line signal table for filament cycles
|
|
93
|
+
(`feed state change`, `M729`, etc.), and the recorded touchscreen
|
|
94
|
+
tap-event sequence for the Goodix `gt9xxnew_ts` driver.
|
|
95
|
+
|
|
9
96
|
## [0.4.2] - 2026-04-22
|
|
10
97
|
|
|
11
98
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -109,8 +109,17 @@ centauri print start cube.gcode --host 192.168.1.209 --enable-control
|
|
|
109
109
|
centauri print pause --host 192.168.1.209 --enable-control
|
|
110
110
|
centauri print resume --host 192.168.1.209 --enable-control
|
|
111
111
|
centauri print stop --host 192.168.1.209 --enable-control
|
|
112
|
+
|
|
113
|
+
# Live adjust — speed mode, fans, heaters (only effective mid-print for speed)
|
|
114
|
+
centauri speed sport --host 192.168.1.209 --enable-control
|
|
115
|
+
centauri fan --model 100 --aux 60 --chamber 30 --host 192.168.1.209 --enable-control
|
|
116
|
+
centauri temp --nozzle 215 --bed 60 --host 192.168.1.209 --enable-control
|
|
112
117
|
```
|
|
113
118
|
|
|
119
|
+
The four speed modes (`silent`, `balanced`, `sport`, `ludicrous`) map to
|
|
120
|
+
the firmware's only-accepted `PrintSpeedPct` values (50, 100, 130, 160) —
|
|
121
|
+
arbitrary intermediate percentages are silently dropped by the firmware.
|
|
122
|
+
|
|
114
123
|
The host can also come from the `PYCENTAURI_HOST` environment variable.
|
|
115
124
|
If neither is set, every command auto-discovers via a 2.5 s UDP broadcast
|
|
116
125
|
and bails out if it finds zero or more than one printer.
|
|
@@ -136,8 +145,11 @@ async def main():
|
|
|
136
145
|
asyncio.run(main())
|
|
137
146
|
```
|
|
138
147
|
|
|
139
|
-
Control actions (`start_print`, `pause`, `resume`, `stop
|
|
140
|
-
`
|
|
148
|
+
Control actions (`start_print`, `pause`, `resume`, `stop`,
|
|
149
|
+
`set_print_speed`, `set_fan_speed`, `set_temperatures`) require
|
|
150
|
+
`Printer.connect(..., enable_control=True)`. The mode-to-value map for
|
|
151
|
+
print speed is also exposed as `Printer.PRINT_SPEED_MODES` for callers
|
|
152
|
+
that want to render their own picker.
|
|
141
153
|
|
|
142
154
|
## Quick start — HTTP server
|
|
143
155
|
|
|
@@ -166,6 +178,13 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 \
|
|
|
166
178
|
| `GET` | `/docs` / `/redoc` | Auto-generated OpenAPI docs |
|
|
167
179
|
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
168
180
|
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
181
|
+
| `POST` | `/print/speed` | Body: `{"mode": "silent\|balanced\|sport\|ludicrous"}` (or `50\|100\|130\|160`). |
|
|
182
|
+
| `POST` | `/print/fan` | Body: `{"model": 50, "auxiliary": 30, "chamber": 0}` (any subset; 0..100). |
|
|
183
|
+
| `POST` | `/print/temperature` | Body: `{"nozzle": 215, "bed": 60, "chamber": 0}` (any subset; °C). |
|
|
184
|
+
|
|
185
|
+
The web UI's **ADJUST** panel exposes all three of the above as a
|
|
186
|
+
4-button speed-mode selector and per-fan/heater rows with auto-hydration
|
|
187
|
+
from live status.
|
|
169
188
|
|
|
170
189
|
The server holds a single long-lived WebSocket to the printer and reuses
|
|
171
190
|
it for every request — no per-request reconnect, and it won't bump into
|
|
@@ -226,6 +245,9 @@ the host is pinned at spawn time. Tools exposed:
|
|
|
226
245
|
| `pause_print` | only with `--enable-control` | Pauses the current print |
|
|
227
246
|
| `resume_print` | only with `--enable-control` | Resumes a paused print |
|
|
228
247
|
| `stop_print` | only with `--enable-control` | Stops the current print |
|
|
248
|
+
| `set_print_speed` | only with `--enable-control` | Sets speed mode (`silent`/`balanced`/`sport`/`ludicrous`) — only takes effect mid-print |
|
|
249
|
+
| `set_fan_speed` | only with `--enable-control` | Sets any subset of model/aux/chamber fan speeds (0..100%) |
|
|
250
|
+
| `set_temperatures` | only with `--enable-control` | Sets any subset of nozzle/bed/chamber heater targets (°C, 0 = off) |
|
|
229
251
|
|
|
230
252
|
Control tools aren't just gated — they're not *registered* without the
|
|
231
253
|
flag, so an LLM that wasn't given the `--enable-control` launch can't see
|
|
@@ -61,8 +61,17 @@ centauri print start cube.gcode --host 192.168.1.209 --enable-control
|
|
|
61
61
|
centauri print pause --host 192.168.1.209 --enable-control
|
|
62
62
|
centauri print resume --host 192.168.1.209 --enable-control
|
|
63
63
|
centauri print stop --host 192.168.1.209 --enable-control
|
|
64
|
+
|
|
65
|
+
# Live adjust — speed mode, fans, heaters (only effective mid-print for speed)
|
|
66
|
+
centauri speed sport --host 192.168.1.209 --enable-control
|
|
67
|
+
centauri fan --model 100 --aux 60 --chamber 30 --host 192.168.1.209 --enable-control
|
|
68
|
+
centauri temp --nozzle 215 --bed 60 --host 192.168.1.209 --enable-control
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
The four speed modes (`silent`, `balanced`, `sport`, `ludicrous`) map to
|
|
72
|
+
the firmware's only-accepted `PrintSpeedPct` values (50, 100, 130, 160) —
|
|
73
|
+
arbitrary intermediate percentages are silently dropped by the firmware.
|
|
74
|
+
|
|
66
75
|
The host can also come from the `PYCENTAURI_HOST` environment variable.
|
|
67
76
|
If neither is set, every command auto-discovers via a 2.5 s UDP broadcast
|
|
68
77
|
and bails out if it finds zero or more than one printer.
|
|
@@ -88,8 +97,11 @@ async def main():
|
|
|
88
97
|
asyncio.run(main())
|
|
89
98
|
```
|
|
90
99
|
|
|
91
|
-
Control actions (`start_print`, `pause`, `resume`, `stop
|
|
92
|
-
`
|
|
100
|
+
Control actions (`start_print`, `pause`, `resume`, `stop`,
|
|
101
|
+
`set_print_speed`, `set_fan_speed`, `set_temperatures`) require
|
|
102
|
+
`Printer.connect(..., enable_control=True)`. The mode-to-value map for
|
|
103
|
+
print speed is also exposed as `Printer.PRINT_SPEED_MODES` for callers
|
|
104
|
+
that want to render their own picker.
|
|
93
105
|
|
|
94
106
|
## Quick start — HTTP server
|
|
95
107
|
|
|
@@ -118,6 +130,13 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 \
|
|
|
118
130
|
| `GET` | `/docs` / `/redoc` | Auto-generated OpenAPI docs |
|
|
119
131
|
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
120
132
|
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
133
|
+
| `POST` | `/print/speed` | Body: `{"mode": "silent\|balanced\|sport\|ludicrous"}` (or `50\|100\|130\|160`). |
|
|
134
|
+
| `POST` | `/print/fan` | Body: `{"model": 50, "auxiliary": 30, "chamber": 0}` (any subset; 0..100). |
|
|
135
|
+
| `POST` | `/print/temperature` | Body: `{"nozzle": 215, "bed": 60, "chamber": 0}` (any subset; °C). |
|
|
136
|
+
|
|
137
|
+
The web UI's **ADJUST** panel exposes all three of the above as a
|
|
138
|
+
4-button speed-mode selector and per-fan/heater rows with auto-hydration
|
|
139
|
+
from live status.
|
|
121
140
|
|
|
122
141
|
The server holds a single long-lived WebSocket to the printer and reuses
|
|
123
142
|
it for every request — no per-request reconnect, and it won't bump into
|
|
@@ -178,6 +197,9 @@ the host is pinned at spawn time. Tools exposed:
|
|
|
178
197
|
| `pause_print` | only with `--enable-control` | Pauses the current print |
|
|
179
198
|
| `resume_print` | only with `--enable-control` | Resumes a paused print |
|
|
180
199
|
| `stop_print` | only with `--enable-control` | Stops the current print |
|
|
200
|
+
| `set_print_speed` | only with `--enable-control` | Sets speed mode (`silent`/`balanced`/`sport`/`ludicrous`) — only takes effect mid-print |
|
|
201
|
+
| `set_fan_speed` | only with `--enable-control` | Sets any subset of model/aux/chamber fan speeds (0..100%) |
|
|
202
|
+
| `set_temperatures` | only with `--enable-control` | Sets any subset of nozzle/bed/chamber heater targets (°C, 0 = off) |
|
|
181
203
|
|
|
182
204
|
Control tools aren't just gated — they're not *registered* without the
|
|
183
205
|
flag, so an LLM that wasn't given the `--enable-control` launch can't see
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycentauri"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.1"
|
|
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"
|
|
@@ -323,6 +323,119 @@ def cmd_print_stop(host: HostOpt = None, enable_control: ControlOpt = False) ->
|
|
|
323
323
|
_run(run())
|
|
324
324
|
|
|
325
325
|
|
|
326
|
+
@app.command("speed")
|
|
327
|
+
def cmd_speed(
|
|
328
|
+
mode: Annotated[
|
|
329
|
+
str,
|
|
330
|
+
typer.Argument(
|
|
331
|
+
help="Speed mode: silent | balanced | sport | ludicrous "
|
|
332
|
+
"(or the integer 50 | 100 | 130 | 160).",
|
|
333
|
+
),
|
|
334
|
+
],
|
|
335
|
+
host: HostOpt = None,
|
|
336
|
+
enable_control: ControlOpt = False,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Set the print-speed mode. Only effective while a print is running."""
|
|
339
|
+
if not enable_control:
|
|
340
|
+
_echo_err("Refusing to send a write action without --enable-control.")
|
|
341
|
+
raise typer.Exit(code=2)
|
|
342
|
+
|
|
343
|
+
parsed: str | int = int(mode) if mode.lstrip("-").isdigit() else mode
|
|
344
|
+
|
|
345
|
+
async def run() -> None:
|
|
346
|
+
h, mid = await _resolve_target(host)
|
|
347
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
348
|
+
try:
|
|
349
|
+
await printer.set_print_speed(parsed)
|
|
350
|
+
except ValueError as err:
|
|
351
|
+
_echo_err(str(err))
|
|
352
|
+
raise typer.Exit(code=2) from err
|
|
353
|
+
typer.echo(f"speed mode set: {mode}")
|
|
354
|
+
|
|
355
|
+
_run(run())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.command("fan")
|
|
359
|
+
def cmd_fan(
|
|
360
|
+
model: Annotated[
|
|
361
|
+
int | None,
|
|
362
|
+
typer.Option("--model", help="Model (part-cooling) fan 0..100%."),
|
|
363
|
+
] = None,
|
|
364
|
+
auxiliary: Annotated[
|
|
365
|
+
int | None,
|
|
366
|
+
typer.Option("--aux", "--auxiliary", help="Auxiliary fan 0..100%."),
|
|
367
|
+
] = None,
|
|
368
|
+
chamber: Annotated[
|
|
369
|
+
int | None,
|
|
370
|
+
typer.Option("--chamber", help="Chamber/box fan 0..100%."),
|
|
371
|
+
] = None,
|
|
372
|
+
host: HostOpt = None,
|
|
373
|
+
enable_control: ControlOpt = False,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Set fan speeds. Pass any subset; omitted fans are left untouched."""
|
|
376
|
+
if not enable_control:
|
|
377
|
+
_echo_err("Refusing to send a write action without --enable-control.")
|
|
378
|
+
raise typer.Exit(code=2)
|
|
379
|
+
if model is None and auxiliary is None and chamber is None:
|
|
380
|
+
_echo_err("Specify at least one of --model, --aux, --chamber.")
|
|
381
|
+
raise typer.Exit(code=2)
|
|
382
|
+
|
|
383
|
+
async def run() -> None:
|
|
384
|
+
h, mid = await _resolve_target(host)
|
|
385
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
386
|
+
try:
|
|
387
|
+
await printer.set_fan_speed(model=model, auxiliary=auxiliary, chamber=chamber)
|
|
388
|
+
except ValueError as err:
|
|
389
|
+
_echo_err(str(err))
|
|
390
|
+
raise typer.Exit(code=2) from err
|
|
391
|
+
parts = [
|
|
392
|
+
f"{k}={v}%"
|
|
393
|
+
for k, v in (("model", model), ("aux", auxiliary), ("chamber", chamber))
|
|
394
|
+
if v is not None
|
|
395
|
+
]
|
|
396
|
+
typer.echo("fans set: " + ", ".join(parts))
|
|
397
|
+
|
|
398
|
+
_run(run())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command("temp")
|
|
402
|
+
def cmd_temp(
|
|
403
|
+
nozzle: Annotated[
|
|
404
|
+
float | None, typer.Option("--nozzle", help="Nozzle target °C (0 = off).")
|
|
405
|
+
] = None,
|
|
406
|
+
bed: Annotated[float | None, typer.Option("--bed", help="Bed target °C (0 = off).")] = None,
|
|
407
|
+
chamber: Annotated[
|
|
408
|
+
float | None, typer.Option("--chamber", help="Chamber target °C (0 = off).")
|
|
409
|
+
] = None,
|
|
410
|
+
host: HostOpt = None,
|
|
411
|
+
enable_control: ControlOpt = False,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Set heater target temperatures. Pass any subset."""
|
|
414
|
+
if not enable_control:
|
|
415
|
+
_echo_err("Refusing to send a write action without --enable-control.")
|
|
416
|
+
raise typer.Exit(code=2)
|
|
417
|
+
if nozzle is None and bed is None and chamber is None:
|
|
418
|
+
_echo_err("Specify at least one of --nozzle, --bed, --chamber.")
|
|
419
|
+
raise typer.Exit(code=2)
|
|
420
|
+
|
|
421
|
+
async def run() -> None:
|
|
422
|
+
h, mid = await _resolve_target(host)
|
|
423
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
424
|
+
try:
|
|
425
|
+
await printer.set_temperatures(nozzle=nozzle, bed=bed, chamber=chamber)
|
|
426
|
+
except ValueError as err:
|
|
427
|
+
_echo_err(str(err))
|
|
428
|
+
raise typer.Exit(code=2) from err
|
|
429
|
+
parts = [
|
|
430
|
+
f"{k}={v}°C"
|
|
431
|
+
for k, v in (("nozzle", nozzle), ("bed", bed), ("chamber", chamber))
|
|
432
|
+
if v is not None
|
|
433
|
+
]
|
|
434
|
+
typer.echo("targets set: " + ", ".join(parts))
|
|
435
|
+
|
|
436
|
+
_run(run())
|
|
437
|
+
|
|
438
|
+
|
|
326
439
|
@app.command("server")
|
|
327
440
|
def cmd_server(
|
|
328
441
|
host: HostOpt = None,
|
|
@@ -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:
|
|
@@ -196,6 +196,53 @@ def build_server(*, enable_control: bool = False) -> FastMCP:
|
|
|
196
196
|
result = await printer.stop()
|
|
197
197
|
return {"ok": True, "response": result.inner}
|
|
198
198
|
|
|
199
|
+
@mcp.tool()
|
|
200
|
+
async def set_print_speed(mode: str | int) -> dict[str, Any]:
|
|
201
|
+
"""Set the print-speed mode. Only effective while a print is running.
|
|
202
|
+
|
|
203
|
+
``mode`` is one of ``"silent"``, ``"balanced"``, ``"sport"``,
|
|
204
|
+
``"ludicrous"``, or the corresponding ``PrintSpeedPct`` value
|
|
205
|
+
(``50``, ``100``, ``130``, ``160``). Arbitrary intermediate
|
|
206
|
+
values are rejected by the firmware.
|
|
207
|
+
"""
|
|
208
|
+
host, mid = await _resolve_target()
|
|
209
|
+
async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
|
|
210
|
+
result = await printer.set_print_speed(mode)
|
|
211
|
+
return {"ok": True, "response": result.inner}
|
|
212
|
+
|
|
213
|
+
@mcp.tool()
|
|
214
|
+
async def set_fan_speed(
|
|
215
|
+
model: int | None = None,
|
|
216
|
+
auxiliary: int | None = None,
|
|
217
|
+
chamber: int | None = None,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Set fan speeds (0..100% each). Pass any subset; omitted fans are untouched.
|
|
220
|
+
|
|
221
|
+
``chamber`` is the chamber/box fan. At least one of the three must
|
|
222
|
+
be provided.
|
|
223
|
+
"""
|
|
224
|
+
host, mid = await _resolve_target()
|
|
225
|
+
async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
|
|
226
|
+
result = await printer.set_fan_speed(model=model, auxiliary=auxiliary, chamber=chamber)
|
|
227
|
+
return {"ok": True, "response": result.inner}
|
|
228
|
+
|
|
229
|
+
@mcp.tool()
|
|
230
|
+
async def set_temperatures(
|
|
231
|
+
nozzle: float | None = None,
|
|
232
|
+
bed: float | None = None,
|
|
233
|
+
chamber: float | None = None,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
"""Set heater target temperatures in °C. Pass any subset.
|
|
236
|
+
|
|
237
|
+
``0`` turns the corresponding heater off. Safety caps applied:
|
|
238
|
+
nozzle 0..300, bed 0..110, chamber 0..60. Setting all heaters to
|
|
239
|
+
``0`` mid-print effectively kills the print.
|
|
240
|
+
"""
|
|
241
|
+
host, mid = await _resolve_target()
|
|
242
|
+
async with await Printer.connect(host, enable_control=True, mainboard_id=mid) as printer:
|
|
243
|
+
result = await printer.set_temperatures(nozzle=nozzle, bed=bed, chamber=chamber)
|
|
244
|
+
return {"ok": True, "response": result.inner}
|
|
245
|
+
|
|
199
246
|
return mcp
|
|
200
247
|
|
|
201
248
|
|
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
// pycentauri web console
|
|
2
2
|
// Consumes /api/info, /status, /events/status (SSE), /attributes.
|
|
3
|
-
// Posts to /print/{pause,resume,stop}
|
|
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
|
|
|
@@ -86,7 +87,10 @@ async function loadInfo() {
|
|
|
86
87
|
$("mainboard").textContent = info.mainboard_id || "—";
|
|
87
88
|
$("mainboard").title = info.mainboard_id || "";
|
|
88
89
|
|
|
89
|
-
if (info.enable_control)
|
|
90
|
+
if (info.enable_control) {
|
|
91
|
+
$("controls").hidden = false;
|
|
92
|
+
$("adjust").hidden = false;
|
|
93
|
+
}
|
|
90
94
|
|
|
91
95
|
try {
|
|
92
96
|
const attrs = await (await fetch("/attributes")).json();
|
|
@@ -151,6 +155,11 @@ function renderStatus(raw) {
|
|
|
151
155
|
|
|
152
156
|
// Speed %
|
|
153
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);
|
|
154
163
|
|
|
155
164
|
// Temperatures
|
|
156
165
|
const noz = raw.TempOfNozzle, nozT = raw.TempTargetNozzle;
|
|
@@ -208,15 +217,17 @@ function connectSSE() {
|
|
|
208
217
|
try { src = new EventSource("/events/status"); }
|
|
209
218
|
catch (_) {
|
|
210
219
|
setStatusPill("warn", "SSE N/A");
|
|
211
|
-
setInterval(pollOnce, 3000);
|
|
212
220
|
return;
|
|
213
221
|
}
|
|
214
222
|
src.addEventListener("status", (ev) => {
|
|
215
223
|
try { renderStatus(JSON.parse(ev.data)); } catch (_) {}
|
|
216
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.
|
|
217
229
|
src.onerror = () => {
|
|
218
230
|
setStatusPill("warn", "LINK WAIT");
|
|
219
|
-
setTimeout(pollOnce, 2000);
|
|
220
231
|
};
|
|
221
232
|
}
|
|
222
233
|
|
|
@@ -270,6 +281,166 @@ function wireControls() {
|
|
|
270
281
|
});
|
|
271
282
|
}
|
|
272
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
|
+
|
|
273
444
|
// ---------------------------------------------------------------------------
|
|
274
445
|
// RTSP bridge
|
|
275
446
|
|
|
@@ -405,8 +576,41 @@ function wireWebcamKeepalive() {
|
|
|
405
576
|
|
|
406
577
|
// ---------------------------------------------------------------------------
|
|
407
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
|
+
|
|
408
611
|
(async function main() {
|
|
409
612
|
wireControls();
|
|
613
|
+
wireAdjust();
|
|
410
614
|
wireRtsp();
|
|
411
615
|
wireWebcamKeepalive();
|
|
412
616
|
await loadInfo();
|
|
@@ -416,4 +620,5 @@ function wireWebcamKeepalive() {
|
|
|
416
620
|
setInterval(loadInfo, 60000);
|
|
417
621
|
// RTSP state changes are external, poll occasionally.
|
|
418
622
|
setInterval(loadRtsp, 8000);
|
|
623
|
+
schedulePoll();
|
|
419
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,
|
|
@@ -711,6 +710,124 @@ body {
|
|
|
711
710
|
.ctrl-msg.warn { border-left-color: var(--warn); color: var(--warn); }
|
|
712
711
|
.ctrl-msg.err { border-left-color: var(--danger); color: var(--danger); }
|
|
713
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
|
+
|
|
714
831
|
/* ---- RTSP panel ------------------------------------------- */
|
|
715
832
|
|
|
716
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
|
|
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
|