scrollkit 0.8.3__py3-none-any.whl
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/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Models how slow the real CircuitPython hardware runs.
|
|
3
|
+
|
|
4
|
+
It works by ACCUMULATING modeled time per frame — it does NOT actually sleep
|
|
5
|
+
(unless ``throttle=True``), so the simulator and the headless test suite stay
|
|
6
|
+
fast. The accumulated time becomes an estimated *hardware* frame time / FPS in a
|
|
7
|
+
FeasibilityReport.
|
|
8
|
+
|
|
9
|
+
Wired into the previously-dead hooks in ``core/led_matrix.py`` plus one hook in
|
|
10
|
+
``adafruit_display_text/label.py`` (looked up lazily via the module-level active
|
|
11
|
+
manager, so there's zero cost and no constructor threading when it's off).
|
|
12
|
+
|
|
13
|
+
Desktop/simulator only — never imported on hardware.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections import deque
|
|
17
|
+
|
|
18
|
+
from .hardware_profile import HardwareProfile # noqa: F401 (re-exported for callers)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- module-level active manager (for the lazy Label hook) ---------------------
|
|
22
|
+
_active = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_active(manager):
|
|
26
|
+
"""Register the manager the Label bitmap-rebuild hook should report to."""
|
|
27
|
+
global _active
|
|
28
|
+
_active = manager
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_active():
|
|
32
|
+
"""Return the active PerformanceManager, or None when disabled (the default)."""
|
|
33
|
+
return _active
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FrameCost:
|
|
37
|
+
"""Modeled microseconds for one frame, split by category."""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("pixel_writes_us", "refresh_us", "bitmap_rebuild_us", "gc_us",
|
|
40
|
+
"bulk_ops_us")
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.pixel_writes_us = 0.0
|
|
44
|
+
self.refresh_us = 0.0
|
|
45
|
+
self.bitmap_rebuild_us = 0.0
|
|
46
|
+
self.gc_us = 0.0
|
|
47
|
+
self.bulk_ops_us = 0.0 # C bulk ops (fill_region/blit) from the painters
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total_us(self):
|
|
51
|
+
return (self.pixel_writes_us + self.refresh_us
|
|
52
|
+
+ self.bitmap_rebuild_us + self.gc_us + self.bulk_ops_us)
|
|
53
|
+
|
|
54
|
+
def as_dict(self):
|
|
55
|
+
return {
|
|
56
|
+
"pixel_writes_us": self.pixel_writes_us,
|
|
57
|
+
"refresh_us": self.refresh_us,
|
|
58
|
+
"bitmap_rebuild_us": self.bitmap_rebuild_us,
|
|
59
|
+
"gc_us": self.gc_us,
|
|
60
|
+
"bulk_ops_us": self.bulk_ops_us,
|
|
61
|
+
"total_us": self.total_us,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PerformanceManager:
|
|
66
|
+
"""Cost accumulator behind the simulator's performance hooks."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, profile, enabled=True, throttle=False, history=120,
|
|
69
|
+
ambient_warnings=None, warn_interval=30, stutter_fps=10.0,
|
|
70
|
+
strict=False, target_fps=20.0, warmup_frames=2,
|
|
71
|
+
gate_window=8, transient_factor=2.0):
|
|
72
|
+
self.profile = profile
|
|
73
|
+
self.enabled = enabled # read by led_matrix hooks
|
|
74
|
+
self.throttle = throttle # if True, also real-sleep to crawl the window
|
|
75
|
+
# Periodic console "this would stutter on hardware" nags. Default: follow
|
|
76
|
+
# throttle (visceral mode), but can be toggled independently (so tests can
|
|
77
|
+
# exercise the warning logic without actually sleeping).
|
|
78
|
+
self.ambient_warnings = throttle if ambient_warnings is None else ambient_warnings
|
|
79
|
+
self.warn_interval = warn_interval # min frames between ambient nags
|
|
80
|
+
self.stutter_fps = stutter_fps # warn when modeled FPS drops below this
|
|
81
|
+
self.last_warning = None # most recent ambient nag (for UIs/tests)
|
|
82
|
+
# --- strict feasibility gate (opt-in; default off keeps the warning path)
|
|
83
|
+
self.strict = strict
|
|
84
|
+
self.target_fps = target_fps
|
|
85
|
+
self.warmup_frames = warmup_frames # leading frames exempt (one-time setup)
|
|
86
|
+
self.gate_window = max(1, gate_window)
|
|
87
|
+
self.transient_factor = transient_factor
|
|
88
|
+
self.frame_budget_us = 1_000_000.0 / target_fps # 50_000 at 20 fps
|
|
89
|
+
self.transient_budget_us = self.frame_budget_us * transient_factor
|
|
90
|
+
self._frame = FrameCost()
|
|
91
|
+
self._frames = deque(maxlen=history)
|
|
92
|
+
self._frame_index = 0
|
|
93
|
+
self._last_warn_frame = 0
|
|
94
|
+
self._max_bitmap_px = 0 # largest live text bitmap seen (RAM proxy)
|
|
95
|
+
|
|
96
|
+
# --- hooks called by core/led_matrix.py (existing signatures) -------------
|
|
97
|
+
def simulate_instruction_delay(self, n):
|
|
98
|
+
"""Per matrix.set_pixel(): n is a fixed weight, not a pixel count."""
|
|
99
|
+
self._frame.pixel_writes_us += n * self.profile.pixel_write_us
|
|
100
|
+
|
|
101
|
+
def simulate_io_operation(self, kind):
|
|
102
|
+
"""A display.refresh() — also the frame boundary (one refresh == one frame)."""
|
|
103
|
+
if kind == "display_refresh":
|
|
104
|
+
self._frame.refresh_us += self.profile.full_refresh_us
|
|
105
|
+
self._end_frame()
|
|
106
|
+
|
|
107
|
+
def simulate_gc_pause(self):
|
|
108
|
+
if self._frame_index % self.profile.gc_every_n_frames == 0:
|
|
109
|
+
self._frame.gc_us += self.profile.gc_pause_us
|
|
110
|
+
|
|
111
|
+
# --- hook called by adafruit_display_text/label.py _update_text -----------
|
|
112
|
+
def account_bitmap_rebuild(self, width_px, height_px):
|
|
113
|
+
"""A Label rebuilt its glyph bitmap (the dominant cost for scrolling text)."""
|
|
114
|
+
px = max(0, width_px) * max(0, height_px)
|
|
115
|
+
self._frame.bitmap_rebuild_us += px * self.profile.bitmap_rebuild_us_per_px
|
|
116
|
+
if px > self._max_bitmap_px:
|
|
117
|
+
self._max_bitmap_px = px
|
|
118
|
+
|
|
119
|
+
# --- hook called by the display span/rect painters + overlay --------------
|
|
120
|
+
def account_bulk_op(self, kind, px):
|
|
121
|
+
"""A C bulk op (``fill_region`` / ``blit``) touched ``px`` clipped pixels.
|
|
122
|
+
|
|
123
|
+
Cost = ``bulk_base_us`` (fixed per-call C dispatch) + ``px * slope`` for
|
|
124
|
+
the op. The base over-counts slightly on purpose so the strict gate fails
|
|
125
|
+
safe. ``px`` is the clipped/scanned pixel count (a fully-clipped no-op
|
|
126
|
+
still pays the base).
|
|
127
|
+
"""
|
|
128
|
+
px = max(0, px)
|
|
129
|
+
if kind == "blit":
|
|
130
|
+
slope = self.profile.blit_us_per_px
|
|
131
|
+
else: # "fill_region" (and any other bounded fill)
|
|
132
|
+
slope = self.profile.fill_region_us_per_px
|
|
133
|
+
self._frame.bulk_ops_us += self.profile.bulk_base_us + px * slope
|
|
134
|
+
|
|
135
|
+
# --- frame bookkeeping ----------------------------------------------------
|
|
136
|
+
def _end_frame(self):
|
|
137
|
+
total_us = self._frame.total_us
|
|
138
|
+
self._frames.append(self._frame)
|
|
139
|
+
self._frame_index += 1
|
|
140
|
+
# Reset the live frame BEFORE the strict gate can raise, so a caller that
|
|
141
|
+
# catches FeasibilityError and keeps rendering doesn't accumulate the next
|
|
142
|
+
# frame's cost onto the (already-appended) aborted frame and corrupt the
|
|
143
|
+
# rolling history. The appended frame stays valid in self._frames.
|
|
144
|
+
self._frame = FrameCost()
|
|
145
|
+
# Strict feasibility gate (opt-in). Past the warmup grace, a sustained
|
|
146
|
+
# over-budget run (steady-state median) or a single catastrophic frame
|
|
147
|
+
# (transient ceiling) or a RAM breach raises FeasibilityError, stopping
|
|
148
|
+
# the run at the offending frame. Default (strict=False) skips all of this
|
|
149
|
+
# so the warning path below is unchanged.
|
|
150
|
+
if self.strict and self._frame_index > self.warmup_frames:
|
|
151
|
+
self._enforce_strict(total_us)
|
|
152
|
+
# Visceral mode: actually sleep the modeled frame time so the live window
|
|
153
|
+
# crawls at hardware speed.
|
|
154
|
+
if self.throttle and total_us > 0:
|
|
155
|
+
import time
|
|
156
|
+
try:
|
|
157
|
+
time.sleep(total_us / 1_000_000.0)
|
|
158
|
+
except (ValueError, OSError):
|
|
159
|
+
pass
|
|
160
|
+
if self.ambient_warnings:
|
|
161
|
+
self._maybe_emit_ambient_warning(total_us)
|
|
162
|
+
|
|
163
|
+
def _enforce_strict(self, total_us):
|
|
164
|
+
"""Raise FeasibilityError if this frame busts the device budget."""
|
|
165
|
+
from ...exceptions import FeasibilityError
|
|
166
|
+
# 1) Transient ceiling: a single catastrophic frame (e.g. 2 full rebuilds).
|
|
167
|
+
if total_us > self.transient_budget_us:
|
|
168
|
+
raise FeasibilityError(self._budget_message(
|
|
169
|
+
"single frame", total_us, self.transient_budget_us))
|
|
170
|
+
# 2) Steady-state: the median over the rolling window must hold the budget.
|
|
171
|
+
# Only meaningful once the window is full, so an isolated rebuild spike
|
|
172
|
+
# is absorbed rather than triggering a false trip.
|
|
173
|
+
totals = [f.total_us for f in self._frames]
|
|
174
|
+
if len(totals) >= self.gate_window:
|
|
175
|
+
window = totals[-self.gate_window:]
|
|
176
|
+
med = self._median(window)
|
|
177
|
+
if med > self.frame_budget_us:
|
|
178
|
+
raise FeasibilityError(self._budget_message(
|
|
179
|
+
"median over %d frames" % self.gate_window, med,
|
|
180
|
+
self.frame_budget_us))
|
|
181
|
+
# 3) RAM: must fit the usable device RAM.
|
|
182
|
+
peak = self.estimated_peak_ram_bytes()
|
|
183
|
+
if peak > self.profile.usable_ram_bytes:
|
|
184
|
+
raise FeasibilityError(
|
|
185
|
+
"[hw-strict] frame %d: modeled peak RAM ~%d KB exceeds the device's "
|
|
186
|
+
"usable ~%d KB. Reduce content/effects."
|
|
187
|
+
% (self._frame_index, peak // 1024,
|
|
188
|
+
self.profile.usable_ram_bytes // 1024))
|
|
189
|
+
|
|
190
|
+
def _budget_message(self, scope, us, budget_us):
|
|
191
|
+
fps = 1_000_000.0 / us if us > 0 else 0.0
|
|
192
|
+
budget_fps = 1_000_000.0 / budget_us if budget_us > 0 else 0.0
|
|
193
|
+
return ("[hw-strict] frame %d: %s ~%d ms/frame (~%d fps) exceeds the "
|
|
194
|
+
"%d ms budget (%d fps); dominant: %s. Reuse Labels, avoid per-frame "
|
|
195
|
+
"glyph rebuild, and express bounded work through the C bulk painters."
|
|
196
|
+
% (self._frame_index, scope, round(us / 1000.0), round(fps),
|
|
197
|
+
round(budget_us / 1000.0), round(budget_fps),
|
|
198
|
+
self._dominant_component()))
|
|
199
|
+
|
|
200
|
+
def _dominant_component(self):
|
|
201
|
+
last = self._frames[-1] if self._frames else self._frame
|
|
202
|
+
cats = last.as_dict()
|
|
203
|
+
cats.pop("total_us", None)
|
|
204
|
+
if not cats:
|
|
205
|
+
return "unknown"
|
|
206
|
+
return max(cats, key=lambda k: cats[k]).replace("_us", "")
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _median(values):
|
|
210
|
+
s = sorted(values)
|
|
211
|
+
n = len(s)
|
|
212
|
+
if n == 0:
|
|
213
|
+
return 0.0
|
|
214
|
+
mid = n // 2
|
|
215
|
+
return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
|
216
|
+
|
|
217
|
+
def _maybe_emit_ambient_warning(self, frame_total_us):
|
|
218
|
+
"""Periodically nag when the modeled frame time implies a stuttery FPS.
|
|
219
|
+
|
|
220
|
+
Rate-limited to one nag per ``warn_interval`` frames so it informs rather
|
|
221
|
+
than floods. Routed through ``_emit`` so callers/tests can redirect it.
|
|
222
|
+
"""
|
|
223
|
+
if frame_total_us <= 0:
|
|
224
|
+
return
|
|
225
|
+
if self._frame_index - self._last_warn_frame < self.warn_interval:
|
|
226
|
+
return
|
|
227
|
+
fps = 1_000_000.0 / frame_total_us
|
|
228
|
+
if fps >= self.stutter_fps:
|
|
229
|
+
return
|
|
230
|
+
self._last_warn_frame = self._frame_index
|
|
231
|
+
msg = ("[hw-sim] frame %d: ~%d ms/frame (~%d FPS) on the real device — "
|
|
232
|
+
"this would stutter. (estimate)"
|
|
233
|
+
% (self._frame_index, round(frame_total_us / 1000.0), round(fps)))
|
|
234
|
+
self.last_warning = msg
|
|
235
|
+
self._emit(msg)
|
|
236
|
+
|
|
237
|
+
def _emit(self, msg):
|
|
238
|
+
"""Where ambient nags go. Overridable; defaults to stdout."""
|
|
239
|
+
print(msg)
|
|
240
|
+
|
|
241
|
+
# --- reporting ------------------------------------------------------------
|
|
242
|
+
@property
|
|
243
|
+
def frames(self):
|
|
244
|
+
return list(self._frames)
|
|
245
|
+
|
|
246
|
+
def estimated_peak_ram_bytes(self):
|
|
247
|
+
"""Rough modeled peak RAM: app floor + largest live text bitmap."""
|
|
248
|
+
return int(self.profile.base_app_ram_bytes
|
|
249
|
+
+ self._max_bitmap_px * self.profile.bytes_per_label_px)
|
|
250
|
+
|
|
251
|
+
def report(self):
|
|
252
|
+
from .feasibility import FeasibilityReport
|
|
253
|
+
return FeasibilityReport.from_manager(self)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Pixel buffer management for LED matrix simulation."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PixelBuffer:
|
|
8
|
+
"""Manages the pixel data for an LED matrix.
|
|
9
|
+
|
|
10
|
+
This class provides efficient storage and manipulation of pixel data
|
|
11
|
+
using numpy arrays for performance.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, width, height):
|
|
15
|
+
"""Initialize pixel buffer.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
width: Width of the matrix in pixels
|
|
19
|
+
height: Height of the matrix in pixels
|
|
20
|
+
"""
|
|
21
|
+
self.width = width
|
|
22
|
+
self.height = height
|
|
23
|
+
# Store pixels as RGB888 internally for better color accuracy
|
|
24
|
+
self._buffer = np.zeros((height, width, 3), dtype=np.uint8)
|
|
25
|
+
self._dirty = True
|
|
26
|
+
self._dirty_region = None
|
|
27
|
+
|
|
28
|
+
def set_pixel(self, x, y, color):
|
|
29
|
+
"""Set a single pixel color.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
x: X coordinate (0 to width-1)
|
|
33
|
+
y: Y coordinate (0 to height-1)
|
|
34
|
+
color: Color as (r, g, b) tuple or single RGB value
|
|
35
|
+
"""
|
|
36
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if isinstance(color, (list, tuple)):
|
|
40
|
+
self._buffer[y, x] = color[:3]
|
|
41
|
+
else:
|
|
42
|
+
# Assume it's a packed RGB value
|
|
43
|
+
r = (color >> 16) & 0xFF
|
|
44
|
+
g = (color >> 8) & 0xFF
|
|
45
|
+
b = color & 0xFF
|
|
46
|
+
self._buffer[y, x] = [r, g, b]
|
|
47
|
+
|
|
48
|
+
self._mark_dirty(x, y, x, y)
|
|
49
|
+
|
|
50
|
+
def get_pixel(self, x, y):
|
|
51
|
+
"""Get a single pixel color.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
x: X coordinate (0 to width-1)
|
|
55
|
+
y: Y coordinate (0 to height-1)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Color as (r, g, b) tuple
|
|
59
|
+
"""
|
|
60
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
61
|
+
return (0, 0, 0)
|
|
62
|
+
return tuple(self._buffer[y, x])
|
|
63
|
+
|
|
64
|
+
def fill(self, color):
|
|
65
|
+
"""Fill entire buffer with a single color.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
color: Color as (r, g, b) tuple or single RGB value
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(color, (list, tuple)):
|
|
71
|
+
self._buffer[:] = color[:3]
|
|
72
|
+
else:
|
|
73
|
+
r = (color >> 16) & 0xFF
|
|
74
|
+
g = (color >> 8) & 0xFF
|
|
75
|
+
b = color & 0xFF
|
|
76
|
+
self._buffer[:] = [r, g, b]
|
|
77
|
+
|
|
78
|
+
self._dirty = True
|
|
79
|
+
self._dirty_region = None
|
|
80
|
+
|
|
81
|
+
def clear(self):
|
|
82
|
+
"""Clear all pixels to black."""
|
|
83
|
+
self._buffer.fill(0)
|
|
84
|
+
self._dirty = True
|
|
85
|
+
self._dirty_region = None
|
|
86
|
+
|
|
87
|
+
def blit(self, source, x=0, y=0, key_color=None):
|
|
88
|
+
"""Copy pixels from another buffer.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
source: Source PixelBuffer
|
|
92
|
+
x: Destination X coordinate
|
|
93
|
+
y: Destination Y coordinate
|
|
94
|
+
key_color: Optional color to treat as transparent
|
|
95
|
+
"""
|
|
96
|
+
# Calculate source and destination regions
|
|
97
|
+
src_x = max(0, -x)
|
|
98
|
+
src_y = max(0, -y)
|
|
99
|
+
dst_x = max(0, x)
|
|
100
|
+
dst_y = max(0, y)
|
|
101
|
+
|
|
102
|
+
copy_width = min(source.width - src_x, self.width - dst_x)
|
|
103
|
+
copy_height = min(source.height - src_y, self.height - dst_y)
|
|
104
|
+
|
|
105
|
+
if copy_width <= 0 or copy_height <= 0:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Copy the pixel data
|
|
109
|
+
src_data = source._buffer[src_y:src_y + copy_height, src_x:src_x + copy_width]
|
|
110
|
+
|
|
111
|
+
if key_color is not None:
|
|
112
|
+
# Create mask for non-transparent pixels
|
|
113
|
+
if isinstance(key_color, (list, tuple)):
|
|
114
|
+
mask = np.any(src_data != key_color[:3], axis=2)
|
|
115
|
+
else:
|
|
116
|
+
r = (key_color >> 16) & 0xFF
|
|
117
|
+
g = (key_color >> 8) & 0xFF
|
|
118
|
+
b = key_color & 0xFF
|
|
119
|
+
mask = np.any(src_data != [r, g, b], axis=2)
|
|
120
|
+
|
|
121
|
+
# Only copy non-transparent pixels
|
|
122
|
+
dst_slice = self._buffer[dst_y:dst_y + copy_height, dst_x:dst_x + copy_width]
|
|
123
|
+
dst_slice[mask] = src_data[mask]
|
|
124
|
+
else:
|
|
125
|
+
# Copy all pixels
|
|
126
|
+
self._buffer[dst_y:dst_y + copy_height, dst_x:dst_x + copy_width] = src_data
|
|
127
|
+
|
|
128
|
+
self._mark_dirty(dst_x, dst_y, dst_x + copy_width - 1, dst_y + copy_height - 1)
|
|
129
|
+
|
|
130
|
+
def get_buffer(self):
|
|
131
|
+
"""Get the raw numpy buffer.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Numpy array of shape (height, width, 3)
|
|
135
|
+
"""
|
|
136
|
+
return self._buffer
|
|
137
|
+
|
|
138
|
+
def is_dirty(self):
|
|
139
|
+
"""Check if buffer has been modified.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if buffer has been modified since last clear_dirty()
|
|
143
|
+
"""
|
|
144
|
+
return self._dirty
|
|
145
|
+
|
|
146
|
+
def get_dirty_region(self):
|
|
147
|
+
"""Get the region that has been modified.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Tuple of (x1, y1, x2, y2) or None if entire buffer is dirty
|
|
151
|
+
"""
|
|
152
|
+
return self._dirty_region
|
|
153
|
+
|
|
154
|
+
def clear_dirty(self):
|
|
155
|
+
"""Clear the dirty flag."""
|
|
156
|
+
self._dirty = False
|
|
157
|
+
self._dirty_region = None
|
|
158
|
+
|
|
159
|
+
def _mark_dirty(self, x1, y1, x2, y2):
|
|
160
|
+
"""Mark a region as dirty.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
x1, y1: Top-left corner
|
|
164
|
+
x2, y2: Bottom-right corner
|
|
165
|
+
"""
|
|
166
|
+
self._dirty = True
|
|
167
|
+
|
|
168
|
+
if self._dirty_region is None:
|
|
169
|
+
self._dirty_region = (x1, y1, x2, y2)
|
|
170
|
+
else:
|
|
171
|
+
# Expand existing dirty region
|
|
172
|
+
old_x1, old_y1, old_x2, old_y2 = self._dirty_region
|
|
173
|
+
self._dirty_region = (
|
|
174
|
+
min(x1, old_x1),
|
|
175
|
+
min(y1, old_y1),
|
|
176
|
+
max(x2, old_x2),
|
|
177
|
+
max(y2, old_y2)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def apply_brightness(self, brightness):
|
|
181
|
+
"""Apply brightness adjustment to all pixels.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
brightness: Float from 0.0 to 1.0
|
|
185
|
+
"""
|
|
186
|
+
self._buffer = (self._buffer * brightness).astype(np.uint8)
|
|
187
|
+
self._dirty = True
|
|
188
|
+
self._dirty_region = None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Base class for LED matrix devices."""
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from ..core import LEDMatrix
|
|
6
|
+
from ..displayio import Display, FourWire
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseDevice(ABC):
|
|
10
|
+
"""Abstract base class for LED matrix devices.
|
|
11
|
+
|
|
12
|
+
Provides common functionality for all simulated devices.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, width, height, pitch=4.0):
|
|
16
|
+
"""Initialize base device.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
width: Display width in pixels
|
|
20
|
+
height: Display height in pixels
|
|
21
|
+
pitch: LED pitch in mm
|
|
22
|
+
"""
|
|
23
|
+
self.width = width
|
|
24
|
+
self.height = height
|
|
25
|
+
self.pitch = pitch
|
|
26
|
+
|
|
27
|
+
# Display components
|
|
28
|
+
self.display = None
|
|
29
|
+
self.matrix = None
|
|
30
|
+
self.display_bus = None
|
|
31
|
+
|
|
32
|
+
# Performance simulation (optional)
|
|
33
|
+
self.performance_manager = None
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def initialize(self):
|
|
37
|
+
"""Initialize the device.
|
|
38
|
+
|
|
39
|
+
Must be implemented by subclasses.
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def set_brightness(self, brightness):
|
|
44
|
+
"""Set display brightness.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
brightness: Brightness value from 0.0 to 1.0
|
|
48
|
+
"""
|
|
49
|
+
if self.display:
|
|
50
|
+
self.display.brightness = brightness
|
|
51
|
+
|
|
52
|
+
def clear(self):
|
|
53
|
+
"""Clear the display."""
|
|
54
|
+
if self.matrix:
|
|
55
|
+
self.matrix.clear()
|
|
56
|
+
|
|
57
|
+
def show(self, group_or_tilegrid):
|
|
58
|
+
"""Show a Group or TileGrid on the display.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
group_or_tilegrid: Display object to show
|
|
62
|
+
"""
|
|
63
|
+
if self.display:
|
|
64
|
+
self.display.root_group = group_or_tilegrid
|
|
65
|
+
|
|
66
|
+
def refresh(self):
|
|
67
|
+
"""Refresh the display."""
|
|
68
|
+
if self.display:
|
|
69
|
+
self.display.refresh()
|
|
70
|
+
|
|
71
|
+
def get_surface(self):
|
|
72
|
+
"""Get the pygame surface for this device.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Pygame surface or None
|
|
76
|
+
"""
|
|
77
|
+
if self.matrix:
|
|
78
|
+
return self.matrix.get_surface()
|
|
79
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""MatrixPortal S3 device simulation."""
|
|
3
|
+
|
|
4
|
+
from .base_device import BaseDevice
|
|
5
|
+
from ..core import LEDMatrix
|
|
6
|
+
from ..displayio import Display, FourWire
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MatrixPortalS3(BaseDevice):
|
|
10
|
+
"""Simulated Adafruit MatrixPortal S3 device.
|
|
11
|
+
|
|
12
|
+
Standard configuration with 64x32 RGB LED matrix.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, pitch=3.0, width=64, height=32):
|
|
16
|
+
"""Initialize MatrixPortal S3.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
pitch: LED pitch in mm (default 3.0 for 192x96mm physical size)
|
|
20
|
+
width: Display width in pixels (default 64)
|
|
21
|
+
height: Display height in pixels (default 32)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(width, height, pitch)
|
|
24
|
+
|
|
25
|
+
# Board-specific attributes
|
|
26
|
+
self.NEOPIXEL = None # Status NeoPixel (not simulated)
|
|
27
|
+
self.ACCELEROMETER = None # LIS3DH accelerometer (not simulated)
|
|
28
|
+
|
|
29
|
+
def initialize(self):
|
|
30
|
+
"""Initialize MatrixPortal S3 specific features."""
|
|
31
|
+
# Create LED matrix
|
|
32
|
+
self.matrix = LEDMatrix(
|
|
33
|
+
self.width,
|
|
34
|
+
self.height,
|
|
35
|
+
pitch=self.pitch,
|
|
36
|
+
performance_manager=self.performance_manager
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Create display bus (stub for compatibility)
|
|
40
|
+
self.display_bus = FourWire(
|
|
41
|
+
spi_bus=None,
|
|
42
|
+
command=None,
|
|
43
|
+
chip_select=None,
|
|
44
|
+
reset=None
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Create display
|
|
48
|
+
self.display = Display(
|
|
49
|
+
self.display_bus,
|
|
50
|
+
width=self.width,
|
|
51
|
+
height=self.height,
|
|
52
|
+
rotation=0,
|
|
53
|
+
color_depth=16,
|
|
54
|
+
auto_refresh=True
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Link display to matrix
|
|
58
|
+
self.display._matrix = self.matrix
|
|
59
|
+
|
|
60
|
+
# Create board module stub
|
|
61
|
+
self._create_board_module()
|
|
62
|
+
|
|
63
|
+
def _create_board_module(self):
|
|
64
|
+
"""Create board module attributes for compatibility."""
|
|
65
|
+
# This would normally be done by importing board
|
|
66
|
+
# but we create a simple stub here
|
|
67
|
+
class BoardStub:
|
|
68
|
+
def __init__(self, display):
|
|
69
|
+
self.DISPLAY = display
|
|
70
|
+
|
|
71
|
+
self.board = BoardStub(self.display)
|
|
72
|
+
|
|
73
|
+
def set_text_color(self, color):
|
|
74
|
+
"""Set default text color (convenience method).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
color: RGB color value
|
|
78
|
+
"""
|
|
79
|
+
# This is a convenience method not in the real hardware
|
|
80
|
+
# but useful for the simulator
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def default_font(self):
|
|
85
|
+
"""Get the default font for this device."""
|
|
86
|
+
from ..terminalio import FONT
|
|
87
|
+
return FONT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""CircuitPython displayio compatibility layer."""
|
|
3
|
+
|
|
4
|
+
from .bitmap import Bitmap
|
|
5
|
+
from .group import Group
|
|
6
|
+
from .palette import Palette
|
|
7
|
+
from .tilegrid import TileGrid
|
|
8
|
+
from .ondiskbitmap import OnDiskBitmap
|
|
9
|
+
from .display import Display
|
|
10
|
+
from .fourwire import FourWire
|
|
11
|
+
|
|
12
|
+
__all__ = ['Bitmap', 'Group', 'Palette', 'TileGrid', 'OnDiskBitmap', 'Display', 'FourWire']
|