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.
Files changed (32) hide show
  1. {pycentauri-0.6.0 → pycentauri-0.6.1}/CHANGELOG.md +19 -0
  2. {pycentauri-0.6.0 → pycentauri-0.6.1}/PKG-INFO +5 -1
  3. {pycentauri-0.6.0 → pycentauri-0.6.1}/README.md +4 -0
  4. {pycentauri-0.6.0 → pycentauri-0.6.1}/pyproject.toml +1 -1
  5. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/__init__.py +1 -1
  6. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/cc2.py +97 -4
  7. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/server.py +5 -0
  8. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_cc2_mapping.py +89 -0
  9. {pycentauri-0.6.0 → pycentauri-0.6.1}/.gitignore +0 -0
  10. {pycentauri-0.6.0 → pycentauri-0.6.1}/LICENSE +0 -0
  11. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/camera.py +0 -0
  12. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/cli.py +0 -0
  13. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/client.py +0 -0
  14. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/connect.py +0 -0
  15. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/discovery.py +0 -0
  16. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/__init__.py +0 -0
  17. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/__main__.py +0 -0
  18. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/mcp/server.py +0 -0
  19. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/models.py +0 -0
  20. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/py.typed +0 -0
  21. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/rtsp.py +0 -0
  22. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/sdcp.py +0 -0
  23. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/__init__.py +0 -0
  24. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/app.js +0 -0
  25. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/index.html +0 -0
  26. {pycentauri-0.6.0 → pycentauri-0.6.1}/src/pycentauri/web/styles.css +0 -0
  27. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/__init__.py +0 -0
  28. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_client.py +0 -0
  29. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_discovery.py +0 -0
  30. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_rtsp.py +0 -0
  31. {pycentauri-0.6.0 → pycentauri-0.6.1}/tests/test_sdcp.py +0 -0
  32. {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.0
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.0"
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"
@@ -39,4 +39,4 @@ __all__ = [
39
39
  "discover",
40
40
  ]
41
41
 
42
- __version__ = "0.6.0"
42
+ __version__ = "0.6.1"
@@ -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
- if self._ping_task is not None and not self._ping_task.done():
516
- self._ping_task.cancel()
517
- with contextlib.suppress(asyncio.CancelledError, Exception):
518
- await self._ping_task
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