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,191 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Hardware timing/memory profile for modeling real-device performance.
3
+
4
+ Single source of truth for the (DESKTOP-ONLY) hardware-realism simulation: how
5
+ slow the real CircuitPython device is and how little RAM it has.
6
+
7
+ IMPORTANT — the hard-coded numbers in the ``*_estimate()`` builders below are
8
+ ROUGH ENGINEERING ESTIMATES, used only as a fallback. When a board's calibrated
9
+ baseline JSON is present, ``profile_for()`` loads it in preference (via
10
+ ``HardwareProfile.from_measurements()``) and flips confidence to
11
+ ``CALIBRATED_FROM_DEVICE``. The shipped ``matrixportal_s3_baseline.json`` WAS
12
+ measured on a real MatrixPortal S3, so the default S3 profile is calibrated;
13
+ recapture with ``test/claude/calibrate_device.py``. Reports built from an
14
+ uncalibrated profile say so and round to one significant figure to avoid false
15
+ precision. Trust the RELATIVE breakdown (which category dominates) more than the
16
+ absolute FPS.
17
+
18
+ This module is part of the simulator package and is never imported on hardware.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ from dataclasses import dataclass
24
+
25
+
26
+ CONFIDENCE_ESTIMATE = "ROUGH_ESTIMATE_UNCALIBRATED"
27
+ CONFIDENCE_CALIBRATED = "CALIBRATED_FROM_DEVICE"
28
+
29
+ # Calibrated baselines (captured on a real board by test/claude/calibrate_device.py)
30
+ # ship next to this module, one JSON per board. When a board's file is present it's
31
+ # used in preference to that board's estimate. The S3 file keeps its historical name
32
+ # rather than following the canonical-id convention, so it's mapped explicitly.
33
+ # Override the location for the default board with SCROLLKIT_HW_BASELINE.
34
+ _DEFAULT_BOARD_ID = "adafruit_matrixportal_s3"
35
+ _BASELINE_FILENAMES = {
36
+ "adafruit_matrixportal_s3": "matrixportal_s3_baseline.json",
37
+ "pimoroni_interstate75_w": "pimoroni_interstate75_w_baseline.json",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class HardwareProfile:
43
+ """A device's modeled timing and memory characteristics."""
44
+
45
+ name: str
46
+ # --- Memory (bytes) ---
47
+ usable_ram_bytes: int # RAM free to the app after CP + displayio overhead
48
+ base_app_ram_bytes: int # modeled floor a running app costs
49
+ bytes_per_label_px: float # RAM per Label bitmap pixel
50
+ # --- Timing (microseconds) ---
51
+ pixel_write_us: float # per matrix.set_pixel() during a group render
52
+ full_refresh_us: float # per display.refresh() (group render + panel update)
53
+ bitmap_rebuild_us_per_px: float # per pixel of a rebuilt text bitmap (the CP killer)
54
+ gc_pause_us: float # modeled garbage-collection stall
55
+ gc_every_n_frames: int # how often a GC pause is modeled
56
+ # --- C bulk-op costs (fill_region / blit via bitmaptools) ---
57
+ # Defaulted so existing callers/baselines are unaffected. Seeded from
58
+ # device_benchmarks.json: fill_region ~147 us / 512 px (~0.287 us/px),
59
+ # blit ~160 us / 256 px (~0.623 us/px). bulk_base_us is a conservative fixed
60
+ # per-call C-dispatch overhead (over-counts slightly so the strict gate fails
61
+ # safe). TODO: capture these at 2-3 sizes to fit base+slope rather than estimate.
62
+ bulk_base_us: float = 12.0
63
+ fill_region_us_per_px: float = 0.287
64
+ blit_us_per_px: float = 0.623
65
+ # --- Honesty ---
66
+ confidence: str = CONFIDENCE_ESTIMATE
67
+ source: str = "engineering estimate"
68
+
69
+ @property
70
+ def is_calibrated(self):
71
+ return self.confidence == CONFIDENCE_CALIBRATED
72
+
73
+ @classmethod
74
+ def from_measurements(cls, path, base=None):
75
+ """Build a CALIBRATED profile from a JSON file of measured values.
76
+
77
+ The JSON may contain any subset of this dataclass's numeric fields; any
78
+ omitted fall back to ``base`` (that board's estimate, or the MatrixPortal
79
+ S3 estimate by default). Use after capturing real frame times / RAM on a
80
+ device.
81
+ """
82
+ if base is None:
83
+ base = matrixportal_s3_estimate()
84
+ fields = {name: getattr(base, name) for name in base.__dataclass_fields__}
85
+ with open(path) as f:
86
+ data = json.load(f)
87
+ for key, value in data.items():
88
+ if key in fields and not key.startswith("_") and value is not None:
89
+ fields[key] = value
90
+ fields["confidence"] = CONFIDENCE_CALIBRATED
91
+ if not data.get("source"): # keep a descriptive source if the file gave one
92
+ fields["source"] = str(path)
93
+ return cls(**fields)
94
+
95
+
96
+ def matrixportal_s3_estimate():
97
+ """Rough ESTIMATE for the Adafruit MatrixPortal S3 (ESP32-S3) + 64x32 panel.
98
+
99
+ Premise (documented assumption): CircuitPython on the S3 is roughly two orders
100
+ of magnitude slower than desktop pygame. Simple scrolling text runs ~15-25
101
+ FPS; busy multi-element frames drop to single digits. RAM free to an app is on
102
+ the order of ~180 KB after CircuitPython, displayio, and the Adafruit
103
+ libraries. Every value below is an estimate, flagged UNCALIBRATED.
104
+ """
105
+ return HardwareProfile(
106
+ name="Adafruit MatrixPortal S3 (64x32) [ESTIMATE]",
107
+ # Memory: ~180 KB free; an app floors around ~40 KB; a 2-color Label
108
+ # bitmap costs ~0.5 byte/px (packed bits + object overhead).
109
+ usable_ram_bytes=180_000,
110
+ base_app_ram_bytes=40_000,
111
+ bytes_per_label_px=0.5,
112
+ # Timing: interpreted per-pixel writes are slow; the dominant cost for
113
+ # scrolling text is rebuilding the glyph bitmap every frame (per-pixel
114
+ # Python loop), which is why bitmap_rebuild_us_per_px is large.
115
+ pixel_write_us=5.0,
116
+ full_refresh_us=5_000.0,
117
+ bitmap_rebuild_us_per_px=75.0,
118
+ gc_pause_us=15_000.0,
119
+ gc_every_n_frames=30,
120
+ )
121
+
122
+
123
+ def interstate75w_estimate():
124
+ """Rough ESTIMATE for the Pimoroni Interstate 75 W (RP2350) + 64x32 panel.
125
+
126
+ UNCALIBRATED — no timing has been captured on a real Interstate 75 W yet.
127
+ Premise (documented assumption): the RP2350 (dual Cortex-M33 @ ~150 MHz)
128
+ drives HUB75 via PIO/DMA, so refresh is efficient, but it has only ~520 KB
129
+ on-chip SRAM and — unlike the S3's 2 MB PSRAM — no large PSRAM by default,
130
+ so RAM free to an app is far tighter (the RAM feasibility gate matters more
131
+ here). Interpreted-Python cost is in the same order as the S3. Replace every
132
+ value below by running test/claude/calibrate_device.py --board
133
+ pimoroni_interstate75_w once the board is in hand.
134
+ """
135
+ return HardwareProfile(
136
+ name="Pimoroni Interstate 75 W (64x32) [ESTIMATE]",
137
+ # Memory: ~520 KB SRAM, no PSRAM; after CircuitPython + displayio + the
138
+ # rgbmatrix framebuffer, an app has on the order of ~200 KB.
139
+ usable_ram_bytes=200_000,
140
+ base_app_ram_bytes=40_000,
141
+ bytes_per_label_px=0.5,
142
+ # Timing: PIO/DMA refresh estimated similar to the S3; interpreted
143
+ # per-pixel/glyph work slightly higher at the lower clock.
144
+ pixel_write_us=5.0,
145
+ full_refresh_us=5_000.0,
146
+ bitmap_rebuild_us_per_px=80.0,
147
+ gc_pause_us=15_000.0,
148
+ gc_every_n_frames=30,
149
+ )
150
+
151
+
152
+ def _estimate_for(board_id):
153
+ """That board's labeled ROUGH ESTIMATE profile (default: S3)."""
154
+ if board_id == "pimoroni_interstate75_w":
155
+ return interstate75w_estimate()
156
+ return matrixportal_s3_estimate()
157
+
158
+
159
+ def baseline_path(board_id=_DEFAULT_BOARD_ID):
160
+ """Path to a board's shipped device baseline JSON (may not exist).
161
+
162
+ ``SCROLLKIT_HW_BASELINE`` overrides the path for the default board.
163
+ """
164
+ if board_id == _DEFAULT_BOARD_ID:
165
+ env = os.environ.get("SCROLLKIT_HW_BASELINE")
166
+ if env:
167
+ return env
168
+ filename = _BASELINE_FILENAMES.get(board_id, "%s_baseline.json" % board_id)
169
+ return os.path.join(os.path.dirname(__file__), filename)
170
+
171
+
172
+ def profile_for(board_id=_DEFAULT_BOARD_ID):
173
+ """The performance profile for ``board_id``.
174
+
175
+ Returns a CALIBRATED profile built from that board's shipped baseline when one
176
+ is present, and falls back to the labeled ROUGH ESTIMATE otherwise. This is
177
+ what the simulator and capability catalog use.
178
+ """
179
+ estimate = _estimate_for(board_id)
180
+ path = baseline_path(board_id)
181
+ try:
182
+ if path and os.path.exists(path):
183
+ return HardwareProfile.from_measurements(path, base=estimate)
184
+ except Exception:
185
+ pass
186
+ return estimate
187
+
188
+
189
+ def matrixportal_s3_profile():
190
+ """The default (MatrixPortal S3) profile. Back-compat alias for ``profile_for``."""
191
+ return profile_for(_DEFAULT_BOARD_ID)
@@ -0,0 +1,307 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Core LED matrix simulation with pixel-level control."""
3
+
4
+ import os
5
+ from .pixel_buffer import PixelBuffer
6
+ from .color_utils import apply_brightness
7
+
8
+ # pygame is imported lazily (inside the methods that build the desktop "LED
9
+ # circle" window surface) so the pixel-buffer render path stays pure-Python +
10
+ # numpy and importable in headless environments without pygame (e.g. Pyodide in
11
+ # the browser, or CI). See the ``headless`` flag below.
12
+
13
+
14
+ class LEDMatrix:
15
+ """Core LED matrix simulation with pixel-level control.
16
+
17
+ This class simulates the physical appearance of an LED matrix display,
18
+ including realistic LED rendering with configurable pitch and appearance.
19
+ """
20
+
21
+ def __init__(self, width, height, pitch=3.0, led_size=None, performance_manager=None,
22
+ headless=False, bit_depth=4):
23
+ """Initialize LED matrix.
24
+
25
+ Args:
26
+ width: Matrix width in pixels
27
+ height: Matrix height in pixels
28
+ pitch: LED pitch in mm (2.5, 3, 4, 5, or 6)
29
+ led_size: Optional LED size override in pixels
30
+ performance_manager: Optional performance simulation manager
31
+ """
32
+ self.width = width
33
+ self.height = height
34
+ self.pitch = pitch
35
+
36
+ # Calculate LED size and spacing based on pitch
37
+ # For a given pitch, LED size is typically 80% of pitch
38
+ # Scale factor adjusted to match physical 192mm x 96mm display (725x362 pixels at 96 DPI)
39
+ # 192mm / 64 pixels = 3mm pitch, display should be ~725 pixels wide
40
+ if led_size is None:
41
+ self.led_size = int(pitch * 0.8 * 3.75) # 9 pixel LED size
42
+ else:
43
+ self.led_size = led_size
44
+ self.spacing = int(pitch * 0.2 * 3.75) # 2 pixel spacing
45
+
46
+ self.pixel_buffer = PixelBuffer(width, height)
47
+ self.brightness = 1.0
48
+ self.performance_manager = performance_manager
49
+ # Headless: populate the pixel buffer but skip the pygame window surface
50
+ # entirely (no pygame import). Per-instance, or process-wide via
51
+ # SCROLLKIT_HEADLESS=1 (used by the browser/Pyodide and CI render paths).
52
+ self.headless = headless or os.environ.get("SCROLLKIT_HEADLESS") == "1"
53
+
54
+ # Calculate surface size based on LED arrangement
55
+ self.surface_width = width * (self.led_size + self.spacing) - self.spacing
56
+ self.surface_height = height * (self.led_size + self.spacing) - self.spacing
57
+
58
+ # Create pygame surface for rendering
59
+ self.surface = None
60
+ self._background_color = (8, 8, 11) # near-black, like a powered-but-unlit HUB75 panel
61
+
62
+ # Honest colour: quantise to the panel's per-channel bit depth (16 levels
63
+ # at bit_depth=4) so the simulator shows what the hardware actually shows,
64
+ # not full 24-bit colour the panel can't display. Set ``bit_depth`` to 6
65
+ # (and the caches self-refresh) for the smooth-gradient finale.
66
+ self.bit_depth = bit_depth
67
+ self.posterize = True
68
+
69
+ # Round-LED + soft-glow appearance geometry, derived from the LED size so
70
+ # it tracks the configured pitch (matches the rendered hero stills).
71
+ self._dot_radius = max(2, int(round(self.led_size * 0.46)))
72
+ self._core_radius = max(1, int(round(self.led_size * 0.22)))
73
+ self._off_radius = max(1, int(round(self.led_size * 0.30)))
74
+ self._off_color = (22, 22, 26) # faint unlit LED, so the grid reads
75
+ self._glow_reach = max(self.led_size, # how far the additive halo bleeds
76
+ int(round((self.led_size + self.spacing) * 0.95)))
77
+ self._glow_cache = {} # posterised color -> additive glow halo sprite
78
+ self._dot_cache = {} # posterised color -> crisp LED dot sprite
79
+ self._base_surface = None # near-black bg + unlit-LED grid (rebuilt only if cleared)
80
+
81
+ def initialize_surface(self):
82
+ """Initialize the pygame surface for rendering (no-op when headless)."""
83
+ if self.headless:
84
+ return
85
+ import pygame
86
+ if not pygame.get_init():
87
+ pygame.init()
88
+
89
+ self.surface = pygame.Surface((self.surface_width, self.surface_height))
90
+ self.surface.fill(self._background_color)
91
+
92
+ def set_pixel(self, x, y, color):
93
+ """Set a single pixel color.
94
+
95
+ Args:
96
+ x: X coordinate (0 to width-1)
97
+ y: Y coordinate (0 to height-1)
98
+ color: Color as (r, g, b) tuple or RGB value
99
+ """
100
+ if self.performance_manager and self.performance_manager.enabled:
101
+ # Simulate pixel write delay
102
+ self.performance_manager.simulate_instruction_delay(3)
103
+
104
+ self.pixel_buffer.set_pixel(x, y, color)
105
+
106
+ def get_pixel(self, x, y):
107
+ """Get a single pixel color.
108
+
109
+ Args:
110
+ x: X coordinate (0 to width-1)
111
+ y: Y coordinate (0 to height-1)
112
+
113
+ Returns:
114
+ Color as (r, g, b) tuple
115
+ """
116
+ return self.pixel_buffer.get_pixel(x, y)
117
+
118
+ def fill(self, color):
119
+ """Fill all pixels with a single color.
120
+
121
+ Args:
122
+ color: Color as (r, g, b) tuple or RGB value
123
+ """
124
+ self.pixel_buffer.fill(color)
125
+
126
+ def clear(self):
127
+ """Clear all pixels to black."""
128
+ self.pixel_buffer.clear()
129
+
130
+ def set_brightness(self, brightness):
131
+ """Set display brightness.
132
+
133
+ Args:
134
+ brightness: Float from 0.0 to 1.0
135
+ """
136
+ self.brightness = max(0.0, min(1.0, brightness))
137
+
138
+ def render(self):
139
+ """Draw the (already-populated) pixel buffer to the pygame window surface.
140
+
141
+ The buffer is filled by set_pixel()/fill() before this runs. When
142
+ headless the perf model still runs (it models device timing, not pixels)
143
+ but the pygame surface step is skipped, so no pygame is needed.
144
+ """
145
+
146
+ if self.performance_manager and self.performance_manager.enabled:
147
+ # Simulate display refresh delay
148
+ self.performance_manager.simulate_io_operation("display_refresh")
149
+ # Simulate potential GC pause during rendering
150
+ self.performance_manager.simulate_gc_pause()
151
+
152
+ if self.headless:
153
+ # Buffer already populated by set_pixel(); skip the pygame surface.
154
+ self.pixel_buffer.clear_dirty()
155
+ return
156
+
157
+ if self.surface is None:
158
+ self.initialize_surface()
159
+
160
+ # Only update dirty regions if tracking is enabled
161
+ if self.pixel_buffer.is_dirty():
162
+ dirty_region = self.pixel_buffer.get_dirty_region()
163
+
164
+ if dirty_region is None:
165
+ # Full update
166
+ self._render_full()
167
+ else:
168
+ # Partial update (future optimization)
169
+ self._render_full() # For now, always do full update
170
+
171
+ self.pixel_buffer.clear_dirty()
172
+
173
+ def _posterize_color(self, color):
174
+ """Quantise an (r, g, b) to the panel's per-channel bit depth.
175
+
176
+ At ``bit_depth=4`` each channel snaps to 16 levels (multiples of 17) — the
177
+ honest hardware colour. Returns plain Python ints (no numpy scalars).
178
+ """
179
+ if not self.posterize:
180
+ return (int(color[0]), int(color[1]), int(color[2]))
181
+ levels = (1 << self.bit_depth) - 1
182
+ if levels <= 0:
183
+ return (int(color[0]), int(color[1]), int(color[2]))
184
+ return tuple(int(round(int(c) / 255.0 * levels)) * 255 // levels
185
+ for c in color)
186
+
187
+ # All round LEDs are drawn supersampled then smooth-scaled down: a plain small
188
+ # pygame circle rasterises to a chunky diamond, so we draw big and shrink for
189
+ # clean anti-aliased dots (matches the rendered hero stills).
190
+ @staticmethod
191
+ def _aa_scale(surface, size):
192
+ import pygame
193
+ return pygame.transform.smoothscale(surface, size)
194
+
195
+ def _build_base_surface(self):
196
+ """Build (and cache) the static panel: near-black ground + the unlit-LED grid."""
197
+ import pygame
198
+ ss = 3
199
+ big = pygame.Surface((self.surface_width * ss, self.surface_height * ss))
200
+ big.fill(self._background_color)
201
+ step = (self.led_size + self.spacing) * ss
202
+ half = (self.led_size // 2) * ss
203
+ rad = max(1, self._off_radius * ss)
204
+ for gy in range(self.height):
205
+ cy = gy * step + half
206
+ for gx in range(self.width):
207
+ cx = gx * step + half
208
+ pygame.draw.circle(big, self._off_color, (cx, cy), rad)
209
+ return self._aa_scale(big, (self.surface_width, self.surface_height))
210
+
211
+ def _glow_sprite(self, color):
212
+ """A radial additive halo for an 'on' LED of ``color`` (cached per colour).
213
+
214
+ Drawn on black so an additive blit contributes only the glow; overlapping
215
+ halos sum, giving the soft bleed of a real panel.
216
+ """
217
+ import pygame
218
+ sprite = self._glow_cache.get(color)
219
+ if sprite is not None:
220
+ return sprite
221
+ ss = 2
222
+ reach = self._glow_reach
223
+ big = pygame.Surface((2 * reach * ss, 2 * reach * ss)) # RGB; additive ignores black
224
+ big.fill((0, 0, 0))
225
+ steps = 6
226
+ for k in range(steps):
227
+ t = k / (steps - 1) # 0 outer .. 1 inner
228
+ rad = int(round((reach - t * (reach - self._dot_radius)) * ss))
229
+ bright = 0.10 + 0.42 * t # dim at the edge, bright near the core
230
+ col = tuple(min(255, int(ch * bright)) for ch in color)
231
+ pygame.draw.circle(big, col, (reach * ss, reach * ss), max(1, rad))
232
+ sprite = self._aa_scale(big, (2 * reach, 2 * reach))
233
+ if len(self._glow_cache) < 256:
234
+ self._glow_cache[color] = sprite
235
+ return sprite
236
+
237
+ def _dot_sprite(self, color):
238
+ """The crisp LED dot + bright core for an 'on' LED (cached per colour)."""
239
+ import pygame
240
+ sprite = self._dot_cache.get(color)
241
+ if sprite is not None:
242
+ return sprite
243
+ ss = 4
244
+ size = self.led_size
245
+ big = pygame.Surface((size * ss, size * ss), pygame.SRCALPHA)
246
+ center = (size // 2) * ss
247
+ pygame.draw.circle(big, color, (center, center), self._dot_radius * ss)
248
+ core = tuple(min(255, int(ch * 1.18) + 40) for ch in color)
249
+ pygame.draw.circle(big, core, (center, center), max(1, self._core_radius * ss))
250
+ sprite = self._aa_scale(big, (size, size))
251
+ if len(self._dot_cache) < 256:
252
+ self._dot_cache[color] = sprite
253
+ return sprite
254
+
255
+ def _render_full(self):
256
+ """Render the panel: near-black ground + unlit grid, then additive glow + dots.
257
+
258
+ Two passes so every halo blooms *under* every crisp dot (and overlapping
259
+ halos add), matching the rendered hero stills.
260
+ """
261
+ import pygame
262
+ if self._base_surface is None:
263
+ self._base_surface = self._build_base_surface()
264
+ self.surface.blit(self._base_surface, (0, 0))
265
+
266
+ pixels = self.pixel_buffer.get_buffer()
267
+ step = self.led_size + self.spacing
268
+ half = self.led_size // 2
269
+
270
+ lit = []
271
+ for y in range(self.height):
272
+ for x in range(self.width):
273
+ color = tuple(int(c) for c in pixels[y, x])
274
+ if self.brightness < 1.0:
275
+ color = apply_brightness(color, self.brightness)
276
+ color = self._posterize_color(color)
277
+ if color[0] + color[1] + color[2] <= 24:
278
+ continue # 'off' — the unlit grid in the base shows through
279
+ lit.append((x * step, y * step, color))
280
+
281
+ reach = self._glow_reach
282
+ for led_x, led_y, color in lit:
283
+ self.surface.blit(self._glow_sprite(color),
284
+ (led_x + half - reach, led_y + half - reach),
285
+ special_flags=pygame.BLEND_RGB_ADD)
286
+ for led_x, led_y, color in lit:
287
+ self.surface.blit(self._dot_sprite(color), (led_x, led_y))
288
+
289
+ def get_surface(self):
290
+ """Get the pygame surface for this matrix.
291
+
292
+ Returns:
293
+ Pygame surface containing rendered LEDs
294
+ """
295
+ if self.surface is None:
296
+ self.initialize_surface()
297
+ return self.surface
298
+
299
+ def save_screenshot(self, filename):
300
+ """Save a screenshot of the current matrix state.
301
+
302
+ Args:
303
+ filename: Path to save the screenshot
304
+ """
305
+ if self.surface:
306
+ import pygame
307
+ pygame.image.save(self.surface, filename)
@@ -0,0 +1,12 @@
1
+ {
2
+ "_mem_after_display": 2073536,
3
+ "_mem_boot": 2074512,
4
+ "base_app_ram_bytes": 976,
5
+ "bitmap_rebuild_us_per_px": 16.53,
6
+ "bytes_per_label_px": 0.1875,
7
+ "full_refresh_us": 4488.12,
8
+ "gc_pause_us": 383.0,
9
+ "name": "Adafruit MatrixPortal S3 (64x32)",
10
+ "source": "measured on adafruit_matrixportal_s3, CircuitPython 9.1.0",
11
+ "usable_ram_bytes": 2073536
12
+ }