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.
Files changed (32) hide show
  1. {pycentauri-0.6.1 → pycentauri-0.6.2}/CHANGELOG.md +28 -0
  2. {pycentauri-0.6.1 → pycentauri-0.6.2}/PKG-INFO +9 -5
  3. {pycentauri-0.6.1 → pycentauri-0.6.2}/README.md +8 -4
  4. {pycentauri-0.6.1 → pycentauri-0.6.2}/pyproject.toml +1 -1
  5. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/cc2.py +98 -73
  7. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/cli.py +2 -1
  8. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/index.html +4 -0
  9. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/styles.css +1 -1
  10. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_cc2_mapping.py +112 -64
  11. {pycentauri-0.6.1 → pycentauri-0.6.2}/.gitignore +0 -0
  12. {pycentauri-0.6.1 → pycentauri-0.6.2}/LICENSE +0 -0
  13. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/camera.py +0 -0
  14. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/client.py +0 -0
  15. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/connect.py +0 -0
  16. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/discovery.py +0 -0
  17. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/__init__.py +0 -0
  18. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/__main__.py +0 -0
  19. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/mcp/server.py +0 -0
  20. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/models.py +0 -0
  21. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/py.typed +0 -0
  22. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/rtsp.py +0 -0
  23. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/sdcp.py +0 -0
  24. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/server.py +0 -0
  25. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/__init__.py +0 -0
  26. {pycentauri-0.6.1 → pycentauri-0.6.2}/src/pycentauri/web/app.js +0 -0
  27. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/__init__.py +0 -0
  28. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_client.py +0 -0
  29. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_discovery.py +0 -0
  30. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_rtsp.py +0 -0
  31. {pycentauri-0.6.1 → pycentauri-0.6.2}/tests/test_sdcp.py +0 -0
  32. {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.1
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
- - **Canvas filament switches reset the speed mode.** Every mid-print
411
- switch silently drops the printer back to balanced. pycentauri
412
- detects the switch ending and re-applies whatever mode was active
413
- before it (needs `--enable-control`).
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
- - **Canvas filament switches reset the speed mode.** Every mid-print
361
- switch silently drops the printer back to balanced. pycentauri
362
- detects the switch ending and re-applies whatever mode was active
363
- before it (needs `--enable-control`).
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.1"
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.1"
42
+ __version__ = "0.6.2"
@@ -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
- # A speed mode must hold this long before it becomes the restore baseline —
44
- # the firmware resets speed_mode several seconds BEFORE the head parks for a
45
- # filament switch (observed 2026-07-05: reset at T-6s), and without the hold
46
- # that transient poisons the baseline.
47
- BASELINE_HOLD_S = 15.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
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 restore across Canvas filament switches: the firmware
274
- # resets speed_mode to balanced (1) on every mid-print switch
275
- # (verified 2026-07-05 mode stays 1 after the switch completes).
276
- self._speed_mode_during_print: int | None = None
277
- self._speed_mode_to_restore: int | None = None
278
- self._restore_task: asyncio.Task[None] | None = None
279
- self._mode_candidate: int | None = None
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
- # The user's explicit choice supersedes any pending post-switch
467
- # restore and becomes the new baseline.
468
- self._speed_mode_during_print = value
469
- self._mode_candidate = None
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._restore_task):
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
- """Restore the speed mode after a Canvas filament switch.
661
-
662
- The firmware resets ``speed_mode`` to balanced on every mid-print
663
- filament switch (verified 2026-07-05). We snapshot the mode in
664
- effect when a switch starts (print_status 27) and re-apply it once
665
- printing resumes, so the user's selection made from any surface,
666
- including the touchscreen survives the switch. Requires
667
- ``enable_control``; read-only sessions just log the reset.
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 == 27:
674
- if self._speed_mode_to_restore is None and self._speed_mode_during_print is not None:
675
- self._speed_mode_to_restore = self._speed_mode_during_print
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
- want = self._speed_mode_to_restore
680
- if want is not None:
681
- self._speed_mode_to_restore = None
682
- if want != speed_mode:
683
- if self.enable_control:
684
- self._restore_task = asyncio.create_task(
685
- self._restore_speed_mode(want),
686
- name=f"pycentauri-cc2-speedrestore-{self.host}",
687
- )
688
- else:
689
- log.warning(
690
- "filament switch reset speed_mode %d -> %d; "
691
- "enable_control is off so it will not be restored",
692
- want,
693
- speed_mode,
694
- )
695
- return
696
- # Track the running mode, but not while a restore is in flight —
697
- # the firmware still reports the reset value for a poll or two.
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._speed_mode_during_print is None:
701
- # Bootstrap: first observation is the baseline.
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 speed_mode == self._speed_mode_during_print:
705
- self._mode_candidate = None
723
+ if now - self._mismatch_since < ENFORCE_AFTER_S:
706
724
  return
707
- # Debounce: a changed mode must hold BASELINE_HOLD_S before it
708
- # becomes the restore baseline, so the firmware's pre-switch reset
709
- # (which fires seconds before the head parks) never gets adopted.
710
- now = self._now()
711
- if speed_mode != self._mode_candidate:
712
- self._mode_candidate = speed_mode
713
- self._mode_candidate_since = now
714
- elif now - self._mode_candidate_since >= BASELINE_HOLD_S:
715
- self._speed_mode_during_print = speed_mode
716
- self._mode_candidate = None
717
-
718
- async def _restore_speed_mode(self, mode: int) -> None:
719
- await asyncio.sleep(2.0) # let the firmware settle after the switch
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("restored speed_mode %d after filament switch", mode)
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>
@@ -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
  }
@@ -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 restore across filament switches -------------------------------
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 = False) -> Any:
282
+ def _printer(enable_control: bool = True) -> Any:
283
283
  from pycentauri.cc2 import CC2Printer
284
284
 
285
- return CC2Printer("127.0.0.1", access_code="x", enable_control=enable_control)
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 test_switch_snapshots_and_flags_restore(monkeypatch: Any) -> None:
289
- p = _printer(enable_control=False)
290
- # Printing at sport (2)
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 test_no_restore_when_mode_survives_switch() -> None:
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._track_speed_mode(_payload(13, 3))
305
- p._track_speed_mode(_payload(27, 3))
306
- assert p._speed_mode_to_restore == 3
307
- # Firmware kept the mode: nothing to do, baseline continues
308
- p._track_speed_mode(_payload(13, 3))
309
- assert p._speed_mode_to_restore is None
310
- assert p._speed_mode_during_print == 3
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 test_snapshot_not_overwritten_mid_switch() -> None:
339
+ def test_balanced_is_never_learned_as_pin_while_pinned() -> None:
314
340
  p = _printer()
315
- p._track_speed_mode(_payload(13, 2))
316
- p._track_speed_mode(_payload(27, 1))
317
- p._track_speed_mode(_payload(27, 1)) # more switch polls
318
- assert p._speed_mode_to_restore == 2
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 test_idle_states_do_not_touch_tracking() -> None:
351
+ def test_no_pin_means_no_enforcement() -> None:
322
352
  p = _printer()
323
- p._track_speed_mode(_payload(13, 2))
324
- p._track_speed_mode(_payload(0, 1)) # idle
325
- p._track_speed_mode(_payload(6, 1)) # paused
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 test_pre_switch_reset_does_not_poison_baseline() -> None:
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
- t = [0.0]
335
- p._now = lambda: t[0]
336
- p._track_speed_mode(_payload(13, 2)) # printing at sport (bootstrap)
337
- t[0] = 120.0
338
- p._track_speed_mode(_payload(13, 2)) # still sport
339
- # Pre-switch reset: mode drops while status is still 13
340
- t[0] = 126.0
341
- p._track_speed_mode(_payload(13, 1))
342
- t[0] = 129.0
343
- p._track_speed_mode(_payload(13, 1)) # held only 3 s — not promoted
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
- t = [0.0]
354
- p._now = lambda: t[0]
355
- p._track_speed_mode(_payload(13, 1)) # bootstrap balanced
356
- t[0] = 10.0
357
- p._track_speed_mode(_payload(13, 3)) # user sets ludicrous on screen
358
- assert p._speed_mode_during_print == 1 # not yet promoted
359
- t[0] = 26.0
360
- p._track_speed_mode(_payload(13, 3)) # held 16 s promoted
361
- assert p._speed_mode_during_print == 3
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