pycentauri 0.6.2__tar.gz → 0.6.3__tar.gz

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