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.
Files changed (32) hide show
  1. {pycentauri-0.6.0 → pycentauri-0.6.2}/CHANGELOG.md +47 -0
  2. {pycentauri-0.6.0 → pycentauri-0.6.2}/PKG-INFO +9 -1
  3. {pycentauri-0.6.0 → pycentauri-0.6.2}/README.md +8 -0
  4. {pycentauri-0.6.0 → pycentauri-0.6.2}/pyproject.toml +1 -1
  5. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/cc2.py +122 -4
  7. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/cli.py +2 -1
  8. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/server.py +5 -0
  9. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/index.html +4 -0
  10. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/styles.css +1 -1
  11. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_cc2_mapping.py +137 -0
  12. {pycentauri-0.6.0 → pycentauri-0.6.2}/.gitignore +0 -0
  13. {pycentauri-0.6.0 → pycentauri-0.6.2}/LICENSE +0 -0
  14. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/camera.py +0 -0
  15. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/client.py +0 -0
  16. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/connect.py +0 -0
  17. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/discovery.py +0 -0
  18. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/__init__.py +0 -0
  19. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/__main__.py +0 -0
  20. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/mcp/server.py +0 -0
  21. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/models.py +0 -0
  22. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/py.typed +0 -0
  23. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/rtsp.py +0 -0
  24. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/sdcp.py +0 -0
  25. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/__init__.py +0 -0
  26. {pycentauri-0.6.0 → pycentauri-0.6.2}/src/pycentauri/web/app.js +0 -0
  27. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/__init__.py +0 -0
  28. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_client.py +0 -0
  29. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_discovery.py +0 -0
  30. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_rtsp.py +0 -0
  31. {pycentauri-0.6.0 → pycentauri-0.6.2}/tests/test_sdcp.py +0 -0
  32. {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.0
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.0"
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"
@@ -39,4 +39,4 @@ __all__ = [
39
39
  "discover",
40
40
  ]
41
41
 
42
- __version__ = "0.6.0"
42
+ __version__ = "0.6.2"
@@ -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
- if self._ping_task is not None and not self._ping_task.done():
516
- self._ping_task.cancel()
517
- with contextlib.suppress(asyncio.CancelledError, Exception):
518
- await self._ping_task
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>
@@ -467,7 +467,7 @@ body {
467
467
 
468
468
  .adj-modes {
469
469
  display: grid;
470
- grid-template-columns: repeat(4, 1fr);
470
+ grid-template-columns: repeat(5, 1fr);
471
471
  gap: 4px;
472
472
  margin-top: 6px;
473
473
  }
@@ -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