scrollkit 0.8.4__tar.gz → 0.8.5__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 (117) hide show
  1. {scrollkit-0.8.4/src/scrollkit.egg-info → scrollkit-0.8.5}/PKG-INFO +1 -2
  2. {scrollkit-0.8.4 → scrollkit-0.8.5}/README.md +1 -2
  3. {scrollkit-0.8.4 → scrollkit-0.8.5}/pyproject.toml +1 -1
  4. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/__init__.py +1 -1
  5. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_graphics.py +26 -0
  6. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/image_animators.py +134 -10
  7. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/http_client.py +34 -0
  8. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/wifi_manager.py +14 -6
  9. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/client.py +103 -17
  10. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/display_progress.py +6 -0
  11. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/wifi_setup.py +2 -1
  12. {scrollkit-0.8.4 → scrollkit-0.8.5/src/scrollkit.egg-info}/PKG-INFO +1 -2
  13. {scrollkit-0.8.4 → scrollkit-0.8.5}/LICENSE +0 -0
  14. {scrollkit-0.8.4 → scrollkit-0.8.5}/setup.cfg +0 -0
  15. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/__init__.py +0 -0
  16. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/base.py +0 -0
  17. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/memory.py +0 -0
  18. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/__init__.py +0 -0
  19. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/settings_manager.py +0 -0
  20. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/transition_names.py +0 -0
  21. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/__init__.py +0 -0
  22. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/capabilities.py +0 -0
  23. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/harness.py +0 -0
  24. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/metrics.py +0 -0
  25. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/performance.py +0 -0
  26. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/validation.py +0 -0
  27. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/__init__.py +0 -0
  28. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_recording.py +0 -0
  29. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_sim_backend.py +0 -0
  30. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/bitmap_text.py +0 -0
  31. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/boards.py +0 -0
  32. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/colors.py +0 -0
  33. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/content.py +0 -0
  34. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/gradient_text.py +0 -0
  35. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/interface.py +0 -0
  36. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/simulator.py +0 -0
  37. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/text_fill.py +0 -0
  38. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/text_pixels.py +0 -0
  39. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/unified.py +0 -0
  40. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/__init__.py +0 -0
  41. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/drip_splash.py +0 -0
  42. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/easing.py +0 -0
  43. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/overlay.py +0 -0
  44. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/particles.py +0 -0
  45. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/reveal_splash.py +0 -0
  46. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/scrolling.py +0 -0
  47. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/swarm_reveal.py +0 -0
  48. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/text_render.py +0 -0
  49. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/transitions.py +0 -0
  50. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/exceptions.py +0 -0
  51. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/__init__.py +0 -0
  52. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/mdns.py +0 -0
  53. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/__init__.py +0 -0
  54. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/manifest.py +0 -0
  55. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/publish.py +0 -0
  56. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/ATTRIBUTION.md +0 -0
  57. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +0 -0
  58. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/LICENSE +0 -0
  59. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/README.md +0 -0
  60. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/__init__.py +0 -0
  61. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/__init__.py +0 -0
  62. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +0 -0
  63. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +0 -0
  64. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_display_text/__init__.py +0 -0
  65. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_display_text/label.py +0 -0
  66. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/bitmaptools.py +0 -0
  67. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/__init__.py +0 -0
  68. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/color_utils.py +0 -0
  69. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/device_benchmarks.json +0 -0
  70. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/feasibility.py +0 -0
  71. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/hardware_profile.py +0 -0
  72. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/led_matrix.py +0 -0
  73. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/matrixportal_s3_baseline.json +0 -0
  74. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/performance_manager.py +0 -0
  75. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/pixel_buffer.py +0 -0
  76. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/__init__.py +0 -0
  77. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/base_device.py +0 -0
  78. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/matrixportal_s3.py +0 -0
  79. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/__init__.py +0 -0
  80. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/bitmap.py +0 -0
  81. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/display.py +0 -0
  82. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/fourwire.py +0 -0
  83. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/group.py +0 -0
  84. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/ondiskbitmap.py +0 -0
  85. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/palette.py +0 -0
  86. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/tilegrid.py +0 -0
  87. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/3x5.bdf +0 -0
  88. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_16.bdf +0 -0
  89. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_16.bdf.license +0 -0
  90. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf +0 -0
  91. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +0 -0
  92. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf +0 -0
  93. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +0 -0
  94. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf +0 -0
  95. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf.license +0 -0
  96. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +0 -0
  97. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +0 -0
  98. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +0 -0
  99. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +0 -0
  100. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +0 -0
  101. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +0 -0
  102. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/tom-thumb.bdf +0 -0
  103. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/viii-bold.bdf +0 -0
  104. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/viii.bdf +0 -0
  105. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/terminalio/__init__.py +0 -0
  106. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/__init__.py +0 -0
  107. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/color_utils.py +0 -0
  108. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/diagnostics.py +0 -0
  109. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/error_handler.py +0 -0
  110. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/system_utils.py +0 -0
  111. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/url_utils.py +0 -0
  112. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/__init__.py +0 -0
  113. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/settings_server.py +0 -0
  114. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/SOURCES.txt +0 -0
  115. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/dependency_links.txt +0 -0
  116. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/requires.txt +0 -0
  117. {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrollkit
3
- Version: 0.8.4
3
+ Version: 0.8.5
4
4
  Summary: LED Matrix Display Framework for CircuitPython and Desktop
5
5
  Author-email: Michael Czeiszperger <michael@czei.org>
6
6
  License: MIT
@@ -228,7 +228,6 @@ if is_dev_mode():
228
228
  | Platform | Backend | Status |
229
229
  |---|---|---|
230
230
  | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
231
- | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
232
231
  | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
233
232
  | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
234
233
 
@@ -184,7 +184,6 @@ if is_dev_mode():
184
184
  | Platform | Backend | Status |
185
185
  |---|---|---|
186
186
  | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
187
- | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
188
187
  | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
189
188
  | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
190
189
 
@@ -208,4 +207,4 @@ of his code — but the idea came from his project, and it deserves the shout-ou
208
207
 
209
208
  ## License
210
209
 
211
- MIT
210
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrollkit"
7
- version = "0.8.4"
7
+ version = "0.8.5"
8
8
  description = "LED Matrix Display Framework for CircuitPython and Desktop"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,4 +13,4 @@ need from submodules, e.g.::
13
13
  from scrollkit.display.unified import UnifiedDisplay
14
14
  """
15
15
 
16
- __version__ = "0.8.4"
16
+ __version__ = "0.8.5"
@@ -120,6 +120,32 @@ class GraphicsMixin:
120
120
  lg.pop(i)
121
121
  return
122
122
 
123
+ def clear_layers(self):
124
+ """Strip EVERY persistent layer — the takeover blank.
125
+
126
+ ``clear()`` deliberately leaves ``_layer_group`` alone (per-frame label
127
+ reset must not disturb long-lived effect layers), so a full-screen
128
+ takeover message needs this instead: without it, an interrupted ride
129
+ screen's bitmap layers stay composited and the takeover text paints ON
130
+ TOP of them (the "Updating — DO NOT UNPLUG over the ride list" bug,
131
+ 2026-07-12). Content objects whose layers vanish are unaffected later:
132
+ ``remove_layer`` is idempotent.
133
+
134
+ The bounded painter's canvas lives in ``_layer_group`` too, so its refs
135
+ are reset here — ``_ensure_paint`` rebuilds a composited canvas on the
136
+ next ``set_pixel``/``fill_rect`` (stale refs would silently draw into a
137
+ bitmap no longer in the displayio tree).
138
+ """
139
+ lg = getattr(self, "_layer_group", None)
140
+ if lg is None:
141
+ return
142
+ while len(lg):
143
+ lg.pop()
144
+ self._paint_bitmap = None
145
+ self._paint_palette = None
146
+ self._paint_tile = None
147
+ self._paint_colors = {}
148
+
123
149
  # --- bounded painters -----------------------------------------------------
124
150
  def _ensure_paint(self):
125
151
  if self._paint_bitmap is not None:
@@ -579,7 +579,9 @@ class RegionRotateAnimator(IntroAnimator):
579
579
  ci = bitmap[x, y]
580
580
  if ci != 0 and not self._in_excl(x, y): # body pixels stay put
581
581
  src[(x, y)] = ci
582
- r = math.hypot(x - self._px, y - self._py)
582
+ dx = x - self._px
583
+ dy = y - self._py
584
+ r = math.sqrt(dx * dx + dy * dy) # math.hypot is absent on CircuitPython
583
585
  if r > maxr:
584
586
  maxr = r
585
587
  if not src or len(src) > 320:
@@ -598,16 +600,18 @@ class RegionRotateAnimator(IntroAnimator):
598
600
  self._hidden = False
599
601
 
600
602
  def _stamp(self, ang):
601
- bmp = self.bitmap
602
- for x, y in self._stamped: # erase only what we last drew
603
- bmp[x, y] = 0
604
603
  # forward is dest = R(ang)*(p-pivot)+pivot, so the pre-image of a dest pixel is
605
604
  # src = R(-ang)*(dest-pivot)+pivot. R(-ang) = [[cos, sin], [-sin, cos]].
605
+ # Compute the whole new pose FIRST (no bitmap writes), then apply it as a
606
+ # DIFF against the previous pose. The device panel refreshes continuously
607
+ # from the bitmap, so the old erase-everything-then-slowly-redraw left a
608
+ # visible "head blinks out" window on every restamp (the desktop simulator
609
+ # only presents between steps, which is why it never showed there).
606
610
  ca = math.cos(ang)
607
611
  sa = math.sin(ang)
608
612
  px, py = self._px, self._py
609
613
  src = self._src
610
- stamped = []
614
+ new = {}
611
615
  excl = self._excl
612
616
  for Y in range(self._sy0, self._sy1 + 1):
613
617
  dY = Y - py
@@ -619,9 +623,15 @@ class RegionRotateAnimator(IntroAnimator):
619
623
  sy = int(round(py - dX * sa + dY * ca))
620
624
  ci = src.get((sx, sy))
621
625
  if ci:
622
- bmp[X, Y] = ci
623
- stamped.append((X, Y))
624
- self._stamped = stamped
626
+ new[(X, Y)] = ci
627
+ bmp = self.bitmap
628
+ for xy in self._stamped: # erase only pixels LEAVING the pose
629
+ if xy not in new:
630
+ bmp[xy[0], xy[1]] = 0
631
+ for xy, ci in new.items(): # write only pixels that changed
632
+ if bmp[xy[0], xy[1]] != ci:
633
+ bmp[xy[0], xy[1]] = ci
634
+ self._stamped = list(new)
625
635
  self._last_ang = ang
626
636
  self._hidden = False
627
637
 
@@ -1074,14 +1084,36 @@ class CelWalkAnimator(IntroAnimator):
1074
1084
  HOLD_FRAMES = 104 # a full off-screen-to-off-screen crossing plus several strides
1075
1085
 
1076
1086
  def __init__(self, sheet_suffix="_walk", period=6, bob=0, path="traverse_lr",
1077
- tile_w=None, tile_h=None):
1087
+ tile_w=None, tile_h=None, head_box=None, head_pivot=None,
1088
+ head_amp_deg=0, head_period=None, head_steps=5):
1089
+ """Create an authored cel walk, optionally with a procedurally nodding head.
1090
+
1091
+ ``head_box`` is an inclusive ``(x0, y0, x1, y1)`` box in each cel and
1092
+ ``head_pivot`` is the fixed neck joint. When both are supplied with a
1093
+ non-zero ``head_amp_deg``, the head pixels are split from every authored
1094
+ cel and a small set of rotated head poses is baked at :meth:`start`.
1095
+ The walk still costs only tile-index and position writes per displayed
1096
+ frame; the one-time bake avoids rotating pixels on the MatrixPortal.
1097
+
1098
+ The box must include only the head/neck and the transparent space its arc
1099
+ crosses. The default ``head_period`` is one complete authored gait cycle
1100
+ (``period * number_of_cels``), and ``head_steps`` is the number of baked
1101
+ angles across ``-head_amp_deg`` through ``+head_amp_deg``.
1102
+ """
1078
1103
  self._suffix = sheet_suffix
1079
1104
  self._period = max(1, period) # display frames per pose (the gait clock)
1080
1105
  self._bob = bob
1081
1106
  self._path = path
1082
1107
  self._tw = tile_w
1083
1108
  self._th = tile_h
1109
+ self._head_box = tuple(head_box) if head_box is not None else None
1110
+ self._head_pivot = tuple(head_pivot) if head_pivot is not None else None
1111
+ self._head_amp = head_amp_deg * 0.017453292519943295
1112
+ self._head_period = head_period
1113
+ steps = max(3, head_steps)
1114
+ self._head_steps = steps if steps & 1 else steps + 1
1084
1115
  self._tile = None
1116
+ self._head_tile = None
1085
1117
  self._odb = None
1086
1118
 
1087
1119
  def _sheet_path(self):
@@ -1108,17 +1140,98 @@ class CelWalkAnimator(IntroAnimator):
1108
1140
  n = sheet.width // tw
1109
1141
  if n < 1:
1110
1142
  raise ValueError("cel_walk: sheet narrower than one tile")
1111
- gtile = display.gfx.TileGrid(sheet, pixel_shader=pal, tile_width=tw, tile_height=th)
1143
+ head_enabled = self._head_box is not None or self._head_pivot is not None
1144
+ if head_enabled:
1145
+ if self._head_box is None or self._head_pivot is None or not self._head_amp:
1146
+ raise ValueError("cel_walk: head rotation needs box, pivot, and non-zero amplitude")
1147
+ sheet, head_sheet = self._bake_head_poses(display.gfx, self._sheet_path(),
1148
+ n, tw, th, len(pal))
1149
+ gtile = display.gfx.TileGrid(sheet, pixel_shader=pal,
1150
+ tile_width=tw, tile_height=th)
1151
+ head_tile = display.gfx.TileGrid(head_sheet, pixel_shader=pal,
1152
+ tile_width=tw, tile_height=th)
1153
+ else:
1154
+ gtile = display.gfx.TileGrid(sheet, pixel_shader=pal, tile_width=tw, tile_height=th)
1155
+ head_tile = None
1112
1156
  # Commit: blank the static base sprite, then composite the walking strip above it.
1113
1157
  self._odb = odb
1114
1158
  self._tile = gtile
1159
+ self._head_tile = head_tile
1115
1160
  self._n = n
1116
1161
  self._apply(0)
1117
1162
  bitmap.fill(0)
1118
1163
  display.add_layer(self._tile)
1164
+ if self._head_tile is not None:
1165
+ display.add_layer(self._head_tile)
1166
+
1167
+ def _bake_head_poses(self, gfx, sheet_path, n, tw, th, ncolors):
1168
+ """Return a headless cel sheet plus a sheet of pre-rotated head poses.
1169
+
1170
+ A sheet read from ``OnDiskBitmap`` cannot be inspected or changed on
1171
+ CircuitPython. Decode it once only when this opt-in feature is used;
1172
+ the base sheet retains the authored body/leg poses while the sparse head
1173
+ sheet supplies ``n * head_steps`` rotated alternatives.
1174
+ """
1175
+ x0, y0, x1, y1 = self._head_box
1176
+ px, py = self._head_pivot
1177
+ if x0 < 0 or y0 < 0 or x1 < x0 or y1 < y0 or x1 >= tw or y1 >= th:
1178
+ raise ValueError("cel_walk: head box outside a cel")
1179
+ if not (0 <= px < tw and 0 <= py < th):
1180
+ raise ValueError("cel_walk: head pivot outside a cel")
1181
+
1182
+ base = read_indexed_bmp(gfx, sheet_path)
1183
+ head_sheet = gfx.Bitmap(tw * n * self._head_steps, th, ncolors)
1184
+ max_radius = 1.0
1185
+ head_pixels = []
1186
+ for pose in range(n):
1187
+ pixels = {}
1188
+ offset = pose * tw
1189
+ for y in range(y0, y1 + 1):
1190
+ for x in range(x0, x1 + 1):
1191
+ ci = base[offset + x, y]
1192
+ if ci:
1193
+ pixels[(x, y)] = ci
1194
+ base[offset + x, y] = 0
1195
+ dx, dy = x - px, y - py
1196
+ radius = math.sqrt(dx * dx + dy * dy)
1197
+ if radius > max_radius:
1198
+ max_radius = radius
1199
+ if not pixels:
1200
+ raise ValueError("cel_walk: head box has no pixels in pose %d" % pose)
1201
+ head_pixels.append(pixels)
1202
+
1203
+ margin = int(max_radius * abs(math.sin(self._head_amp))) + 1
1204
+ sx0, sx1 = max(0, x0 - margin), min(tw - 1, x1 + margin)
1205
+ sy0, sy1 = max(0, y0 - margin), min(th - 1, y1 + margin)
1206
+ for pose, pixels in enumerate(head_pixels):
1207
+ for step in range(self._head_steps):
1208
+ # Equally-spaced angles include the upright rest pose because
1209
+ # head_steps is always odd.
1210
+ angle = self._head_amp * ((2.0 * step / (self._head_steps - 1)) - 1.0)
1211
+ ca, sa = math.cos(angle), math.sin(angle)
1212
+ cell_offset = (pose * self._head_steps + step) * tw
1213
+ # Inverse-map the bounded destination rectangle so the rotated
1214
+ # head stays solid instead of developing forward-map holes.
1215
+ for Y in range(sy0, sy1 + 1):
1216
+ dy = Y - py
1217
+ for X in range(sx0, sx1 + 1):
1218
+ dx = X - px
1219
+ sx = int(round(px + dx * ca + dy * sa))
1220
+ sy = int(round(py - dx * sa + dy * ca))
1221
+ ci = pixels.get((sx, sy))
1222
+ if ci:
1223
+ head_sheet[cell_offset + X, Y] = ci
1224
+ return base, head_sheet
1119
1225
 
1120
1226
  def _apply(self, frame):
1121
1227
  self._tile[0, 0] = (frame // self._period) % self._n # gait clock -> which pose
1228
+ head_tile = self._head_tile
1229
+ if head_tile is not None:
1230
+ cycle = self._head_period or (self._period * self._n)
1231
+ amount = (math.sin(6.2832 * frame / float(max(1, cycle))) + 1.0) * 0.5
1232
+ head_step = int(round(amount * (self._head_steps - 1)))
1233
+ pose = (frame // self._period) % self._n
1234
+ head_tile[0, 0] = pose * self._head_steps + head_step
1122
1235
  span = self.HOLD_FRAMES - 1 if self.HOLD_FRAMES > 1 else 1
1123
1236
  t = frame / span
1124
1237
  if t > 1.0:
@@ -1126,13 +1239,23 @@ class CelWalkAnimator(IntroAnimator):
1126
1239
  x0, x1 = ((self._off, -self._off) if self._path == "traverse_rl"
1127
1240
  else (-self._off, self._off))
1128
1241
  self._tile.x = int(round(x0 + (x1 - x0) * t))
1242
+ if head_tile is not None:
1243
+ head_tile.x = self._tile.x
1129
1244
  if self._bob:
1130
1245
  self._tile.y = int(round(self._bob * math.sin(frame * 0.3)))
1246
+ if head_tile is not None:
1247
+ head_tile.y = self._tile.y
1131
1248
 
1132
1249
  def step(self, frame):
1133
1250
  self._apply(frame)
1134
1251
 
1135
1252
  def detach(self):
1253
+ head = getattr(self, "_head_tile", None)
1254
+ if head is not None and getattr(self, "display", None) is not None:
1255
+ try:
1256
+ self.display.remove_layer(head)
1257
+ except Exception:
1258
+ pass
1136
1259
  t = getattr(self, "_tile", None)
1137
1260
  if t is not None and getattr(self, "display", None) is not None:
1138
1261
  try:
@@ -1143,6 +1266,7 @@ class CelWalkAnimator(IntroAnimator):
1143
1266
  # (CircuitPython has a small open-file limit). No base-restore — the sprite walked off,
1144
1267
  # the base is blank, the fade shows empty sky (matches MotionAnimator traverse).
1145
1268
  self._tile = None
1269
+ self._head_tile = None
1146
1270
  self._odb = None
1147
1271
 
1148
1272
 
@@ -324,6 +324,28 @@ class HttpClient:
324
324
  # a rebuild doesn't immediately rebuild again (thrash).
325
325
  self._failures_since_rebuild = 0
326
326
 
327
+ def close_pooled_sockets(self):
328
+ """Properly close every socket the current session's connection manager
329
+ holds, releasing their NATIVE resources (each pooled TLS socket pins an
330
+ mbedtls context — ~40 KB of the ESP32-S3's ~320 KB internal SRAM, which
331
+ PSRAM cannot substitute for). Public so callers needing native headroom
332
+ for a new TLS handshake (e.g. an OTA check to a second host) can make
333
+ room through a supported API. The pool stays valid: the manager opens
334
+ fresh sockets transparently on the next request.
335
+
336
+ Returns True if a close-all ran. Never raises."""
337
+ session = self.session
338
+ if session is None:
339
+ return False
340
+ try:
341
+ from adafruit_connection_manager import connection_manager_close_all
342
+ pool = session._connection_manager._socket_pool
343
+ connection_manager_close_all(socket_pool=pool)
344
+ return True
345
+ except Exception as e:
346
+ _logger().error(e, "close_pooled_sockets failed")
347
+ return False
348
+
327
349
  def _rebuild_session(self):
328
350
  """Tear down and recreate the adafruit_requests session.
329
351
 
@@ -332,9 +354,21 @@ class HttpClient:
332
354
  failure short of a reboot. Device-only (the imports exist on CircuitPython
333
355
  only); a no-op that returns False on desktop, where the urllib path never
334
356
  uses a session. Never raises into the caller.
357
+
358
+ HYGIENE IS LOAD-BEARING: the old session's pooled sockets must be
359
+ properly CLOSED before the replacement exists — dropping them to the GC
360
+ does not promptly release their native mbedtls TLS contexts. With a
361
+ rebuild threshold of 2, every pair of transient blips on a multi-day run
362
+ orphaned another ~40 KB of internal SRAM, until TLS handshakes (data
363
+ path AND the OTA check) died with mbedtls PK_ALLOC_FAILED / MemoryError.
364
+ Found 2026-07-11 after a ~30 h soak left the field device in
365
+ 'STALE (network issues)'.
335
366
  """
336
367
  if not (self.using_adafruit and self.session is not None):
337
368
  return False
369
+ # Release the wedged pool's native sockets/TLS contexts first — this is
370
+ # both the leak fix and what frees room for the replacement context.
371
+ self.close_pooled_sockets()
338
372
  try:
339
373
  import socketpool
340
374
  import wifi
@@ -47,21 +47,29 @@ class WiFiManager:
47
47
  Manages WiFi connections for the application
48
48
  """
49
49
 
50
- def __init__(self, settings_manager):
50
+ def __init__(self, settings_manager, ap_name=None):
51
51
  """
52
52
  Initialize the WiFi manager
53
-
53
+
54
54
  Args:
55
55
  settings_manager: The settings manager
56
+ ap_name: Brand name for the setup-portal access point. The SSID a
57
+ customer sees while onboarding is ``"<ap_name>-<tail>"`` where
58
+ the tail is a short MAC-derived suffix (kept library-side so two
59
+ un-onboarded boxes in one home never broadcast identical SSIDs).
60
+ Apps SHOULD pass their product name — the default is the
61
+ generic ``WifiManager`` prefix, deliberately not a product name
62
+ (branding belongs to the app, never hardwired here).
56
63
  """
57
64
  self.settings_manager = settings_manager
65
+ self._ap_base = ap_name or "WifiManager"
58
66
  self.ssid, self.password = self._resolve_credentials()
59
67
  self.is_connected = False
60
68
  self.wifi_client = None
61
69
  self.ap_enabled = False
62
70
 
63
71
  # Development mode values
64
- self.AP_SSID = "WifiManager_DEV"
72
+ self.AP_SSID = self._ap_base + "-DEV"
65
73
  self.AP_PASSWORD = "password"
66
74
 
67
75
  try:
@@ -72,10 +80,10 @@ class WiFiManager:
72
80
  self.wifi = None
73
81
  self.HAS_WIFI = False
74
82
  # Set dummy values for development
75
- self.AP_SSID = "WifiManager_DEV"
83
+ self.AP_SSID = self._ap_base + "-DEV"
76
84
  self.AP_PASSWORD = "password"
77
85
  return
78
-
86
+
79
87
  # Try to import CircuitPython specific modules
80
88
  import wifi
81
89
  self.wifi = wifi
@@ -84,7 +92,7 @@ class WiFiManager:
84
92
  mac_ap = ' '.join([hex(i) for i in self.wifi.radio.mac_address_ap])
85
93
  mac_ap = mac_ap.replace('0x', '').replace(' ', '').upper()
86
94
  # access point settings
87
- self.AP_SSID = "WifiManager_" + mac_ap[5:10] + mac_ap[1:2]
95
+ self.AP_SSID = self._ap_base + "-" + mac_ap[5:10] + mac_ap[1:2]
88
96
  self.AP_PASSWORD = "password"
89
97
  self.AP_AUTHMODES = [self.wifi.AuthMode.WPA2, self.wifi.AuthMode.PSK]
90
98
 
@@ -180,6 +180,11 @@ class OTAClient:
180
180
  self.update_dir = update_dir
181
181
  self.backup_dir = backup_dir
182
182
  self.download_timeout = 30
183
+ # Checks get a much shorter leash than downloads: the check runs inside
184
+ # a synchronous web handler that freezes the display loop for its whole
185
+ # duration, so a single stalled read must cost seconds, not the full
186
+ # 30 s download budget ("the box froze for 30 seconds").
187
+ self.check_timeout = 8
183
188
  self.chunk_size = 1024
184
189
  self.session = session
185
190
 
@@ -216,7 +221,7 @@ class OTAClient:
216
221
  if on_error:
217
222
  self.on_update_error = on_error
218
223
 
219
- def _http_get(self, url: str) -> Any:
224
+ def _http_get(self, url: str, timeout: Any = None) -> Any:
220
225
  """Perform an HTTP GET, preferring an injected Session.
221
226
 
222
227
  ``self.session`` is read live (never cached) so the app can create or
@@ -224,11 +229,15 @@ class OTAClient:
224
229
  right before use. When a session is present, its ``.get`` is used (modern
225
230
  ``adafruit_requests`` is Session-based and exposes no module-level
226
231
  ``get``); otherwise the module-level ``requests.get`` is used (desktop).
232
+ ``timeout`` overrides ``download_timeout`` (the check path passes the
233
+ short ``check_timeout``).
227
234
  """
235
+ if timeout is None:
236
+ timeout = self.download_timeout
228
237
  try:
229
238
  if self.session is not None:
230
- return self.session.get(url, timeout=self.download_timeout)
231
- return requests.get(url, timeout=self.download_timeout)
239
+ return self.session.get(url, timeout=timeout)
240
+ return requests.get(url, timeout=timeout)
232
241
  except Exception as e:
233
242
  # Typed boundary error (no `from e` chaining: heap fragmentation on
234
243
  # CircuitPython). The public check/download methods catch it and
@@ -244,17 +253,65 @@ class OTAClient:
244
253
  if self.session is None and not requests:
245
254
  return False, "Requests library not available"
246
255
 
256
+ # FAST PATH: the channel publishes a ~6-byte version.txt next to the
257
+ # manifest. Comparing versions needs those bytes, not the ~31 KB / 176-
258
+ # entry manifest (which the old check fetched, flash-streamed and JSON-
259
+ # parsed every time — slow, and pure waste when the answer is "up to
260
+ # date", i.e. almost always). Only a NEWER version proceeds to the full
261
+ # manifest fetch below (needed then anyway for staging). Any miss —
262
+ # 404 on an older channel, junk content, transport error — falls
263
+ # through to the manifest path unchanged.
264
+ try:
265
+ v_resp = self._http_get(f"{self.server_url}/version.txt",
266
+ timeout=self.check_timeout)
267
+ try:
268
+ if v_resp.status_code == 200:
269
+ remote_version = str(v_resp.text).strip()
270
+ # Trust ONLY a strict MAJOR.MINOR[.PATCH] shape: parse_version
271
+ # maps junk to (0,0,0), so an unvalidated error page would
272
+ # compare as "older" and fake an up-to-date answer.
273
+ parts = remote_version.split(".")
274
+ if 2 <= len(parts) <= 3 and all(p.isdigit() for p in parts):
275
+ probe = UpdateManifest(version=remote_version)
276
+ if probe.compare_version(self.current_version) <= 0:
277
+ return False, UP_TO_DATE
278
+ finally:
279
+ try:
280
+ v_resp.close()
281
+ except Exception:
282
+ pass
283
+ except Exception:
284
+ pass # fall through to the manifest fetch
285
+ gc.collect()
286
+
247
287
  try:
248
288
  url = f"{self.server_url}/manifest.json"
249
- response = self._http_get(url)
289
+ response = self._http_get(url, timeout=self.check_timeout)
250
290
 
251
291
  if response.status_code != 200:
252
292
  return False, f"Server error: {response.status_code}"
253
293
 
254
294
  try:
255
- manifest_data = response.json()
295
+ # Avoid response.json() when the body can be streamed: .json()
296
+ # needs the whole body as ONE contiguous allocation, and a
297
+ # ~30 KB manifest routinely exceeds the largest free block on a
298
+ # hot CircuitPython heap (the intermittent "Check for Update ...
299
+ # MemoryError"). Stream the body to flash in small chunks, then
300
+ # json.load the FILE — the parser reads it incrementally, so the
301
+ # RAM cost is many small allocations instead of one big one.
302
+ if getattr(response, "iter_content", None) is None:
303
+ manifest_data = response.json() # desktop mocks/shims
304
+ else:
305
+ part_path = f"{self.update_dir}/manifest.part"
306
+ self._stream_body_to_file(response, part_path)
307
+ gc.collect()
308
+ try:
309
+ with open(part_path) as f:
310
+ manifest_data = json.load(f)
311
+ finally:
312
+ self._remove(part_path)
256
313
  manifest = UpdateManifest.from_dict(manifest_data)
257
- except ValueError as e: # CircuitPython: json.loads raises ValueError
314
+ except ValueError as e: # CircuitPython: json raises ValueError
258
315
  return False, f"Invalid manifest: {e}"
259
316
 
260
317
  is_valid, error = manifest.validate()
@@ -407,22 +464,23 @@ class OTAClient:
407
464
  raise OTAError("Server error %d for %s"
408
465
  % (response.status_code, file_path))
409
466
 
410
- content = response.content
467
+ # Stream to flash while hashing per chunk — response.content would
468
+ # need the whole file as one contiguous allocation (same hot-heap
469
+ # MemoryError class as the manifest fetch above). Verify AFTER the
470
+ # write and delete the staged file on any mismatch, so a bad body
471
+ # never survives in the staging area.
472
+ digest = _sha256()
473
+ local_path = f"{self.update_dir}/{file_path.lstrip('/')}"
474
+ total = self._stream_body_to_file(response, local_path, digest)
411
475
 
412
- if len(content) != file_info['size']:
476
+ if total != file_info['size']:
477
+ self._remove(local_path)
413
478
  raise OTAError("Size mismatch for %s: %d != %d"
414
- % (file_path, len(content), file_info['size']))
479
+ % (file_path, total, file_info['size']))
415
480
 
