pycentauri 0.6.1__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.1 → pycentauri-0.6.3}/CHANGELOG.md +45 -0
  2. {pycentauri-0.6.1 → pycentauri-0.6.3}/PKG-INFO +43 -5
  3. {pycentauri-0.6.1 → pycentauri-0.6.3}/README.md +42 -4
  4. {pycentauri-0.6.1 → pycentauri-0.6.3}/pyproject.toml +1 -1
  5. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/cc2.py +127 -72
  7. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_cc2_mapping.py +130 -64
  8. {pycentauri-0.6.1 → pycentauri-0.6.3}/.gitignore +0 -0
  9. {pycentauri-0.6.1 → pycentauri-0.6.3}/LICENSE +0 -0
  10. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/camera.py +0 -0
  11. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/cli.py +0 -0
  12. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/client.py +0 -0
  13. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/connect.py +0 -0
  14. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/discovery.py +0 -0
  15. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/__init__.py +0 -0
  16. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/__main__.py +0 -0
  17. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/server.py +0 -0
  18. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/models.py +0 -0
  19. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/py.typed +0 -0
  20. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/rtsp.py +0 -0
  21. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/sdcp.py +0 -0
  22. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/server.py +0 -0
  23. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/__init__.py +0 -0
  24. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/app.js +0 -0
  25. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/index.html +0 -0
  26. {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/styles.css +0 -0
  27. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/__init__.py +0 -0
  28. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_client.py +0 -0
  29. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_discovery.py +0 -0
  30. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_rtsp.py +0 -0
  31. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_sdcp.py +0 -0
  32. {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_server.py +0 -0
@@ -6,6 +6,51 @@ 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
+
30
+ ## [0.6.2] - 2026-07-05
31
+
32
+ ### Changed
33
+ - **CC2 speed-mode persistence redesigned as pin-and-enforce.** The
34
+ 0.6.1 snapshot-per-switch restore lost an arms race with the
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
45
+ printing is re-applied, at most every 30 s. One consequence,
46
+ deliberate: while a mode is pinned, selecting *balanced* from the
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.
53
+
9
54
  ## [0.6.1] - 2026-07-05
10
55
 
11
56
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycentauri
3
- Version: 0.6.1
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,10 +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
- - **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`).
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.
414
452
  - **Registrations expire without an app-level PING.** The printer
415
453
  forgets a registered client after several quiet minutes and silently
416
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,10 +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
- - **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`).
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.
364
402
  - **Registrations expire without an app-level PING.** The printer
365
403
  forgets a registered client after several quiet minutes and silently
366
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.1"
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.1"
42
+ __version__ = "0.6.3"
@@ -40,11 +40,22 @@ 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 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.
56
+ PIN_LEARN_S = 8.0
57
+ HUMAN_WINDOW_S = 12.0
58
+ ENFORCE_MIN_INTERVAL_S = 30.0
48
59
  # Lifecycle commands (start/pause/stop/resume) are answered only after the
49
60
  # firmware finishes the mechanical sequence — a resume reheats and unparks
50
61
  # before responding, easily exceeding the default 15 s request timeout
@@ -270,14 +281,16 @@ class CC2Printer(Printer):
270
281
  self._last_full_result: dict[str, Any] | None = None
271
282
  self._connect_error: str | None = None
272
283
  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
284
+ # Speed-mode pinning (see module constants for the rationale).
285
+ self._pinned_mode: int | None = None
286
+ self._pin_candidate: int | None = None
287
+ self._pin_candidate_since: float = 0.0
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
292
+ self._last_enforce: float = float("-inf")
293
+ self._enforce_task: asyncio.Task[None] | None = None
281
294
  self._now = time.monotonic # overridable for tests
282
295
 
283
296
  @classmethod
@@ -463,12 +476,11 @@ class CC2Printer(Printer):
463
476
  f"{sorted(self.PRINT_SPEED_MODES.values())}"
464
477
  )
465
478
  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
479
+ # An explicit choice through pycentauri pins the mode.
480
+ self._pinned_mode = value
481
+ self._pin_candidate = None
482
+ self._pending_since = None
483
+ self._switch_seen = False
472
484
  return self._wrap_result(1031, result)
473
485
 
