pycentauri 0.6.1__tar.gz → 0.6.2__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.1 → pycentauri-0.6.2}/CHANGELOG.md +28 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/PKG-INFO +9 -5
- {pycentauri-0.6.1 → pycentauri-0.6.2}/README.md +8 -4
- {pycentauri-0.6.1 → pycentauri-0.6.2}/pyproject.toml +1 -1
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/cc2.py +98 -73
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/cli.py +2 -1
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/index.html +4 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/styles.css +1 -1
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_cc2_mapping.py +112 -64
- {pycentauri-0.6.1 → pycentauri-0.6.2}/.gitignore +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/LICENSE +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/client.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/connect.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/models.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/server.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/app.js +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_client.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_discovery.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_rtsp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_sdcp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_server.py +0 -0
|
@@ -6,6 +6,34 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.2] - 2026-07-05
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **CC2 speed-mode persistence redesigned as pin-and-enforce.** The
|
|
13
|
+
0.6.1 snapshot-per-switch restore lost an arms race with the
|
|
14
|
+
firmware: after eight consecutive successful restores, a reset
|
|
15
|
+
arriving outside the covered window (late after a restore, or
|
|
16
|
+
mid-print with no switch at all — observed 2026-07-05 ~15:00)
|
|
17
|
+
poisoned the baseline and silently disabled every restore after it.
|
|
18
|
+
The firmware only ever resets TO balanced, so the model is now: the
|
|
19
|
+
mode you set is pinned (immediately via pycentauri; after holding
|
|
20
|
+
15 s when set from the touchscreen, since a non-balanced mode can
|
|
21
|
+
only come from a human), and any disagreement lasting 12 s while
|
|
22
|
+
printing is re-applied, at most every 30 s. One consequence,
|
|
23
|
+
deliberate: while a mode is pinned, selecting *balanced* from the
|
|
24
|
+
touchscreen is indistinguishable from a firmware reset and will be
|
|
25
|
+
reverted — switch to balanced through pycentauri instead, which
|
|
26
|
+
re-pins. Pins clear when the print ends, and ``speed auto`` (CLI),
|
|
27
|
+
``{"mode": "auto"}`` (HTTP), or the web UI's Auto button release the
|
|
28
|
+
pin explicitly, returning speed control to the printer.
|
|
29
|
+
- Root cause fully mapped (wire captures, 2026-07-05): every reset is
|
|
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.
|
|
36
|
+
|
|
9
37
|
## [0.6.1] - 2026-07-05
|
|
10
38
|
|
|
11
39
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -407,10 +407,14 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
407
407
|
cooldown of a few seconds during which the broker silently drops
|
|
408
408
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
409
409
|
should too.
|
|
410
|
-
- **
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
410
|
+
- **The firmware keeps resetting the speed mode to balanced.** Around
|
|
411
|
+
every Canvas filament switch (starting seconds *before* the head
|
|
412
|
+
parks), and occasionally mid-print with no switch at all. pycentauri
|
|
413
|
+
pins the mode you set and re-applies it whenever the printer drifts
|
|
414
|
+
from it for more than ~12 s while printing (needs
|
|
415
|
+
`--enable-control`). Caveat: while a mode is pinned, picking
|
|
416
|
+
*balanced* on the touchscreen looks identical to a firmware reset
|
|
417
|
+
and will be reverted — set balanced through pycentauri instead.
|
|
414
418
|
- **Registrations expire without an app-level PING.** The printer
|
|
415
419
|
forgets a registered client after several quiet minutes and silently
|
|
416
420
|
stops answering that session's requests — the MQTT connection itself
|
|
@@ -357,10 +357,14 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
357
357
|
cooldown of a few seconds during which the broker silently drops
|
|
358
358
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
359
359
|
should too.
|
|
360
|
-
- **
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
360
|
+
- **The firmware keeps resetting the speed mode to balanced.** Around
|
|
361
|
+
every Canvas filament switch (starting seconds *before* the head
|
|
362
|
+
parks), and occasionally mid-print with no switch at all. pycentauri
|
|
363
|
+
pins the mode you set and re-applies it whenever the printer drifts
|
|
364
|
+
from it for more than ~12 s while printing (needs
|
|
365
|
+
`--enable-control`). Caveat: while a mode is pinned, picking
|
|
366
|
+
*balanced* on the touchscreen looks identical to a firmware reset
|
|
367
|
+
and will be reverted — set balanced through pycentauri instead.
|
|
364
368
|
- **Registrations expire without an app-level PING.** The printer
|
|
365
369
|
forgets a registered client after several quiet minutes and silently
|
|
366
370
|
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.2"
|
|
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,11 +40,21 @@ log = logging.getLogger(__name__)
|
|
|
40
40
|
MQTT_PORT = 1883
|
|
41
41
|
CC2_USERNAME = "elegoo"
|
|
42
42
|
PING_INTERVAL_S = 30.0
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
43
|
+
# Speed-mode pinning. The firmware only ever resets speed_mode TO balanced
|
|
44
|
+
# (1) — before filament switches (observed at T-6..10s before the head
|
|
45
|
+
# parks), sometimes again after a restore, and occasionally mid-print with
|
|
46
|
+
# no switch at all (observed 2026-07-05 ~15:00). A snapshot-per-switch
|
|
47
|
+
# design lost that arms race, so instead the mode a user selects is PINNED
|
|
48
|
+
# and enforced: a disagreement lasting ENFORCE_AFTER_S while printing gets
|
|
49
|
+
# re-applied, at most once per ENFORCE_MIN_INTERVAL_S. A sustained
|
|
50
|
+
# non-balanced mode from the touchscreen (held PIN_LEARN_S) adopts the pin,
|
|
51
|
+
# since the firmware never resets to a non-balanced mode.
|
|
52
|
+
# PIN_LEARN_S must be SHORTER than ENFORCE_AFTER_S: a touchscreen change to
|
|
53
|
+
# a non-balanced mode has to be adopted as the new pin before enforcement
|
|
54
|
+
# would revert it, or screen users could never override a pinned mode.
|
|
55
|
+
PIN_LEARN_S = 8.0
|
|
56
|
+
ENFORCE_AFTER_S = 12.0
|
|
57
|
+
ENFORCE_MIN_INTERVAL_S = 30.0
|
|
48
58
|
# Lifecycle commands (start/pause/stop/resume) are answered only after the
|
|
49
59
|
# firmware finishes the mechanical sequence — a resume reheats and unparks
|
|
50
60
|
# before responding, easily exceeding the default 15 s request timeout
|
|
@@ -270,14 +280,13 @@ class CC2Printer(Printer):
|
|
|
270
280
|
self._last_full_result: dict[str, Any] | None = None
|
|
271
281
|
self._connect_error: str | None = None
|
|
272
282
|
self._ping_task: asyncio.Task[None] | None = None
|
|
273
|
-
# Speed-mode
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
self.
|
|
277
|
-
self.
|
|
278
|
-
self.
|
|
279
|
-
self.
|
|
280
|
-
self._mode_candidate_since: float = 0.0
|
|
283
|
+
# Speed-mode pinning (see module constants for the rationale).
|
|
284
|
+
self._pinned_mode: int | None = None
|
|
285
|
+
self._pin_candidate: int | None = None
|
|
286
|
+
self._pin_candidate_since: float = 0.0
|
|
287
|
+
self._mismatch_since: float | None = None
|
|
288
|
+
self._last_enforce: float = float("-inf")
|
|
289
|
+
self._enforce_task: asyncio.Task[None] | None = None
|
|
281
290
|
self._now = time.monotonic # overridable for tests
|
|
282
291
|
|
|
283
292
|
@classmethod
|
|
@@ -448,6 +457,14 @@ class CC2Printer(Printer):
|
|
|
448
457
|
|
|
449
458
|
async def set_print_speed(self, mode: str | int) -> sdcp.ParsedMessage:
|
|
450
459
|
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"})
|
|
451
468
|
if isinstance(mode, str):
|
|
452
469
|
key = mode.strip().lower()
|
|
453
470
|
if key not in self.PRINT_SPEED_MODES:
|
|
@@ -463,12 +480,10 @@ class CC2Printer(Printer):
|
|
|
463
480
|
f"{sorted(self.PRINT_SPEED_MODES.values())}"
|
|
464
481
|
)
|
|
465
482
|
result = await self._cc2_request(1031, {"mode": value})
|
|
466
|
-
#
|
|
467
|
-
|
|
468
|
-
self.
|
|
469
|
-
self.
|
|
470
|
-
if self._speed_mode_to_restore is not None:
|
|
471
|
-
self._speed_mode_to_restore = value
|
|
483
|
+
# An explicit choice through pycentauri pins the mode.
|
|
484
|
+
self._pinned_mode = value
|
|
485
|
+
self._pin_candidate = None
|
|
486
|
+
self._mismatch_since = None
|
|
472
487
|
return self._wrap_result(1031, result)
|
|
473
488
|
|
|
474
489
|
async def set_fan_speed(
|
|
@@ -534,7 +549,7 @@ class CC2Printer(Printer):
|
|
|
534
549
|
if self._closed:
|
|
535
550
|
return
|
|
536
551
|
self._closed = True
|
|
537
|
-
for task in (self._ping_task, self.
|
|
552
|
+
for task in (self._ping_task, self._enforce_task):
|
|
538
553
|
if task is not None and not task.done():
|
|
539
554
|
task.cancel()
|
|
540
555
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
@@ -657,73 +672,83 @@ class CC2Printer(Printer):
|
|
|
657
672
|
log.exception("failed to handle CC2 status push")
|
|
658
673
|
|
|
659
674
|
def _track_speed_mode(self, payload: dict[str, Any]) -> None:
|
|
660
|
-
"""
|
|
661
|
-
|
|
662
|
-
The firmware resets ``speed_mode`` to balanced
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
675
|
+
"""Pin-and-enforce the user's speed mode (runs on the asyncio loop).
|
|
676
|
+
|
|
677
|
+
The firmware resets ``speed_mode`` to balanced around filament
|
|
678
|
+
switches and occasionally mid-print. Whatever mode the user set —
|
|
679
|
+
through pycentauri (pins immediately) or the touchscreen (pins
|
|
680
|
+
after holding ``PIN_LEARN_S``) — is re-applied whenever the
|
|
681
|
+
printer disagrees for ``ENFORCE_AFTER_S`` while printing. Only
|
|
682
|
+
non-balanced modes are learned from the wire, because a flip to
|
|
683
|
+
balanced is indistinguishable from a firmware reset.
|
|
668
684
|
"""
|
|
669
685
|
print_status = payload.get("PrintInfo", {}).get("Status")
|
|
670
686
|
speed_mode = payload.get("_cc2", {}).get("speed_mode")
|
|
671
687
|
if not isinstance(speed_mode, int) or print_status is None:
|
|
672
688
|
return
|
|
673
|
-
if print_status
|
|
674
|
-
|
|
675
|
-
|
|
689
|
+
if print_status in (0, 8, 9, 14):
|
|
690
|
+
# Print over (idle/stopped/completed/error): the pin dies with it.
|
|
691
|
+
self._pinned_mode = None
|
|
692
|
+
self._pin_candidate = None
|
|
693
|
+
self._mismatch_since = None
|
|
676
694
|
return
|
|
677
695
|
if print_status != 13:
|
|
696
|
+
# Switching filament, pausing, etc. — never learn or enforce
|
|
697
|
+
# here, and a mismatch clock from before doesn't carry across.
|
|
698
|
+
self._mismatch_since = None
|
|
678
699
|
return
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if self._restore_task is not None and not self._restore_task.done():
|
|
700
|
+
|
|
701
|
+
now = self._now()
|
|
702
|
+
|
|
703
|
+
# Learn a pin from the wire: only non-balanced modes qualify.
|
|
704
|
+
if speed_mode != 1 and speed_mode != self._pinned_mode:
|
|
705
|
+
if self._pin_candidate == speed_mode:
|
|
706
|
+
if now - self._pin_candidate_since >= PIN_LEARN_S:
|
|
707
|
+
self._pinned_mode = speed_mode
|
|
708
|
+
self._pin_candidate = None
|
|
709
|
+
self._mismatch_since = None
|
|
710
|
+
else:
|
|
711
|
+
self._pin_candidate = speed_mode
|
|
712
|
+
self._pin_candidate_since = now
|
|
713
|
+
elif speed_mode == self._pinned_mode:
|
|
714
|
+
self._pin_candidate = None
|
|
715
|
+
|
|
716
|
+
# Enforce the pin.
|
|
717
|
+
if self._pinned_mode is None or speed_mode == self._pinned_mode:
|
|
718
|
+
self._mismatch_since = None
|
|
699
719
|
return
|
|
700
|
-
if self.
|
|
701
|
-
|
|
702
|
-
self._speed_mode_during_print = speed_mode
|
|
720
|
+
if self._mismatch_since is None:
|
|
721
|
+
self._mismatch_since = now
|
|
703
722
|
return
|
|
704
|
-
if
|
|
705
|
-
self._mode_candidate = None
|
|
723
|
+
if now - self._mismatch_since < ENFORCE_AFTER_S:
|
|
706
724
|
return
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
self.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
725
|
+
if self._enforce_task is not None and not self._enforce_task.done():
|
|
726
|
+
return
|
|
727
|
+
if now - self._last_enforce < ENFORCE_MIN_INTERVAL_S:
|
|
728
|
+
return
|
|
729
|
+
self._last_enforce = now
|
|
730
|
+
if self.enable_control:
|
|
731
|
+
self._launch_enforce(self._pinned_mode)
|
|
732
|
+
else:
|
|
733
|
+
log.warning(
|
|
734
|
+
"speed_mode drifted to %d but pin is %d; enable_control is off "
|
|
735
|
+
"so it will not be re-applied",
|
|
736
|
+
speed_mode,
|
|
737
|
+
self._pinned_mode,
|
|
738
|
+
)
|
|
739
|
+
|
|
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
|
+
async def _apply_pinned_mode(self, mode: int) -> None:
|
|
720
747
|
try:
|
|
721
748
|
await self._cc2_request(1031, {"mode": mode})
|
|
722
|
-
log.info("
|
|
749
|
+
log.info("re-applied pinned speed_mode %d", mode)
|
|
723
750
|
except Exception:
|
|
724
|
-
log.warning(
|
|
725
|
-
"could not restore speed_mode %d after filament switch", mode, exc_info=True
|
|
726
|
-
)
|
|
751
|
+
log.warning("could not re-apply pinned speed_mode %d", mode, exc_info=True)
|
|
727
752
|
|
|
728
753
|
def _publish_register(self) -> None:
|
|
729
754
|
topic = f"elegoo/{self._serial_number}/api_register"
|
|
@@ -378,7 +378,8 @@ 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)."
|
|
381
|
+
"(or the integer 50 | 100 | 130 | 160). On a CC2, 'auto' "
|
|
382
|
+
"releases the speed pin and stops enforcing a mode.",
|
|
382
383
|
),
|
|
383
384
|
],
|
|
384
385
|
host: HostOpt = None,
|
|
@@ -79,6 +79,10 @@
|
|
|
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>
|
|
82
86
|
</div>
|
|
83
87
|
|
|
84
88
|
<div class="adj-sub">Fans</div>
|
|
@@ -272,90 +272,138 @@ 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
|
|
275
|
+
# --- speed-mode pin & enforce ---------------------------------------------
|
|
276
276
|
|
|
277
277
|
|
|
278
278
|
def _payload(status: int, mode: int) -> dict[str, Any]:
|
|
279
279
|
return {"PrintInfo": {"Status": status}, "_cc2": {"speed_mode": mode}}
|
|
280
280
|
|
|
281
281
|
|
|
282
|
-
def _printer(enable_control: bool =
|
|
282
|
+
def _printer(enable_control: bool = True) -> Any:
|
|
283
283
|
from pycentauri.cc2 import CC2Printer
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
p = CC2Printer("127.0.0.1", access_code="x", enable_control=enable_control)
|
|
286
|
+
p._fired: list[int] = []
|
|
287
|
+
p._launch_enforce = p._fired.append # record instead of spawning tasks
|
|
288
|
+
t = [0.0]
|
|
289
|
+
p._clock = t
|
|
290
|
+
p._now = lambda: t[0]
|
|
291
|
+
return p
|
|
286
292
|
|
|
287
293
|
|
|
288
|
-
def
|
|
289
|
-
p
|
|
290
|
-
|
|
291
|
-
p._track_speed_mode(_payload(13, 2))
|
|
292
|
-
assert p._speed_mode_during_print == 2
|
|
293
|
-
# Switch begins — snapshot taken
|
|
294
|
-
p._track_speed_mode(_payload(27, 1))
|
|
295
|
-
assert p._speed_mode_to_restore == 2
|
|
296
|
-
# Switch ends with the firmware reset to balanced; control off → no
|
|
297
|
-
# restore task, but the pending snapshot is consumed.
|
|
298
|
-
p._track_speed_mode(_payload(13, 1))
|
|
299
|
-
assert p._speed_mode_to_restore is None
|
|
294
|
+
def _tick(p: Any, dt: float, status: int, mode: int) -> None:
|
|
295
|
+
p._clock[0] += dt
|
|
296
|
+
p._track_speed_mode(_payload(status, mode))
|
|
300
297
|
|
|
301
298
|
|
|
302
|
-
def
|
|
299
|
+
def test_api_set_pins_immediately_and_enforces_after_grace() -> None:
|
|
300
|
+
p = _printer()
|
|
301
|
+
p._pinned_mode = 2 # what set_print_speed() records
|
|
302
|
+
_tick(p, 0, 13, 2)
|
|
303
|
+
assert p._fired == []
|
|
304
|
+
# Firmware resets to balanced mid-print; not enforced until it holds 12 s
|
|
305
|
+
_tick(p, 5, 13, 1)
|
|
306
|
+
_tick(p, 5, 13, 1)
|
|
307
|
+
assert p._fired == []
|
|
308
|
+
_tick(p, 5, 13, 1) # mismatch has now persisted 10 s... (first at t=10)
|
|
309
|
+
_tick(p, 5, 13, 1) # 15 s since mismatch start -> fires
|
|
310
|
+
assert p._fired == [2]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_pre_switch_reset_window_never_fires() -> None:
|
|
314
|
+
"""The 6-10 s balanced window before the head parks stays untouched."""
|
|
303
315
|
p = _printer()
|
|
304
|
-
p.
|
|
305
|
-
p
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
p
|
|
309
|
-
|
|
310
|
-
assert p.
|
|
316
|
+
p._pinned_mode = 2
|
|
317
|
+
_tick(p, 0, 13, 2)
|
|
318
|
+
_tick(p, 3, 13, 1) # pre-switch reset
|
|
319
|
+
_tick(p, 3, 13, 1) # 6 s in
|
|
320
|
+
_tick(p, 3, 27, 1) # head parks -> mismatch clock cleared
|
|
321
|
+
_tick(p, 60, 27, 1) # switch runs
|
|
322
|
+
assert p._fired == []
|
|
323
|
+
# Resume: reset persists, enforcement kicks in after the grace period
|
|
324
|
+
_tick(p, 5, 13, 1)
|
|
325
|
+
_tick(p, 13, 13, 1)
|
|
326
|
+
assert p._fired == [2]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_screen_change_to_nonbalanced_becomes_pin() -> None:
|
|
330
|
+
p = _printer()
|
|
331
|
+
_tick(p, 0, 13, 1) # printing at balanced, nothing pinned
|
|
332
|
+
_tick(p, 5, 13, 3) # user picks ludicrous on the touchscreen
|
|
333
|
+
assert p._pinned_mode is None
|
|
334
|
+
_tick(p, 16, 13, 3) # held > PIN_LEARN_S -> adopted
|
|
335
|
+
assert p._pinned_mode == 3
|
|
336
|
+
assert p._fired == []
|
|
311
337
|
|
|
312
338
|
|
|
313
|
-
def
|
|
339
|
+
def test_balanced_is_never_learned_as_pin_while_pinned() -> None:
|
|
314
340
|
p = _printer()
|
|
315
|
-
p.
|
|
316
|
-
p
|
|
317
|
-
|
|
318
|
-
|
|
341
|
+
p._pinned_mode = 2
|
|
342
|
+
_tick(p, 0, 13, 2)
|
|
343
|
+
# Sustained balanced (reset or screen tap) never displaces the pin...
|
|
344
|
+
_tick(p, 10, 13, 1)
|
|
345
|
+
_tick(p, 30, 13, 1)
|
|
346
|
+
assert p._pinned_mode == 2
|
|
347
|
+
# ...and enforcement fired for it
|
|
348
|
+
assert p._fired == [2]
|
|
319
349
|
|
|
320
350
|
|
|
321
|
-
def
|
|
351
|
+
def test_no_pin_means_no_enforcement() -> None:
|
|
322
352
|
p = _printer()
|
|
323
|
-
p
|
|
324
|
-
p
|
|
325
|
-
p.
|
|
326
|
-
assert p._speed_mode_during_print == 2
|
|
327
|
-
assert p._speed_mode_to_restore is None
|
|
353
|
+
_tick(p, 0, 13, 1)
|
|
354
|
+
_tick(p, 60, 13, 1)
|
|
355
|
+
assert p._fired == []
|
|
328
356
|
|
|
329
357
|
|
|
330
|
-
def
|
|
331
|
-
"""Replicates the live trace from 2026-07-05 14:10: the firmware
|
|
332
|
-
resets speed_mode ~6 s BEFORE the head parks for the switch."""
|
|
358
|
+
def test_print_end_clears_pin() -> None:
|
|
333
359
|
p = _printer()
|
|
334
|
-
|
|
335
|
-
p
|
|
336
|
-
p
|
|
337
|
-
|
|
338
|
-
p
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
assert p._speed_mode_during_print == 2
|
|
345
|
-
# Head parks, switch state
|
|
346
|
-
t[0] = 132.0
|
|
347
|
-
p._track_speed_mode(_payload(27, 1))
|
|
348
|
-
assert p._speed_mode_to_restore == 2 # snapshot is the REAL baseline
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def test_sustained_mode_change_becomes_baseline() -> None:
|
|
360
|
+
p._pinned_mode = 2
|
|
361
|
+
_tick(p, 0, 13, 2)
|
|
362
|
+
_tick(p, 5, 9, 1) # completed
|
|
363
|
+
assert p._pinned_mode is None
|
|
364
|
+
_tick(p, 5, 13, 1) # a new print at balanced: nothing enforced
|
|
365
|
+
_tick(p, 60, 13, 1)
|
|
366
|
+
assert p._fired == []
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_enforce_rate_limited() -> None:
|
|
352
370
|
p = _printer()
|
|
353
|
-
|
|
354
|
-
p
|
|
355
|
-
p
|
|
356
|
-
|
|
357
|
-
p
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
p
|
|
361
|
-
assert p.
|
|
371
|
+
p._pinned_mode = 2
|
|
372
|
+
_tick(p, 0, 13, 2)
|
|
373
|
+
_tick(p, 5, 13, 1)
|
|
374
|
+
_tick(p, 13, 13, 1) # fires (mismatch held 13 s)
|
|
375
|
+
_tick(p, 5, 13, 1) # still mismatched, but inside the 30 s cooldown
|
|
376
|
+
_tick(p, 5, 13, 1)
|
|
377
|
+
assert p._fired == [2]
|
|
378
|
+
_tick(p, 30, 13, 1) # cooldown over -> fires again
|
|
379
|
+
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
|