416
- digest = _sha256()
417
- digest.update(content)
418
481
  if _hexdigest(digest) != file_info['checksum']:
482
+ self._remove(local_path)
419
483
  raise OTAError("Checksum mismatch for %s" % file_path)
420
-
421
- local_path = f"{self.update_dir}/{file_path.lstrip('/')}"
422
- self._ensure_directory_for_file(local_path)
423
-
424
- with open(local_path, 'wb') as f:
425
- f.write(content)
426
484
  finally:
427
485
  if response is not None:
428
486
  try:
@@ -776,6 +834,34 @@ class OTAClient:
776
834
  """True if this file was downloaded into the staging dir (i.e. changed)."""
777
835
  return self._exists(f"{self.update_dir}/{file_path.lstrip('/')}")
778
836
 
837
+ def _stream_body_to_file(self, response, path, digest=None):
838
+ """Write a response body to ``path`` in small chunks; return the byte count.
839
+
840
+ The point is to never hold the whole body in RAM: on a hot CircuitPython
841
+ heap the largest free block is often smaller than a 30 KB manifest or
842
+ source file, so ``response.content`` / ``response.json()`` fail with
843
+ MemoryError while chunked writes sail through. Feeds ``digest`` per
844
+ chunk when given. Falls back to ``response.content`` when the response
845
+ has no ``iter_content`` (desktop mocks / simple session shims).
846
+ """
847
+ self._ensure_directory_for_file(path)
848
+ total = 0
849
+ iter_content = getattr(response, "iter_content", None)
850
+ with open(path, "wb") as f:
851
+ if iter_content is None:
852
+ content = response.content
853
+ f.write(content)
854
+ total = len(content)
855
+ if digest is not None:
856
+ digest.update(content)
857
+ else:
858
+ for chunk in iter_content(chunk_size=1024):
859
+ f.write(chunk)
860
+ total += len(chunk)
861
+ if digest is not None:
862
+ digest.update(chunk)
863
+ return total
864
+
779
865
  def _remove_mpy_sibling(self, file_path):
