pycentauri 0.6.0__tar.gz → 0.6.1__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.0 → pycentauri-0.6.1}/CHANGELOG.md +19 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/PKG-INFO +5 -1
- {pycentauri-0.6.0 → pycentauri-0.6.1}/README.md +4 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/pyproject.toml +1 -1
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/cc2.py +97 -4
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/server.py +5 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_cc2_mapping.py +89 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/.gitignore +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/LICENSE +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/cli.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/client.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/connect.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/models.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/app.js +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/index.html +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/styles.css +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/__init__.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_client.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_discovery.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_rtsp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_sdcp.py +0 -0
- {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_server.py +0 -0
|
@@ -6,6 +6,25 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.1] - 2026-07-05
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **CC2: the user's speed mode now survives Canvas filament switches.**
|
|
13
|
+
The firmware resets `speed_mode` to balanced on every mid-print
|
|
14
|
+
switch and leaves it there (verified 2026-07-05: mode stayed reset
|
|
15
|
+
after the switch completed). `CC2Printer` now snapshots the mode in
|
|
16
|
+
effect when a switch begins and re-applies it once printing resumes —
|
|
17
|
+
regardless of whether the mode was set via pycentauri or the
|
|
18
|
+
touchscreen. Requires `enable_control`; read-only sessions log the
|
|
19
|
+
reset instead. The restore baseline is debounced (a mode must hold
|
|
20
|
+
15 s to count) because the firmware fires its reset several seconds
|
|
21
|
+
*before* the head parks — without the debounce, that transient
|
|
22
|
+
poisoned the snapshot. Verified across two hands-free switch cycles
|
|
23
|
+
with journal-logged restores ~3 s after each resume.
|
|
24
|
+
- `centauri server` now surfaces pycentauri's own log lines (speed-mode
|
|
25
|
+
restores, reconnects) in its output — previously only uvicorn's
|
|
26
|
+
loggers were configured and library INFO logs were invisible.
|
|
27
|
+
|
|
9
28
|
## [0.6.0] - 2026-07-05
|
|
10
29
|
|
|
11
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Local-network toolkit for Elegoo Centauri Carbon 3D printers: async Python client, CLI, MCP server, REST/SSE HTTP server, web UI, and RTSP bridge
|
|
5
5
|
Project-URL: Homepage, https://github.com/bjan/pycentauri
|
|
6
6
|
Project-URL: Repository, https://github.com/bjan/pycentauri
|
|
@@ -407,6 +407,10 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
407
407
|
cooldown of a few seconds during which the broker silently drops
|
|
408
408
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
409
409
|
should too.
|
|
410
|
+
- **Canvas filament switches reset the speed mode.** Every mid-print
|
|
411
|
+
switch silently drops the printer back to balanced. pycentauri
|
|
412
|
+
detects the switch ending and re-applies whatever mode was active
|
|
413
|
+
before it (needs `--enable-control`).
|
|
410
414
|
- **Registrations expire without an app-level PING.** The printer
|
|
411
415
|
forgets a registered client after several quiet minutes and silently
|
|
412
416
|
stops answering that session's requests — the MQTT connection itself
|
|
@@ -357,6 +357,10 @@ reports code 27 the entire time the head is parked there mid-print.
|
|
|
357
357
|
cooldown of a few seconds during which the broker silently drops
|
|
358
358
|
responses. pycentauri's polling cadence stays under it; your scripts
|
|
359
359
|
should too.
|
|
360
|
+
- **Canvas filament switches reset the speed mode.** Every mid-print
|
|
361
|
+
switch silently drops the printer back to balanced. pycentauri
|
|
362
|
+
detects the switch ending and re-applies whatever mode was active
|
|
363
|
+
before it (needs `--enable-control`).
|
|
360
364
|
- **Registrations expire without an app-level PING.** The printer
|
|
361
365
|
forgets a registered client after several quiet minutes and silently
|
|
362
366
|
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.1"
|
|
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"
|
|
@@ -17,6 +17,7 @@ import contextlib
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
19
|
import random
|
|
20
|
+
import time
|
|
20
21
|
from collections.abc import AsyncIterator
|
|
21
22
|
from typing import Any, ClassVar
|
|
22
23
|
|
|
@@ -39,6 +40,11 @@ log = logging.getLogger(__name__)
|
|
|
39
40
|
MQTT_PORT = 1883
|
|
40
41
|
CC2_USERNAME = "elegoo"
|
|
41
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
|
|
42
48
|
# Lifecycle commands (start/pause/stop/resume) are answered only after the
|
|
43
49
|
# firmware finishes the mechanical sequence — a resume reheats and unparks
|
|
44
50
|
# before responding, easily exceeding the default 15 s request timeout
|
|
@@ -264,6 +270,15 @@ class CC2Printer(Printer):
|
|
|
264
270
|
self._last_full_result: dict[str, Any] | None = None
|
|
265
271
|
self._connect_error: str | None = None
|
|
266
272
|
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
|
|
281
|
+
self._now = time.monotonic # overridable for tests
|
|
267
282
|
|
|
268
283
|
@classmethod
|
|
269
284
|
async def connect(
|
|
@@ -352,6 +367,7 @@ class CC2Printer(Printer):
|
|
|
352
367
|
result = await self._cc2_request(1002, {}, timeout=timeout)
|
|
353
368
|
self._last_full_result = result
|
|
354
369
|
payload = _cc2_status_to_cc1(result)
|
|
370
|
+
self._track_speed_mode(payload)
|
|
355
371
|
st = Status.from_payload(payload)
|
|
356
372
|
self._latest_status = st
|
|
357
373
|
self._latest_status_event.set()
|
|
@@ -447,6 +463,12 @@ class CC2Printer(Printer):
|
|
|
447
463
|
f"{sorted(self.PRINT_SPEED_MODES.values())}"
|
|
448
464
|
)
|
|
449
465
|
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
|
|
450
472
|
return self._wrap_result(1031, result)
|
|
451
473
|
|
|
452
474
|
async def set_fan_speed(
|
|
@@ -512,10 +534,11 @@ class CC2Printer(Printer):
|
|
|
512
534
|
if self._closed:
|
|
513
535
|
return
|
|
514
536
|
self._closed = True
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
537
|
+
for task in (self._ping_task, self._restore_task):
|
|
538
|
+
if task is not None and not task.done():
|
|
539
|
+
task.cancel()
|
|
540
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
541
|
+
await task
|
|
519
542
|
if self._mqtt is not None:
|
|
520
543
|
self._mqtt.disconnect() # flush DISCONNECT before stopping the loop
|
|
521
544
|
self._mqtt.loop_stop()
|
|
@@ -623,6 +646,7 @@ class CC2Printer(Printer):
|
|
|
623
646
|
return
|
|
624
647
|
_deep_merge(self._last_full_result, result)
|
|
625
648
|
payload = _cc2_status_to_cc1(self._last_full_result)
|
|
649
|
+
self._track_speed_mode(payload)
|
|
626
650
|
st = Status.from_payload(payload)
|
|
627
651
|
self._latest_status = st
|
|
628
652
|
self._latest_status_event.set()
|
|
@@ -632,6 +656,75 @@ class CC2Printer(Printer):
|
|
|
632
656
|
except Exception:
|
|
633
657
|
log.exception("failed to handle CC2 status push")
|
|
634
658
|
|
|
659
|
+
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.
|
|
668
|
+
"""
|
|
669
|
+
print_status = payload.get("PrintInfo", {}).get("Status")
|
|
670
|
+
speed_mode = payload.get("_cc2", {}).get("speed_mode")
|
|
671
|
+
if not isinstance(speed_mode, int) or print_status is None:
|
|
672
|
+
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
|
|
676
|
+
return
|
|
677
|
+
if print_status != 13:
|
|
678
|
+
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():
|
|
699
|
+
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
|
|
703
|
+
return
|
|
704
|
+
if speed_mode == self._speed_mode_during_print:
|
|
705
|
+
self._mode_candidate = None
|
|
706
|
+
return
|
|
707
|
+
# Debounce: a changed mode must hold BASELINE_HOLD_S before it
|
|
708
|
+
# becomes the restore baseline, so the firmware's pre-switch reset
|
|
709
|
+
# (which fires seconds before the head parks) never gets adopted.
|
|
710
|
+
now = self._now()
|
|
711
|
+
if speed_mode != self._mode_candidate:
|
|
712
|
+
self._mode_candidate = speed_mode
|
|
713
|
+
self._mode_candidate_since = now
|
|
714
|
+
elif now - self._mode_candidate_since >= BASELINE_HOLD_S:
|
|
715
|
+
self._speed_mode_during_print = speed_mode
|
|
716
|
+
self._mode_candidate = None
|
|
717
|
+
|
|
718
|
+
async def _restore_speed_mode(self, mode: int) -> None:
|
|
719
|
+
await asyncio.sleep(2.0) # let the firmware settle after the switch
|
|
720
|
+
try:
|
|
721
|
+
await self._cc2_request(1031, {"mode": mode})
|
|
722
|
+
log.info("restored speed_mode %d after filament switch", mode)
|
|
723
|
+
except Exception:
|
|
724
|
+
log.warning(
|
|
725
|
+
"could not restore speed_mode %d after filament switch", mode, exc_info=True
|
|
726
|
+
)
|
|
727
|
+
|
|
635
728
|
def _publish_register(self) -> None:
|
|
636
729
|
topic = f"elegoo/{self._serial_number}/api_register"
|
|
637
730
|
payload = json.dumps({"client_id": self._client_id, "request_id": self._request_id_prefix})
|
|
@@ -747,6 +747,11 @@ def run(
|
|
|
747
747
|
"""
|
|
748
748
|
import uvicorn
|
|
749
749
|
|
|
750
|
+
# Surface pycentauri's own log lines (reconnects, speed-mode restores)
|
|
751
|
+
# in the journal — uvicorn only configures its own loggers, and the
|
|
752
|
+
# root logger's lastResort handler hides INFO.
|
|
753
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
|
754
|
+
|
|
750
755
|
app = create_app(
|
|
751
756
|
host,
|
|
752
757
|
enable_control=enable_control,
|
|
@@ -270,3 +270,92 @@ def test_canvas_parse_survives_malformed_payloads() -> None:
|
|
|
270
270
|
== 0
|
|
271
271
|
)
|
|
272
272
|
assert CanvasStatus.from_payload({"canvas_info": {"canvas_list": "bogus"}}).tray_count == 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- speed-mode restore across filament switches -------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _payload(status: int, mode: int) -> dict[str, Any]:
|
|
279
|
+
return {"PrintInfo": {"Status": status}, "_cc2": {"speed_mode": mode}}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _printer(enable_control: bool = False) -> Any:
|
|
283
|
+
from pycentauri.cc2 import CC2Printer
|
|
284
|
+
|
|
285
|
+
return CC2Printer("127.0.0.1", access_code="x", enable_control=enable_control)
|
|
286
|
+
|
|
287
|
+
|
|
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
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_no_restore_when_mode_survives_switch() -> None:
|
|
303
|
+
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
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_snapshot_not_overwritten_mid_switch() -> None:
|
|
314
|
+
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
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_idle_states_do_not_touch_tracking() -> None:
|
|
322
|
+
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
|
|
328
|
+
|
|
329
|
+
|
|
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."""
|
|
333
|
+
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:
|
|
352
|
+
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
|
|
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
|