474
486
  async def set_fan_speed(
@@ -534,7 +546,7 @@ class CC2Printer(Printer):
534
546
  if self._closed:
535
547
  return
536
548
  self._closed = True
537
- for task in (self._ping_task, self._restore_task):
549
+ for task in (self._ping_task, self._enforce_task):
538
550
  if task is not None and not task.done():
539
551
  task.cancel()
540
552
  with contextlib.suppress(asyncio.CancelledError, Exception):
@@ -657,73 +669,116 @@ class CC2Printer(Printer):
657
669
  log.exception("failed to handle CC2 status push")
658
670
 
659
671
  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.
672
+ """Pin-and-enforce the user's speed mode (runs on the asyncio loop).
673
+
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).
668
681
  """
669
682
  print_status = payload.get("PrintInfo", {}).get("Status")
670
683
  speed_mode = payload.get("_cc2", {}).get("speed_mode")
671
684
  if not isinstance(speed_mode, int) or print_status is None:
672
685
  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
686
+
687
+ if print_status in (0, 8, 9, 14):
688
+ # Print over (idle/stopped/completed/error): the pin dies with it.
689
+ self._pinned_mode = None
690
+ self._pin_candidate = None
691
+ self._pending_since = None
692
+ self._switch_seen = False
676
693
  return
694
+
677
695
  if print_status != 13:
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
678
700
  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():
701
+
702
+ # --- printing (status 13) ---
703
+ now = self._now()
704
+
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.
707
+ if speed_mode != 1 and speed_mode != self._pinned_mode:
708
+ if self._pin_candidate == speed_mode:
709
+ if now - self._pin_candidate_since >= PIN_LEARN_S:
710
+ self._pinned_mode = speed_mode
711
+ self._pin_candidate = None
712
+ self._pending_since = None
713
+ self._switch_seen = False
714
+ else:
715
+ self._pin_candidate = speed_mode
716
+ self._pin_candidate_since = now
699
717
  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
718
+
719
+ self._pin_candidate = None
720
+
721
+ # Nothing to defend, or the pin is satisfied.
722
+ if self._pinned_mode is None or speed_mode == self._pinned_mode:
723
+ self._pending_since = None
724
+ self._switch_seen = False
703
725
  return
704
- if speed_mode == self._speed_mode_during_print:
705
- self._mode_candidate = None
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
706
732
  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:
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)
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
757
+ if self._enforce_task is not None and not self._enforce_task.done():
758
+ return
759
+ if now - self._last_enforce < ENFORCE_MIN_INTERVAL_S:
760
+ return
761
+ self._last_enforce = now
762
+ if self.enable_control:
763
+ self._enforce_task = asyncio.create_task(
764
+ self._apply_pinned_mode(self._pinned_mode),
765
+ name=f"pycentauri-cc2-speedpin-{self.host}",
766
+ )
767
+ else:
768
+ log.warning(
769
+ "speed_mode drifted to %d but pin is %d; enable_control is off "
770
+ "so it will not be re-applied",
771
+ current_mode,
772
+ self._pinned_mode,
773
+ )
774
+
775
+ async def _apply_pinned_mode(self, mode: int) -> None:
719
776
  await asyncio.sleep(2.0) # let the firmware settle after the switch
720
777
  try:
721
778
  await self._cc2_request(1031, {"mode": mode})
722
- log.info("restored speed_mode %d after filament switch", mode)
779
+ log.info("re-applied pinned speed_mode %d after filament switch", mode)
723
780
  except Exception:
724
- log.warning(
725
- "could not restore speed_mode %d after filament switch", mode, exc_info=True
726
- )
781
+ log.warning("could not re-apply pinned speed_mode %d", mode, exc_info=True)
727
782
 
728
783
  def _publish_register(self) -> None:
729
784
  topic = f"elegoo/{self._serial_number}/api_register"
@@ -272,90 +272,156 @@ 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 & human/firmware adjudication ----------------
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._enforce_calls = 0
286
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
287
292
 
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
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
299
+ t = [0.0]
300
+ p._clock = t
301
+ p._now = lambda: t[0]
302
+ return p
303
+
304
+
305
+ def _tick(p: Any, dt: float, status: int, mode: int) -> None:
306
+ p._clock[0] += dt
307
+ p._track_speed_mode(_payload(status, mode))
308
+
309
+
310
+ # --- firmware reset: switch follows, mode is re-applied on resume ---------
300
311
 
301
312
 
302
- def test_no_restore_when_mode_survives_switch() -> None:
313
+ def test_firmware_reset_reenforced_after_switch() -> None:
303
314
  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
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
319
+ assert p._fired == []
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
324
+ assert p._fired == [2]
325
+
326
+
327
+ def test_park_within_window_prevents_human_release() -> None:
328
+ """A park inside the window must stop the human-release clock."""
329
+ p = _printer()
330
+ p._pinned_mode = 2
331
+ _tick(p, 0, 13, 2)
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
337
+ assert p._fired == [2]
338
+
311
339
 
340
+ # --- human balanced: no switch, pin released ------------------------------
312
341
 
313
- def test_snapshot_not_overwritten_mid_switch() -> None:
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."""
314
360
  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
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
319
367
 
320
368
 
321
- def test_idle_states_do_not_touch_tracking() -> None:
369
+ # --- touchscreen non-balanced override ------------------------------------
370
+
371
+
372
+ def test_screen_change_to_nonbalanced_becomes_pin() -> None:
322
373
  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
374
+ _tick(p, 0, 13, 1) # printing, nothing pinned
375
+ _tick(p, 5, 13, 3) # user picks ludicrous on the touchscreen
376
+ assert p._pinned_mode is None
377
+ _tick(p, 9, 13, 3) # held > PIN_LEARN_S -> adopted
378
+ assert p._pinned_mode == 3
379
+ assert p._fired == []
328
380
 
329
381
 
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."""
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."""
333
385
  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:
386
+ p._pinned_mode = 2
387
+ _tick(p, 0, 13, 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
+
393
+
394
+ # --- lifecycle ------------------------------------------------------------
395
+
396
+
397
+ def test_no_pin_no_action() -> None:
352
398
  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
399
+ _tick(p, 0, 13, 1)
400
+ _tick(p, 60, 13, 1)
401
+ assert p._fired == []
402
+ assert p._pinned_mode is None
403
+
404
+
405
+ def test_print_end_clears_pin() -> None:
406
+ p = _printer()
407
+ p._pinned_mode = 2
408
+ _tick(p, 0, 13, 2)
409
+ _tick(p, 5, 9, 1) # completed
410
+ assert p._pinned_mode is None
411
+ _tick(p, 5, 13, 1) # a fresh print at balanced
412
+ _tick(p, 60, 13, 1)
413
+ assert p._fired == []
414
+
415
+
416
+ def test_enforce_rate_limited_across_repeated_switches() -> None:
417
+ p = _printer()
418
+ p._pinned_mode = 2
419
+ _tick(p, 0, 13, 2)
420
+ _tick(p, 3, 13, 1) # reset
421
+ _tick(p, 4, 27, 1) # park
422
+ _tick(p, 60, 13, 1) # resume -> enforce (T=67)
423
+ assert p._fired == [2]
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
427
+ assert p._fired == [2, 2]
File without changes
File without changes
File without changes