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.
- {pycentauri-0.6.1 → pycentauri-0.6.3}/CHANGELOG.md +45 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/PKG-INFO +43 -5
- {pycentauri-0.6.1 → pycentauri-0.6.3}/README.md +42 -4
- {pycentauri-0.6.1 → pycentauri-0.6.3}/pyproject.toml +1 -1
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/cc2.py +127 -72
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_cc2_mapping.py +130 -64
- {pycentauri-0.6.1 → pycentauri-0.6.3}/.gitignore +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/LICENSE +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/cli.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/client.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/connect.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/models.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/server.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/app.js +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/index.html +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/src/pycentauri/web/styles.css +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/__init__.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_client.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_discovery.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_rtsp.py +0 -0
- {pycentauri-0.6.1 → pycentauri-0.6.3}/tests/test_sdcp.py +0 -0
- {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.
|
|
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
|
-
- **
|
|
411
|
-
switch
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
- **
|
|
361
|
-
switch
|
|
362
|
-
|
|
363
|
-
|
|
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.
|
|
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"
|
|
@@ -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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
self.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
self.
|
|
280
|
-
self.
|
|
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
|
-
#
|
|
467
|
-
|
|
468
|
-
self.
|
|
469
|
-
self.
|
|
470
|
-
|
|
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.
|
|
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
|
-
"""
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
self.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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("
|
|
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
|
|
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 =
|
|
282
|
+
def _printer(enable_control: bool = True) -> Any:
|
|
283
283
|
from pycentauri.cc2 import CC2Printer
|
|
284
284
|
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
313
|
+
def test_firmware_reset_reenforced_after_switch() -> None:
|
|
303
314
|
p = _printer()
|
|
304
|
-
p.
|
|
305
|
-
p
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
p.
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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.
|
|
316
|
-
p
|
|
317
|
-
p
|
|
318
|
-
|
|
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
|
-
|
|
369
|
+
# --- touchscreen non-balanced override ------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_screen_change_to_nonbalanced_becomes_pin() -> None:
|
|
322
373
|
p = _printer()
|
|
323
|
-
p
|
|
324
|
-
p
|
|
325
|
-
p.
|
|
326
|
-
|
|
327
|
-
assert p.
|
|
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
|
|
331
|
-
"""
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
p
|
|
336
|
-
p
|
|
337
|
-
|
|
338
|
-
p.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
354
|
-
p
|
|
355
|
-
p.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
p
|
|
361
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|