pycentauri 0.6.2__tar.gz → 0.6.3__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.6.2 → pycentauri-0.6.3}/CHANGELOG.md +37 -20
- {pycentauri-0.6.2 → pycentauri-0.6.3}/PKG-INFO +43 -9
- {pycentauri-0.6.2 → pycentauri-0.6.3}/README.md +42 -8
- {pycentauri-0.6.2 → pycentauri-0.6.3}/pyproject.toml +1 -1
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/cc2.py +82 -52
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/cli.py +1 -2
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/web/index.html +0 -4
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/web/styles.css +1 -1
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_cc2_mapping.py +86 -68
- {pycentauri-0.6.2 → pycentauri-0.6.3}/.gitignore +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/LICENSE +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/client.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/connect.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/models.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/server.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/src/pycentauri/web/app.js +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/__init__.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_client.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_discovery.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_rtsp.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_sdcp.py +0 -0
- {pycentauri-0.6.2 → pycentauri-0.6.3}/tests/test_server.py +0 -0
|
@@ -6,33 +6,50 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.3] - 2026-07-05
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **CC2 speed pinning now tells a human's balanced apart from a firmware
|
|
13
|
+
reset — no more Auto escape hatch.** The firmware resets speed_mode to
|
|
14
|
+
balanced as the leading edge of every Canvas filament switch, a few
|
|
15
|
+
seconds before the head parks; measured reset→park lead times are
|
|
16
|
+
tight (6–8 s, never above ~9 s). pycentauri uses that: a drop to
|
|
17
|
+
balanced followed by a park within ~12 s is the firmware and the
|
|
18
|
+
pinned mode is re-applied when the switch completes; a drop that sits
|
|
19
|
+
at balanced for ~12 s with no park is a human tapping balanced on the
|
|
20
|
+
touchscreen, so the pin is released and balanced is honored. A
|
|
21
|
+
sustained non-balanced touchscreen mode is still adopted as the pin
|
|
22
|
+
outright. Both paths live-verified 2026-07-05 (firmware re-apply and
|
|
23
|
+
human release, each with journal-logged decisions).
|
|
24
|
+
- **Removed the `auto` speed mode / Auto button.** It existed only to
|
|
25
|
+
release the pin for the touchscreen-balanced case, which is now
|
|
26
|
+
detected automatically. `speed auto`, `{"mode": "auto"}`, and the web
|
|
27
|
+
UI Auto button are gone.
|
|
28
|
+
|
|
29
|
+
|
|
9
30
|
## [0.6.2] - 2026-07-05
|
|
10
31
|
|
|
11
32
|
### Changed
|
|
12
33
|
- **CC2 speed-mode persistence redesigned as pin-and-enforce.** The
|
|
13
34
|
0.6.1 snapshot-per-switch restore lost an arms race with the
|
|
14
|
-
firmware: after eight consecutive successful restores, a reset
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
firmware: after eight consecutive successful restores, a reset whose
|
|
36
|
+
lead time exceeded the debounce window poisoned the baseline and
|
|
37
|
+
silently disabled every restore after it. The firmware resets
|
|
38
|
+
`speed_mode` to balanced as part of every Canvas filament switch —
|
|
39
|
+
the reset fires several seconds before the head parks, while the
|
|
40
|
+
status still reads "printing", and its lead time varies — and it only
|
|
41
|
+
ever resets TO balanced. So the model is now timing-agnostic: the
|
|
42
|
+
mode you set is pinned (immediately via pycentauri; after holding a
|
|
43
|
+
few seconds when set from the touchscreen, since a non-balanced mode
|
|
44
|
+
can only come from a human), and any disagreement lasting 12 s while
|
|
22
45
|
printing is re-applied, at most every 30 s. One consequence,
|
|
23
46
|
deliberate: while a mode is pinned, selecting *balanced* from the
|
|
24
|
-
touchscreen is
|
|
25
|
-
reverted — switch to balanced through pycentauri instead
|
|
26
|
-
re-pins. Pins clear when the print ends, and
|
|
27
|
-
``{"mode": "auto"}`` (HTTP), or the web UI's
|
|
28
|
-
pin explicitly, returning speed control to
|
|
29
|
-
|
|
30
|
-
a **service operation** — full filament switches, plus brief nozzle
|
|
31
|
-
wipe / purge operations at the chute that are too short to register
|
|
32
|
-
as "switching" — and a human's touchscreen tap is byte-identical to
|
|
33
|
-
a firmware reset on the wire, so balanced-from-touchscreen cannot be
|
|
34
|
-
auto-detected while a pin is active. Use Auto or set balanced via
|
|
35
|
-
pycentauri instead.
|
|
47
|
+
touchscreen is byte-identical on the wire to a firmware reset and will
|
|
48
|
+
be reverted — switch to balanced through pycentauri instead (which
|
|
49
|
+
re-pins), or release the pin. Pins clear when the print ends, and
|
|
50
|
+
``speed auto`` (CLI), ``{"mode": "auto"}`` (HTTP), or the web UI's
|
|
51
|
+
Auto button release the pin explicitly, returning speed control to
|
|
52
|
+
the printer.
|
|
36
53
|
|
|
37
54
|
## [0.6.1] - 2026-07-05
|
|
38
55
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
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
|
|
@@ -166,6 +166,42 @@ are silently ignored by the firmware:
|
|
|
166
166
|
|
|
167
167
|
Speed changes only take effect while a print is actively running.
|
|
168
168
|
|
|
169
|
+
#### CC2 speed pinning (the firmware fights you, so pycentauri fights back)
|
|
170
|
+
|
|
171
|
+
The CC2 firmware **resets the speed mode back to balanced on every
|
|
172
|
+
Canvas filament switch.** On a multi-color print that switch happens
|
|
173
|
+
every couple of minutes, so set sport once and the printer quietly
|
|
174
|
+
drops you to balanced at the next color change. This is firmware
|
|
175
|
+
behavior; it happens whether you set the speed from pycentauri or from
|
|
176
|
+
the printer's own touchscreen.
|
|
177
|
+
|
|
178
|
+
pycentauri works around it with **pin-and-enforce** (CC2 only, requires
|
|
179
|
+
`--enable-control`):
|
|
180
|
+
|
|
181
|
+
- **Whatever mode you select gets pinned.** Set it via pycentauri (pins
|
|
182
|
+
immediately) or on the touchscreen (pinned after it holds ~8 seconds —
|
|
183
|
+
the firmware never *resets* to a non-balanced mode, so a sustained
|
|
184
|
+
sport/ludicrous/silent must be a human).
|
|
185
|
+
- **A firmware reset is re-applied.** The reset fires a few seconds
|
|
186
|
+
*before* the head parks at the chute, so when the speed drops to
|
|
187
|
+
balanced pycentauri waits to see what follows: a filament switch
|
|
188
|
+
(the head parking) within ~12 seconds means it was the firmware, and
|
|
189
|
+
the pinned mode is re-applied as soon as the switch completes.
|
|
190
|
+
- **A human's balanced is honored.** If the speed drops to balanced and
|
|
191
|
+
*no* switch follows within ~12 seconds, that was you tapping balanced
|
|
192
|
+
on the touchscreen — the pin is released and balanced stays. (On the
|
|
193
|
+
wire a human tap is byte-identical to a firmware reset; the presence
|
|
194
|
+
or absence of the following switch is what tells them apart. Measured
|
|
195
|
+
reset→park lead times cluster at 6–8 s, well inside the window.)
|
|
196
|
+
- **The pin clears when the print ends.**
|
|
197
|
+
|
|
198
|
+
So both paths just work: set any speed from either the app or the
|
|
199
|
+
printer and it sticks across filament switches, and you can always
|
|
200
|
+
drop back to balanced from the touchscreen whenever you want.
|
|
201
|
+
|
|
202
|
+
The CC1 has none of this — its speed mode stays where you put it, so
|
|
203
|
+
`set_print_speed` is a plain one-shot there.
|
|
204
|
+
|
|
169
205
|
## Python library
|
|
170
206
|
|
|
171
207
|
```python
|
|
@@ -407,14 +443,12 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
407
443
|
cooldown of a few seconds during which the broker silently drops
|
|
408
444
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
409
445
|
should too.
|
|
410
|
-
- **The firmware
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
*balanced* on the touchscreen looks identical to a firmware reset
|
|
417
|
-
and will be reverted — set balanced through pycentauri instead.
|
|
446
|
+
- **The firmware resets the speed mode to balanced on every Canvas
|
|
447
|
+
filament switch.** pycentauri pins your chosen mode and re-applies it
|
|
448
|
+
automatically, while still honoring a deliberate balanced from the
|
|
449
|
+
touchscreen — see
|
|
450
|
+
[CC2 speed pinning](#cc2-speed-pinning-the-firmware-fights-you-so-pycentauri-fights-back)
|
|
451
|
+
above for how it tells the two apart.
|
|
418
452
|
- **Registrations expire without an app-level PING.** The printer
|
|
419
453
|
forgets a registered client after several quiet minutes and silently
|
|
420
454
|
stops answering that session's requests — the MQTT connection itself
|
|
@@ -116,6 +116,42 @@ are silently ignored by the firmware:
|
|
|
116
116
|
|
|
117
117
|
Speed changes only take effect while a print is actively running.
|
|
118
118
|
|
|
119
|
+
#### CC2 speed pinning (the firmware fights you, so pycentauri fights back)
|
|
120
|
+
|
|
121
|
+
The CC2 firmware **resets the speed mode back to balanced on every
|
|
122
|
+
Canvas filament switch.** On a multi-color print that switch happens
|
|
123
|
+
every couple of minutes, so set sport once and the printer quietly
|
|
124
|
+
drops you to balanced at the next color change. This is firmware
|
|
125
|
+
behavior; it happens whether you set the speed from pycentauri or from
|
|
126
|
+
the printer's own touchscreen.
|
|
127
|
+
|
|
128
|
+
pycentauri works around it with **pin-and-enforce** (CC2 only, requires
|
|
129
|
+
`--enable-control`):
|
|
130
|
+
|
|
131
|
+
- **Whatever mode you select gets pinned.** Set it via pycentauri (pins
|
|
132
|
+
immediately) or on the touchscreen (pinned after it holds ~8 seconds —
|
|
133
|
+
the firmware never *resets* to a non-balanced mode, so a sustained
|
|
134
|
+
sport/ludicrous/silent must be a human).
|
|
135
|
+
- **A firmware reset is re-applied.** The reset fires a few seconds
|
|
136
|
+
*before* the head parks at the chute, so when the speed drops to
|
|
137
|
+
balanced pycentauri waits to see what follows: a filament switch
|
|
138
|
+
(the head parking) within ~12 seconds means it was the firmware, and
|
|
139
|
+
the pinned mode is re-applied as soon as the switch completes.
|
|
140
|
+
- **A human's balanced is honored.** If the speed drops to balanced and
|
|
141
|
+
*no* switch follows within ~12 seconds, that was you tapping balanced
|
|
142
|
+
on the touchscreen — the pin is released and balanced stays. (On the
|
|
143
|
+
wire a human tap is byte-identical to a firmware reset; the presence
|
|
144
|
+
or absence of the following switch is what tells them apart. Measured
|
|
145
|
+
reset→park lead times cluster at 6–8 s, well inside the window.)
|
|
146
|
+
- **The pin clears when the print ends.**
|
|
147
|
+
|
|
148
|
+
So both paths just work: set any speed from either the app or the
|
|
149
|
+
printer and it sticks across filament switches, and you can always
|
|
150
|
+
drop back to balanced from the touchscreen whenever you want.
|
|
151
|
+
|
|
152
|
+
The CC1 has none of this — its speed mode stays where you put it, so
|
|
153
|
+
`set_print_speed` is a plain one-shot there.
|
|
154
|
+
|
|
119
155
|
## Python library
|
|
120
156
|
|
|
121
157
|
```python
|
|
@@ -357,14 +393,12 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
357
393
|
cooldown of a few seconds during which the broker silently drops
|
|
358
394
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
359
395
|
should too.
|
|
360
|
-
- **The firmware
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
*balanced* on the touchscreen looks identical to a firmware reset
|
|
367
|
-
and will be reverted — set balanced through pycentauri instead.
|
|
396
|
+
- **The firmware resets the speed mode to balanced on every Canvas
|
|
397
|
+
filament switch.** pycentauri pins your chosen mode and re-applies it
|
|
398
|
+
automatically, while still honoring a deliberate balanced from the
|
|
399
|
+
touchscreen — see
|
|
400
|
+
[CC2 speed pinning](#cc2-speed-pinning-the-firmware-fights-you-so-pycentauri-fights-back)
|
|
401
|
+
above for how it tells the two apart.
|
|
368
402
|
- **Registrations expire without an app-level PING.** The printer
|
|
369
403
|
forgets a registered client after several quiet minutes and silently
|
|
370
404
|
stops answering that session's requests — the MQTT connection itself
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycentauri"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
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"
|
|
@@ -40,20 +40,21 @@ log = logging.getLogger(__name__)
|
|
|
40
40
|
MQTT_PORT = 1883
|
|
41
41
|
CC2_USERNAME = "elegoo"
|
|
42
42
|
PING_INTERVAL_S = 30.0
|
|
43
|
-
# Speed-mode pinning. The firmware
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
43
|
+
# Speed-mode pinning. The firmware resets speed_mode TO balanced (1) — never
|
|
44
|
+
# to any other mode — as the leading edge of every Canvas filament switch:
|
|
45
|
+
# the reset fires a few seconds before the head parks at the chute, while the
|
|
46
|
+
# status still reads "printing". Measured lead times (reset -> park) cluster
|
|
47
|
+
# tightly at 6-10 s (2026-07-05). We exploit that to tell a firmware reset
|
|
48
|
+
# apart from a human tapping "balanced" on the touchscreen (byte-identical on
|
|
49
|
+
# the wire): a drop to balanced followed by a park within HUMAN_WINDOW_S is
|
|
50
|
+
# the firmware and the pinned mode is re-applied when the switch completes;
|
|
51
|
+
# a drop that sits at balanced for HUMAN_WINDOW_S with NO switch is a human,
|
|
52
|
+
# and the pin is released. 12 s is ~1.5x the 8 s max lead time from the
|
|
53
|
+
# precise (sub-second) captures on 2026-07-05. A non-balanced mode from the
|
|
54
|
+
# touchscreen (held PIN_LEARN_S) is adopted as the new pin outright, since
|
|
55
|
+
# the firmware never produces one.
|
|
55
56
|
PIN_LEARN_S = 8.0
|
|
56
|
-
|
|
57
|
+
HUMAN_WINDOW_S = 12.0
|
|
57
58
|
ENFORCE_MIN_INTERVAL_S = 30.0
|
|
58
59
|
# Lifecycle commands (start/pause/stop/resume) are answered only after the
|
|
59
60
|
# firmware finishes the mechanical sequence — a resume reheats and unparks
|
|
@@ -284,7 +285,10 @@ class CC2Printer(Printer):
|
|
|
284
285
|
self._pinned_mode: int | None = None
|
|
285
286
|
self._pin_candidate: int | None = None
|
|
286
287
|
self._pin_candidate_since: float = 0.0
|
|
287
|
-
|
|
288
|
+
# Adjudicating a drop-to-balanced: when it started, and whether a
|
|
289
|
+
# filament switch (a park at the chute) has been seen since.
|
|
290
|
+
self._pending_since: float | None = None
|
|
291
|
+
self._switch_seen: bool = False
|
|
288
292
|
self._last_enforce: float = float("-inf")
|
|
289
293
|
self._enforce_task: asyncio.Task[None] | None = None
|
|
290
294
|
self._now = time.monotonic # overridable for tests
|
|
@@ -457,14 +461,6 @@ class CC2Printer(Printer):
|
|
|
457
461
|
|
|
458
462
|
async def set_print_speed(self, mode: str | int) -> sdcp.ParsedMessage:
|
|
459
463
|
self._require_control("set_print_speed")
|
|
460
|
-
if isinstance(mode, str) and mode.strip().lower() == "auto":
|
|
461
|
-
# Release the pin: stop enforcing and let the printer (and its
|
|
462
|
-
# touchscreen) own the speed mode again. No wire command needed.
|
|
463
|
-
self._pinned_mode = None
|
|
464
|
-
self._pin_candidate = None
|
|
465
|
-
self._mismatch_since = None
|
|
466
|
-
log.info("speed-mode pin released (auto)")
|
|
467
|
-
return self._wrap_result(1031, {"error_code": 0, "pin": "released"})
|
|
468
464
|
if isinstance(mode, str):
|
|
469
465
|
key = mode.strip().lower()
|
|
470
466
|
if key not in self.PRINT_SPEED_MODES:
|
|
@@ -483,7 +479,8 @@ class CC2Printer(Printer):
|
|
|
483
479
|
# An explicit choice through pycentauri pins the mode.
|
|
484
480
|
self._pinned_mode = value
|
|
485
481
|
self._pin_candidate = None
|
|
486
|
-
self.
|
|
482
|
+
self._pending_since = None
|
|
483
|
+
self._switch_seen = False
|
|
487
484
|
return self._wrap_result(1031, result)
|
|
488
485
|
|
|
489
486
|
async def set_fan_speed(
|
|
@@ -674,79 +671,112 @@ class CC2Printer(Printer):
|
|
|
674
671
|
def _track_speed_mode(self, payload: dict[str, Any]) -> None:
|
|
675
672
|
"""Pin-and-enforce the user's speed mode (runs on the asyncio loop).
|
|
676
673
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
674
|
+
See the module-level constants for the firmware behaviour this
|
|
675
|
+
works around. In short: a non-balanced mode set anywhere is pinned
|
|
676
|
+
(from the touchscreen after a brief hold) and re-applied after each
|
|
677
|
+
filament switch resets it; a drop to balanced is adjudicated by
|
|
678
|
+
whether a switch follows within ``HUMAN_WINDOW_S`` — switch means
|
|
679
|
+
firmware (re-apply), no switch means a human chose balanced
|
|
680
|
+
(release the pin).
|
|
684
681
|
"""
|
|
685
682
|
print_status = payload.get("PrintInfo", {}).get("Status")
|
|
686
683
|
speed_mode = payload.get("_cc2", {}).get("speed_mode")
|
|
687
684
|
if not isinstance(speed_mode, int) or print_status is None:
|
|
688
685
|
return
|
|
686
|
+
|
|
689
687
|
if print_status in (0, 8, 9, 14):
|
|
690
688
|
# Print over (idle/stopped/completed/error): the pin dies with it.
|
|
691
689
|
self._pinned_mode = None
|
|
692
690
|
self._pin_candidate = None
|
|
693
|
-
self.
|
|
691
|
+
self._pending_since = None
|
|
692
|
+
self._switch_seen = False
|
|
694
693
|
return
|
|
694
|
+
|
|
695
695
|
if print_status != 13:
|
|
696
|
-
#
|
|
697
|
-
#
|
|
698
|
-
self.
|
|
696
|
+
# Filament switch (27) / pausing / etc. A park during an
|
|
697
|
+
# in-flight adjudication is the firmware signature.
|
|
698
|
+
if print_status == 27 and self._pending_since is not None:
|
|
699
|
+
self._switch_seen = True
|
|
699
700
|
return
|
|
700
701
|
|
|
702
|
+
# --- printing (status 13) ---
|
|
701
703
|
now = self._now()
|
|
702
704
|
|
|
703
|
-
#
|
|
705
|
+
# A non-balanced mode we're not already pinned to can only be a
|
|
706
|
+
# human (the firmware never sets one) — adopt it after a brief hold.
|
|
704
707
|
if speed_mode != 1 and speed_mode != self._pinned_mode:
|
|
705
708
|
if self._pin_candidate == speed_mode:
|
|
706
709
|
if now - self._pin_candidate_since >= PIN_LEARN_S:
|
|
707
710
|
self._pinned_mode = speed_mode
|
|
708
711
|
self._pin_candidate = None
|
|
709
|
-
self.
|
|
712
|
+
self._pending_since = None
|
|
713
|
+
self._switch_seen = False
|
|
710
714
|
else:
|
|
711
715
|
self._pin_candidate = speed_mode
|
|
712
716
|
self._pin_candidate_since = now
|
|
713
|
-
|
|
714
|
-
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
self._pin_candidate = None
|
|
715
720
|
|
|
716
|
-
#
|
|
721
|
+
# Nothing to defend, or the pin is satisfied.
|
|
717
722
|
if self._pinned_mode is None or speed_mode == self._pinned_mode:
|
|
718
|
-
self.
|
|
723
|
+
self._pending_since = None
|
|
724
|
+
self._switch_seen = False
|
|
719
725
|
return
|
|
720
|
-
|
|
721
|
-
|
|
726
|
+
|
|
727
|
+
# speed_mode == 1 (balanced) while pinned to a faster mode: the
|
|
728
|
+
# ambiguous case. Adjudicate by whether a switch follows.
|
|
729
|
+
if self._pending_since is None:
|
|
730
|
+
self._pending_since = now
|
|
731
|
+
self._switch_seen = False
|
|
722
732
|
return
|
|
723
|
-
|
|
733
|
+
|
|
734
|
+
if self._switch_seen:
|
|
735
|
+
# Firmware reset confirmed by the switch — re-apply, and keep
|
|
736
|
+
# re-applying (rate-limited) until it takes. Do NOT clear
|
|
737
|
+
# _pending_since here: only a match (above) clears it.
|
|
738
|
+
self._enforce(speed_mode, now)
|
|
724
739
|
return
|
|
740
|
+
|
|
741
|
+
if now - self._pending_since >= HUMAN_WINDOW_S:
|
|
742
|
+
# Balanced held this long with no switch: a human chose it.
|
|
743
|
+
log.info(
|
|
744
|
+
"balanced held %0.0fs with no filament switch — releasing "
|
|
745
|
+
"the speed-mode pin (was %d)",
|
|
746
|
+
now - self._pending_since,
|
|
747
|
+
self._pinned_mode,
|
|
748
|
+
)
|
|
749
|
+
self._pinned_mode = None
|
|
750
|
+
self._pending_since = None
|
|
751
|
+
self._switch_seen = False
|
|
752
|
+
return
|
|
753
|
+
# Still inside the window with no switch yet — keep waiting.
|
|
754
|
+
|
|
755
|
+
def _enforce(self, current_mode: int, now: float) -> None:
|
|
756
|
+
assert self._pinned_mode is not None
|
|
725
757
|
if self._enforce_task is not None and not self._enforce_task.done():
|
|
726
758
|
return
|
|
727
759
|
if now - self._last_enforce < ENFORCE_MIN_INTERVAL_S:
|
|
728
760
|
return
|
|
729
761
|
self._last_enforce = now
|
|
730
762
|
if self.enable_control:
|
|
731
|
-
self.
|
|
763
|
+
self._enforce_task = asyncio.create_task(
|
|
764
|
+
self._apply_pinned_mode(self._pinned_mode),
|
|
765
|
+
name=f"pycentauri-cc2-speedpin-{self.host}",
|
|
766
|
+
)
|
|
732
767
|
else:
|
|
733
768
|
log.warning(
|
|
734
769
|
"speed_mode drifted to %d but pin is %d; enable_control is off "
|
|
735
770
|
"so it will not be re-applied",
|
|
736
|
-
|
|
771
|
+
current_mode,
|
|
737
772
|
self._pinned_mode,
|
|
738
773
|
)
|
|
739
774
|
|
|
740
|
-
def _launch_enforce(self, mode: int) -> None:
|
|
741
|
-
self._enforce_task = asyncio.create_task(
|
|
742
|
-
self._apply_pinned_mode(mode),
|
|
743
|
-
name=f"pycentauri-cc2-speedpin-{self.host}",
|
|
744
|
-
)
|
|
745
|
-
|
|
746
775
|
async def _apply_pinned_mode(self, mode: int) -> None:
|
|
776
|
+
await asyncio.sleep(2.0) # let the firmware settle after the switch
|
|
747
777
|
try:
|
|
748
778
|
await self._cc2_request(1031, {"mode": mode})
|
|
749
|
-
log.info("re-applied pinned speed_mode %d", mode)
|
|
779
|
+
log.info("re-applied pinned speed_mode %d after filament switch", mode)
|
|
750
780
|
except Exception:
|
|
751
781
|
log.warning("could not re-apply pinned speed_mode %d", mode, exc_info=True)
|
|
752
782
|
|
|
@@ -378,8 +378,7 @@ def cmd_speed(
|
|
|
378
378
|
str,
|
|
379
379
|
typer.Argument(
|
|
380
380
|
help="Speed mode: silent | balanced | sport | ludicrous "
|
|
381
|
-
"(or the integer 50 | 100 | 130 | 160).
|
|
382
|
-
"releases the speed pin and stops enforcing a mode.",
|
|
381
|
+
"(or the integer 50 | 100 | 130 | 160).",
|
|
383
382
|
),
|
|
384
383
|
],
|
|
385
384
|
host: HostOpt = None,
|
|
@@ -79,10 +79,6 @@
|
|
|
79
79
|
<button class="btn adj-mode" data-mode="ludicrous" type="button">
|
|
80
80
|
<small>160%</small> Ludicrous
|
|
81
81
|
</button>
|
|
82
|
-
<button class="btn adj-mode" data-mode="auto" type="button"
|
|
83
|
-
title="Release the speed pin (CC2): stop enforcing a mode and let the printer/touchscreen own it.">
|
|
84
|
-
<small>CC2</small> Auto
|
|
85
|
-
</button>
|
|
86
82
|
</div>
|
|
87
83
|
|
|
88
84
|
<div class="adj-sub">Fans</div>
|
|
@@ -272,7 +272,7 @@ def test_canvas_parse_survives_malformed_payloads() -> None:
|
|
|
272
272
|
assert CanvasStatus.from_payload({"canvas_info": {"canvas_list": "bogus"}}).tray_count == 0
|
|
273
273
|
|
|
274
274
|
|
|
275
|
-
# --- speed-mode pin &
|
|
275
|
+
# --- speed-mode pin, enforce & human/firmware adjudication ----------------
|
|
276
276
|
|
|
277
277
|
|
|
278
278
|
def _payload(status: int, mode: int) -> dict[str, Any]:
|
|
@@ -284,7 +284,18 @@ def _printer(enable_control: bool = True) -> Any:
|
|
|
284
284
|
|
|
285
285
|
p = CC2Printer("127.0.0.1", access_code="x", enable_control=enable_control)
|
|
286
286
|
p._fired: list[int] = []
|
|
287
|
-
p.
|
|
287
|
+
p._enforce_calls = 0
|
|
288
|
+
|
|
289
|
+
def fake_enforce(current_mode: int, now: float) -> None:
|
|
290
|
+
# Mirror the real rate-limit + record instead of spawning a task.
|
|
291
|
+
import pycentauri.cc2 as m
|
|
292
|
+
|
|
293
|
+
if now - p._last_enforce < m.ENFORCE_MIN_INTERVAL_S:
|
|
294
|
+
return
|
|
295
|
+
p._last_enforce = now
|
|
296
|
+
p._fired.append(p._pinned_mode)
|
|
297
|
+
|
|
298
|
+
p._enforce = fake_enforce
|
|
288
299
|
t = [0.0]
|
|
289
300
|
p._clock = t
|
|
290
301
|
p._now = lambda: t[0]
|
|
@@ -296,63 +307,99 @@ def _tick(p: Any, dt: float, status: int, mode: int) -> None:
|
|
|
296
307
|
p._track_speed_mode(_payload(status, mode))
|
|
297
308
|
|
|
298
309
|
|
|
299
|
-
|
|
310
|
+
# --- firmware reset: switch follows, mode is re-applied on resume ---------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_firmware_reset_reenforced_after_switch() -> None:
|
|
300
314
|
p = _printer()
|
|
301
|
-
p._pinned_mode = 2
|
|
302
|
-
_tick(p, 0, 13, 2)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
_tick(p, 5, 13, 1)
|
|
306
|
-
_tick(p, 5, 13, 1)
|
|
315
|
+
p._pinned_mode = 2
|
|
316
|
+
_tick(p, 0, 13, 2) # printing at sport
|
|
317
|
+
_tick(p, 3, 13, 1) # pre-switch reset (T=3)
|
|
318
|
+
_tick(p, 4, 13, 1) # still no park; within window, wait
|
|
307
319
|
assert p._fired == []
|
|
308
|
-
_tick(p,
|
|
309
|
-
_tick(p,
|
|
320
|
+
_tick(p, 2, 27, 1) # head parks -> switch seen
|
|
321
|
+
_tick(p, 180, 27, 1) # switch runs for 3 min
|
|
322
|
+
assert p._fired == [] # never released, never enforced mid-switch
|
|
323
|
+
_tick(p, 1, 13, 1) # resume, still balanced -> re-apply sport
|
|
310
324
|
assert p._fired == [2]
|
|
311
325
|
|
|
312
326
|
|
|
313
|
-
def
|
|
314
|
-
"""
|
|
327
|
+
def test_park_within_window_prevents_human_release() -> None:
|
|
328
|
+
"""A park inside the window must stop the human-release clock."""
|
|
315
329
|
p = _printer()
|
|
316
330
|
p._pinned_mode = 2
|
|
317
331
|
_tick(p, 0, 13, 2)
|
|
318
|
-
_tick(p,
|
|
319
|
-
_tick(p,
|
|
320
|
-
_tick(p,
|
|
321
|
-
_tick(p,
|
|
322
|
-
assert p.
|
|
323
|
-
# Resume: reset persists, enforcement kicks in after the grace period
|
|
324
|
-
_tick(p, 5, 13, 1)
|
|
325
|
-
_tick(p, 13, 13, 1)
|
|
332
|
+
_tick(p, 5, 13, 1) # reset at T=5
|
|
333
|
+
_tick(p, 4, 27, 1) # park inside the window -> switch seen
|
|
334
|
+
_tick(p, 20, 27, 1) # long switch, we are not printing
|
|
335
|
+
_tick(p, 1, 13, 1) # resume -> firmware, re-apply
|
|
336
|
+
assert p._pinned_mode == 2
|
|
326
337
|
assert p._fired == [2]
|
|
327
338
|
|
|
328
339
|
|
|
340
|
+
# --- human balanced: no switch, pin released ------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_human_balanced_releases_pin_after_window() -> None:
|
|
344
|
+
p = _printer()
|
|
345
|
+
p._pinned_mode = 2
|
|
346
|
+
_tick(p, 0, 13, 2) # printing at sport
|
|
347
|
+
_tick(p, 2, 13, 1) # user taps balanced (T=2)
|
|
348
|
+
_tick(p, 5, 13, 1) # T=7, no switch, still waiting
|
|
349
|
+
assert p._pinned_mode == 2
|
|
350
|
+
assert p._fired == []
|
|
351
|
+
_tick(p, 6, 13, 1) # still waiting (inside the window)
|
|
352
|
+
assert p._pinned_mode == 2
|
|
353
|
+
_tick(p, 5, 13, 1) # past the window, no switch -> release
|
|
354
|
+
assert p._pinned_mode is None
|
|
355
|
+
assert p._fired == []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_human_balanced_never_yanked_during_window() -> None:
|
|
359
|
+
"""The mode must stay balanced (no enforce) throughout the wait."""
|
|
360
|
+
p = _printer()
|
|
361
|
+
p._pinned_mode = 2
|
|
362
|
+
_tick(p, 0, 13, 2)
|
|
363
|
+
_tick(p, 1, 13, 1)
|
|
364
|
+
for _ in range(6):
|
|
365
|
+
_tick(p, 2, 13, 1) # poll every 2s up to ~13s
|
|
366
|
+
assert p._fired == [] # never re-applied sport while adjudicating
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- touchscreen non-balanced override ------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
329
372
|
def test_screen_change_to_nonbalanced_becomes_pin() -> None:
|
|
330
373
|
p = _printer()
|
|
331
|
-
_tick(p, 0, 13, 1) # printing
|
|
374
|
+
_tick(p, 0, 13, 1) # printing, nothing pinned
|
|
332
375
|
_tick(p, 5, 13, 3) # user picks ludicrous on the touchscreen
|
|
333
376
|
assert p._pinned_mode is None
|
|
334
|
-
_tick(p,
|
|
377
|
+
_tick(p, 9, 13, 3) # held > PIN_LEARN_S -> adopted
|
|
335
378
|
assert p._pinned_mode == 3
|
|
336
379
|
assert p._fired == []
|
|
337
380
|
|
|
338
381
|
|
|
339
|
-
def
|
|
382
|
+
def test_screen_override_beats_release_when_pinned() -> None:
|
|
383
|
+
"""Screen change sport->ludicrous while sport is pinned: adopt ludicrous,
|
|
384
|
+
never release or enforce."""
|
|
340
385
|
p = _printer()
|
|
341
386
|
p._pinned_mode = 2
|
|
342
387
|
_tick(p, 0, 13, 2)
|
|
343
|
-
|
|
344
|
-
_tick(p,
|
|
345
|
-
|
|
346
|
-
assert p.
|
|
347
|
-
|
|
348
|
-
assert p._fired == [2]
|
|
388
|
+
_tick(p, 2, 13, 3) # user picks ludicrous
|
|
389
|
+
_tick(p, 9, 13, 3) # held -> adopted as new pin
|
|
390
|
+
assert p._pinned_mode == 3
|
|
391
|
+
assert p._fired == []
|
|
392
|
+
|
|
349
393
|
|
|
394
|
+
# --- lifecycle ------------------------------------------------------------
|
|
350
395
|
|
|
351
|
-
|
|
396
|
+
|
|
397
|
+
def test_no_pin_no_action() -> None:
|
|
352
398
|
p = _printer()
|
|
353
399
|
_tick(p, 0, 13, 1)
|
|
354
400
|
_tick(p, 60, 13, 1)
|
|
355
401
|
assert p._fired == []
|
|
402
|
+
assert p._pinned_mode is None
|
|
356
403
|
|
|
357
404
|
|
|
358
405
|
def test_print_end_clears_pin() -> None:
|
|
@@ -361,49 +408,20 @@ def test_print_end_clears_pin() -> None:
|
|
|
361
408
|
_tick(p, 0, 13, 2)
|
|
362
409
|
_tick(p, 5, 9, 1) # completed
|
|
363
410
|
assert p._pinned_mode is None
|
|
364
|
-
_tick(p, 5, 13, 1) # a
|
|
411
|
+
_tick(p, 5, 13, 1) # a fresh print at balanced
|
|
365
412
|
_tick(p, 60, 13, 1)
|
|
366
413
|
assert p._fired == []
|
|
367
414
|
|
|
368
415
|
|
|
369
|
-
def
|
|
416
|
+
def test_enforce_rate_limited_across_repeated_switches() -> None:
|
|
370
417
|
p = _printer()
|
|
371
418
|
p._pinned_mode = 2
|
|
372
419
|
_tick(p, 0, 13, 2)
|
|
373
|
-
_tick(p,
|
|
374
|
-
_tick(p,
|
|
375
|
-
_tick(p,
|
|
376
|
-
_tick(p, 5, 13, 1)
|
|
420
|
+
_tick(p, 3, 13, 1) # reset
|
|
421
|
+
_tick(p, 4, 27, 1) # park
|
|
422
|
+
_tick(p, 60, 13, 1) # resume -> enforce (T=67)
|
|
377
423
|
assert p._fired == [2]
|
|
378
|
-
_tick(p,
|
|
424
|
+
_tick(p, 5, 13, 1) # still balanced (enforce not "taken" in this fake)
|
|
425
|
+
assert p._fired == [2] # within 30s cooldown, no repeat
|
|
426
|
+
_tick(p, 30, 13, 1) # cooldown elapsed -> enforce again
|
|
379
427
|
assert p._fired == [2, 2]
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
def test_screen_override_beats_enforcement() -> None:
|
|
383
|
-
"""A touchscreen change to a non-balanced mode must be learned BEFORE
|
|
384
|
-
enforcement of the old pin would revert it (PIN_LEARN_S < ENFORCE_AFTER_S)."""
|
|
385
|
-
p = _printer()
|
|
386
|
-
p._pinned_mode = 1 # balanced pinned via the API
|
|
387
|
-
_tick(p, 0, 13, 1)
|
|
388
|
-
_tick(p, 2, 13, 2) # user picks sport on the screen (mismatch clock starts)
|
|
389
|
-
_tick(p, 5, 13, 2) # 5 s held
|
|
390
|
-
_tick(p, 4, 13, 2) # 9 s held: learned as the new pin, before 12 s enforcement
|
|
391
|
-
assert p._pinned_mode == 2
|
|
392
|
-
assert p._fired == []
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def test_auto_releases_pin() -> None:
|
|
396
|
-
import asyncio
|
|
397
|
-
|
|
398
|
-
p = _printer()
|
|
399
|
-
p._pinned_mode = 2
|
|
400
|
-
|
|
401
|
-
async def run() -> None:
|
|
402
|
-
result = await p.set_print_speed("auto")
|
|
403
|
-
assert result.inner["pin"] == "released"
|
|
404
|
-
|
|
405
|
-
asyncio.run(run())
|
|
406
|
-
assert p._pinned_mode is None
|
|
407
|
-
_tick(p, 0, 13, 1)
|
|
408
|
-
_tick(p, 60, 13, 1) # balanced persists, nothing enforced
|
|
409
|
-
assert p._fired == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|