780
866
  """Delete a same-basename ``.mpy`` before writing a ``.py`` under /lib.
781
867
 
@@ -137,6 +137,12 @@ class OTAProgressDisplay:
137
137
  if isinstance(lines, str):
138
138
  lines = [lines]
139
139
  try:
140
+ # Takeover: strip the interrupted content's persistent bitmap layers
141
+ # (intro tiles, wait-number compositions, effect overlays) — clear()
142
+ # alone leaves them composited and this message paints OVER them.
143
+ clear_layers = getattr(self.display, "clear_layers", None)
144
+ if clear_layers is not None:
145
+ clear_layers()
140
146
  await self.display.clear()
141
147
  line_h = 9 # ~8px glyphs + 1px gap
142
148
  height = getattr(self.display, "height", 32)
@@ -7,7 +7,8 @@ architecture, not a revert). The flow:
7
7
  1. The device can't join Wi-Fi (no/wrong credentials), so the app calls
8
8
  ``WiFiManager.run_setup_portal(display=...)`` — usually right after a
9
9
  failed ``connect()``.
10
- 2. The device starts its own access point (``WifiManager_XXXX``) and shows
10
+ 2. The device starts its own access point (named by the app via
11
+ ``WiFiManager(ap_name=...)``, e.g. ``ThemeParkWaits-XXXX``) and shows
11
12
  join instructions on the LED panel.
12
13
  3. The user's phone joins that AP and opens ``http://192.168.4.1``: a page
13
14
  with the scanned nearby networks (signal bars), a manual-SSID field, and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrollkit
3
- Version: 0.8.4
3
+ Version: 0.8.5
4
4
  Summary: LED Matrix Display Framework for CircuitPython and Desktop
5
5
  Author-email: Michael Czeiszperger <michael@czei.org>
6
6
  License: MIT
@@ -228,7 +228,6 @@ if is_dev_mode():
228
228
  | Platform | Backend | Status |
229
229
  |---|---|---|
230
230
  | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
231
- | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
232
231
  | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
233
232
  | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
234
233
 
File without changes
File without changes