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.
- {scrollkit-0.8.4/src/scrollkit.egg-info → scrollkit-0.8.5}/PKG-INFO +1 -2
- {scrollkit-0.8.4 → scrollkit-0.8.5}/README.md +1 -2
- {scrollkit-0.8.4 → scrollkit-0.8.5}/pyproject.toml +1 -1
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/__init__.py +1 -1
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_graphics.py +26 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/image_animators.py +134 -10
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/http_client.py +34 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/wifi_manager.py +14 -6
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/client.py +103 -17
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/display_progress.py +6 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/wifi_setup.py +2 -1
- {scrollkit-0.8.4 → scrollkit-0.8.5/src/scrollkit.egg-info}/PKG-INFO +1 -2
- {scrollkit-0.8.4 → scrollkit-0.8.5}/LICENSE +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/setup.cfg +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/base.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/app/memory.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/settings_manager.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/config/transition_names.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/capabilities.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/harness.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/metrics.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/performance.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/dev/validation.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_recording.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/_sim_backend.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/bitmap_text.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/boards.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/colors.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/content.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/gradient_text.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/interface.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/simulator.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/text_fill.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/text_pixels.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/display/unified.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/drip_splash.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/easing.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/overlay.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/particles.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/reveal_splash.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/scrolling.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/swarm_reveal.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/text_render.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/effects/transitions.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/exceptions.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/network/mdns.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/manifest.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/ota/publish.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/ATTRIBUTION.md +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/LICENSE +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/README.md +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_display_text/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_display_text/label.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/bitmaptools.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/color_utils.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/device_benchmarks.json +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/feasibility.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/hardware_profile.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/led_matrix.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/matrixportal_s3_baseline.json +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/performance_manager.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/pixel_buffer.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/base_device.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/devices/matrixportal_s3.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/bitmap.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/display.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/fourwire.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/group.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/ondiskbitmap.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/palette.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/displayio/tilegrid.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/3x5.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_16.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_16.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/tom-thumb.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/viii-bold.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/viii.bdf +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/terminalio/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/color_utils.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/diagnostics.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/error_handler.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/system_utils.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/utils/url_utils.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/__init__.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/web/settings_server.py +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/SOURCES.txt +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/dependency_links.txt +0 -0
- {scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
self._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
|
-
|
|
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 = "
|
|
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 = "
|
|
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 = "
|
|
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=
|
|
231
|
-
return requests.get(url, 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
476
|
+
if total != file_info['size']:
|
|
477
|
+
self._remove(local_path)
|
|
413
478
|
raise OTAError("Size mismatch for %s: %d != %d"
|
|
414
|
-
% (file_path,
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
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
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/__init__.py
RENAMED
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py
RENAMED
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py
RENAMED
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/adafruit_display_text/__init__.py
RENAMED
|
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
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/core/matrixportal_s3_baseline.json
RENAMED
|
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
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf.license
RENAMED
|
File without changes
|
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license
RENAMED
|
File without changes
|
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license
RENAMED
|
File without changes
|
{scrollkit-0.8.4 → scrollkit-0.8.5}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf
RENAMED
|
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
|