pycentauri 0.6.0__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.0 → pycentauri-0.6.2}/CHANGELOG.md +47 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/PKG-INFO +9 -1
- {pycentauri-0.6.0 → pycentauri-0.6.2}/README.md +8 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/pyproject.toml +1 -1
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/cc2.py +122 -4
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/cli.py +2 -1
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/server.py +5 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/index.html +4 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/styles.css +1 -1
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_cc2_mapping.py +137 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/.gitignore +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/LICENSE +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/client.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/connect.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/models.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/app.js +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_client.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_discovery.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_rtsp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_sdcp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_server.py +0 -0
|
@@ -6,6 +6,53 @@ 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
|
+
|
|
37
|
+
## [0.6.1] - 2026-07-05
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **CC2: the user's speed mode now survives Canvas filament switches.**
|
|
41
|
+
The firmware resets `speed_mode` to balanced on every mid-print
|
|
42
|
+
switch and leaves it there (verified 2026-07-05: mode stayed reset
|
|
43
|
+
after the switch completed). `CC2Printer` now snapshots the mode in
|
|
44
|
+
effect when a switch begins and re-applies it once printing resumes —
|
|
45
|
+
regardless of whether the mode was set via pycentauri or the
|
|
46
|
+
touchscreen. Requires `enable_control`; read-only sessions log the
|
|
47
|
+
reset instead. The restore baseline is debounced (a mode must hold
|
|
48
|
+
15 s to count) because the firmware fires its reset several seconds
|
|
49
|
+
*before* the head parks — without the debounce, that transient
|
|
50
|
+
poisoned the snapshot. Verified across two hands-free switch cycles
|
|
51
|
+
with journal-logged restores ~3 s after each resume.
|
|
52
|
+
- `centauri server` now surfaces pycentauri's own log lines (speed-mode
|
|
53
|
+
restores, reconnects) in its output — previously only uvicorn's
|
|
54
|
+
loggers were configured and library INFO logs were invisible.
|
|
55
|
+
|
|
9
56
|
## [0.6.0] - 2026-07-05
|
|
10
57
|
|
|
11
58
|
### Added
|
|
@@ -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,6 +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
|
+
- **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.
|
|
410
418
|
- **Registrations expire without an app-level PING.** The printer
|
|
411
419
|
forgets a registered client after several quiet minutes and silently
|
|
412
420
|
stops answering that session's requests — the MQTT connection itself
|
|
@@ -357,6 +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
|
+
- **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.
|
|
360
368
|
- **Registrations expire without an app-level PING.** The printer
|
|
361
369
|
forgets a registered client after several quiet minutes and silently
|
|
362
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"
|
|
@@ -17,6 +17,7 @@ import contextlib
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
19
|
import random
|
|
20
|
+
import time
|
|
20
21
|
from collections.abc import AsyncIterator
|
|
21
22
|
from typing import Any, ClassVar
|
|
22
23
|
|
|
@@ -39,6 +40,21 @@ log = logging.getLogger(__name__)
|
|
|
39
40
|
MQTT_PORT = 1883
|
|
40
41
|
CC2_USERNAME = "elegoo"
|
|
41
42
|
PING_INTERVAL_S = 30.0
|
|
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
|
|
42
58
|
# Lifecycle commands (start/pause/stop/resume) are answered only after the
|
|
43
59
|
# firmware finishes the mechanical sequence — a resume reheats and unparks
|
|
44
60
|
# before responding, easily exceeding the default 15 s request timeout
|
|
@@ -264,6 +280,14 @@ class CC2Printer(Printer):
|
|
|
264
280
|
self._last_full_result: dict[str, Any] | None = None
|
|
265
281
|
self._connect_error: str | None = None
|
|
266
282
|
self._ping_task: asyncio.Task[None] | None = None
|
|
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
|
|
290
|
+
self._now = time.monotonic # overridable for tests
|
|
267
291
|
|
|
268
292
|
@classmethod
|
|
269
293
|
async def connect(
|
|
@@ -352,6 +376,7 @@ class CC2Printer(Printer):
|
|
|
352
376
|
result = await self._cc2_request(1002, {}, timeout=timeout)
|
|
353
377
|
self._last_full_result = result
|
|
354
378
|
payload = _cc2_status_to_cc1(result)
|
|
379
|
+
self._track_speed_mode(payload)
|
|
355
380
|
st = Status.from_payload(payload)
|
|
356
381
|
self._latest_status = st
|
|
357
382
|
self._latest_status_event.set()
|
|
@@ -432,6 +457,14 @@ class CC2Printer(Printer):
|
|
|
432
457
|
|
|
433
458
|
async def set_print_speed(self, mode: str | int) -> sdcp.ParsedMessage:
|
|
434
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"})
|
|
435
468
|
if isinstance(mode, str):
|
|
436
469
|
key = mode.strip().lower()
|
|
437
470
|
if key not in self.PRINT_SPEED_MODES:
|
|
@@ -447,6 +480,10 @@ class CC2Printer(Printer):
|
|
|
447
480
|
f"{sorted(self.PRINT_SPEED_MODES.values())}"
|
|
448
481
|
)
|
|
449
482
|
result = await self._cc2_request(1031, {"mode": 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
|
|
450
487
|
return self._wrap_result(1031, result)
|
|
451
488
|
|
|
452
489
|
async def set_fan_speed(
|
|
@@ -512,10 +549,11 @@ class CC2Printer(Printer):
|
|
|
512
549
|
if self._closed:
|
|
513
550
|
return
|
|
514
551
|
self._closed = True
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
552
|
+
for task in (self._ping_task, self._enforce_task):
|
|
553
|
+
if task is not None and not task.done():
|
|
554
|
+
task.cancel()
|
|
555
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
556
|
+
await task
|
|
519
557
|
if self._mqtt is not None:
|
|
520
558
|
self._mqtt.disconnect() # flush DISCONNECT before stopping the loop
|
|
521
559
|
self._mqtt.loop_stop()
|
|
@@ -623,6 +661,7 @@ class CC2Printer(Printer):
|
|
|
623
661
|
return
|
|
624
662
|
_deep_merge(self._last_full_result, result)
|
|
625
663
|
payload = _cc2_status_to_cc1(self._last_full_result)
|
|
664
|
+
self._track_speed_mode(payload)
|
|
626
665
|
st = Status.from_payload(payload)
|
|
627
666
|
self._latest_status = st
|
|
628
667
|
self._latest_status_event.set()
|
|
@@ -632,6 +671,85 @@ class CC2Printer(Printer):
|
|
|
632
671
|
except Exception:
|
|
633
672
|
log.exception("failed to handle CC2 status push")
|
|
634
673
|
|
|
674
|
+
def _track_speed_mode(self, payload: dict[str, Any]) -> None:
|
|
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.
|
|
684
|
+
"""
|
|
685
|
+
print_status = payload.get("PrintInfo", {}).get("Status")
|
|
686
|
+
speed_mode = payload.get("_cc2", {}).get("speed_mode")
|
|
687
|
+
if not isinstance(speed_mode, int) or print_status is None:
|
|
688
|
+
return
|
|
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
|
|
694
|
+
return
|
|
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
|
|
699
|
+
return
|
|
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
|
|
719
|
+
return
|
|
720
|
+
if self._mismatch_since is None:
|
|
721
|
+
self._mismatch_since = now
|
|
722
|
+
return
|
|
723
|
+
if now - self._mismatch_since < ENFORCE_AFTER_S:
|
|
724
|
+
return
|
|
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:
|
|
747
|
+
try:
|
|
748
|
+
await self._cc2_request(1031, {"mode": mode})
|
|
749
|
+
log.info("re-applied pinned speed_mode %d", mode)
|
|
750
|
+
except Exception:
|
|
751
|
+
log.warning("could not re-apply pinned speed_mode %d", mode, exc_info=True)
|
|
752
|
+
|
|
635
753
|
def _publish_register(self) -> None:
|
|
636
754
|
topic = f"elegoo/{self._serial_number}/api_register"
|
|
637
755
|
payload = json.dumps({"client_id": self._client_id, "request_id": self._request_id_prefix})
|
|
@@ -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,
|
|
@@ -747,6 +747,11 @@ def run(
|
|
|
747
747
|
"""
|
|
748
748
|
import uvicorn
|
|
749
749
|
|
|
750
|
+
# Surface pycentauri's own log lines (reconnects, speed-mode restores)
|
|
751
|
+
# in the journal — uvicorn only configures its own loggers, and the
|
|
752
|
+
# root logger's lastResort handler hides INFO.
|
|
753
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
|
754
|
+
|
|
750
755
|
app = create_app(
|
|
751
756
|
host,
|
|
752
757
|
enable_control=enable_control,
|
|
@@ -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>
|
|
@@ -270,3 +270,140 @@ def test_canvas_parse_survives_malformed_payloads() -> None:
|
|
|
270
270
|
== 0
|
|
271
271
|
)
|
|
272
272
|
assert CanvasStatus.from_payload({"canvas_info": {"canvas_list": "bogus"}}).tray_count == 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- speed-mode pin & enforce ---------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _payload(status: int, mode: int) -> dict[str, Any]:
|
|
279
|
+
return {"PrintInfo": {"Status": status}, "_cc2": {"speed_mode": mode}}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _printer(enable_control: bool = True) -> Any:
|
|
283
|
+
from pycentauri.cc2 import CC2Printer
|
|
284
|
+
|
|
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
|
|
292
|
+
|
|
293
|
+
|
|
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))
|
|
297
|
+
|
|
298
|
+
|
|
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."""
|
|
315
|
+
p = _printer()
|
|
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 == []
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_balanced_is_never_learned_as_pin_while_pinned() -> None:
|
|
340
|
+
p = _printer()
|
|
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]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_no_pin_means_no_enforcement() -> None:
|
|
352
|
+
p = _printer()
|
|
353
|
+
_tick(p, 0, 13, 1)
|
|
354
|
+
_tick(p, 60, 13, 1)
|
|
355
|
+
assert p._fired == []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_print_end_clears_pin() -> None:
|
|
359
|
+
p = _printer()
|
|
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:
|
|
370
|
+
p = _printer()
|
|
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
|