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.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. 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,7 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """SLDK simulator device implementations."""
3
+
4
+ from .matrixportal_s3 import MatrixPortalS3
5
+ from .base_device import BaseDevice
6
+
7
+ __all__ = ['MatrixPortalS3', 'BaseDevice']
@@ -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']