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,383 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Headless verification harness — the AI agent's primary tool.
3
+
4
+ ``run_headless(app, frames=N)`` drives a ``ScrollKitApp``'s display loop
5
+ deterministically in a dummy (no-window) pygame, captures a screenshot plus
6
+ pixel/feasibility metrics, and returns a JSON-able ``RunResult`` — no human and
7
+ no real hardware required. The whole point: an AI can write an app, run it here,
8
+ see that it actually rendered and advanced, read an honest estimate of whether
9
+ it would survive on the real (slow, RAM-tiny) MatrixPortal S3, and iterate.
10
+
11
+ Determinism: the loop is stepped exactly ``frames`` times with no inter-frame
12
+ sleep, so the same app + same frame count always reaches the same visual state
13
+ (reproducible compare across edits) and the run finishes fast. ``seconds=S`` is
14
+ sugar for ``frames = round(S * 20)`` (the display loop targets 20 FPS).
15
+
16
+ Desktop-only — imported via ``scrollkit.dev``, which raises on CircuitPython.
17
+ """
18
+
19
+ import os
20
+
21
+ from . import metrics as _metrics
22
+
23
+ # The display loop in app/base.py paces itself at 20 FPS; seconds->frames uses it.
24
+ TARGET_FPS = 20
25
+ DEFAULT_FRAMES = 120
26
+
27
+
28
+ class RunResult:
29
+ """JSON-able summary of a headless run (what the AI inspects)."""
30
+
31
+ def __init__(self, **kw):
32
+ self.frames = kw.get("frames", 0)
33
+ self.estimated_hardware_fps = kw.get("estimated_hardware_fps")
34
+ self.bright_pixels = kw.get("bright_pixels", 0)
35
+ self.lit_pixels = kw.get("lit_pixels", 0)
36
+ self.coverage = kw.get("coverage", 0.0)
37
+ self.is_blank = kw.get("is_blank", True)
38
+ self.advanced = kw.get("advanced", False)
39
+ self.current_content = kw.get("current_content")
40
+ self.memory = kw.get("memory")
41
+ self.hardware = kw.get("hardware") # FeasibilityReport dict or None
42
+ self.hardware_text = kw.get("hardware_text") # human-readable report or None
43
+ self.screenshot = kw.get("screenshot") # saved path or None
44
+ self.gif = kw.get("gif") # saved animated-GIF path or None
45
+ self.video = kw.get("video") # saved MP4 path or None
46
+ self.errors = kw.get("errors") or []
47
+ self.warnings = kw.get("warnings") or []
48
+
49
+ @property
50
+ def ok(self):
51
+ """A quick "did it render anything without errors" boolean."""
52
+ return not self.errors and not self.is_blank
53
+
54
+ def as_dict(self):
55
+ return {
56
+ "frames": self.frames,
57
+ "estimated_hardware_fps": self.estimated_hardware_fps,
58
+ "bright_pixels": self.bright_pixels,
59
+ "lit_pixels": self.lit_pixels,
60
+ "coverage": self.coverage,
61
+ "is_blank": self.is_blank,
62
+ "advanced": self.advanced,
63
+ "current_content": self.current_content,
64
+ "memory": self.memory,
65
+ "hardware": self.hardware,
66
+ "screenshot": self.screenshot,
67
+ "gif": self.gif,
68
+ "video": self.video,
69
+ "errors": list(self.errors),
70
+ "warnings": list(self.warnings),
71
+ "ok": self.ok,
72
+ }
73
+
74
+ def as_text(self):
75
+ lines = ["=== run_headless: %d frames ===" % self.frames]
76
+ lines.append(" Rendered: %s (bright=%d, lit=%d, coverage=%.1f%%, advanced=%s)"
77
+ % ("yes" if not self.is_blank else "BLANK",
78
+ self.bright_pixels, self.lit_pixels,
79
+ self.coverage * 100.0, self.advanced))
80
+ if self.current_content is not None:
81
+ lines.append(" Content: %s" % (self.current_content,))
82
+ if self.screenshot:
83
+ lines.append(" Screenshot: %s" % self.screenshot)
84
+ if self.gif:
85
+ lines.append(" GIF: %s" % self.gif)
86
+ if self.video:
87
+ lines.append(" Video: %s" % self.video)
88
+ if self.errors:
89
+ lines.append(" ERRORS:")
90
+ for e in self.errors:
91
+ lines.append(" - " + e)
92
+ if self.hardware_text:
93
+ lines.append("")
94
+ lines.append(self.hardware_text)
95
+ elif self.warnings:
96
+ lines.append(" Warnings:")
97
+ for w in self.warnings:
98
+ lines.append(" - " + w)
99
+ return "\n".join(lines)
100
+
101
+ def __repr__(self):
102
+ return "RunResult(frames=%d, bright=%d, advanced=%s, errors=%d)" % (
103
+ self.frames, self.bright_pixels, self.advanced, len(self.errors))
104
+
105
+
106
+ def run_headless(app, frames=None, seconds=None, screenshot=None,
107
+ hardware=True, warmup_data=False, strict=False, gif=None,
108
+ gif_opts=None, video=None, video_opts=None):
109
+ """Run ``app`` headlessly for a fixed number of frames; return a ``RunResult``.
110
+
111
+ Args:
112
+ app: a ``ScrollKitApp`` instance whose ``setup()`` populates content.
113
+ frames: number of display frames to render (default 120).
114
+ seconds: alternative to ``frames``; converted at 20 FPS.
115
+ screenshot: path to save the final frame as a PNG (optional).
116
+ gif: path to save the whole run as an animated GIF (optional). Records
117
+ every rendered frame from the simulator's LED panel and encodes it
118
+ via ``display.save_gif``; the saved path lands on ``result.gif``.
119
+ Ignored if the display can't record (e.g. real hardware).
120
+ gif_opts: optional dict of keyword args forwarded to ``display.save_gif``
121
+ (e.g. ``{"target_width": 320, "frame_step": 2, "max_colors": 48}``)
122
+ to tune the encoded GIF's size/quality. Ignored unless ``gif`` is set.
123
+ video: path to save the whole run as an MP4 (optional), via
124
+ ``display.save_video``; the saved path lands on ``result.video``.
125
+ Mutually exclusive with ``gif`` in one run (the recording is consumed
126
+ by the first save). Ignored if the display can't record.
127
+ video_opts: optional dict forwarded to ``display.save_video`` (e.g.
128
+ ``{"crf": 20, "border": 22}``). Ignored unless ``video`` is set.
129
+ hardware: model real-hardware timing/RAM and include a feasibility
130
+ report (default True).
131
+ warmup_data: call ``update_data()`` once after ``setup()`` (default
132
+ False — kept off so headless runs never block on the network).
133
+ strict: enforce the feasibility gate. A sustained over-budget run (or a
134
+ catastrophic single frame, or a RAM breach) raises FeasibilityError,
135
+ which is caught, recorded in ``result.errors`` (tagged ``feasibility:``)
136
+ and stops the run, so ``result.ok`` is False. Implies hardware
137
+ modeling. Off by default.
138
+
139
+ Synchronous wrapper around :func:`run_headless_async`. Call the async form
140
+ directly from inside an existing event loop.
141
+ """
142
+ import asyncio
143
+ return asyncio.run(run_headless_async(
144
+ app, frames=frames, seconds=seconds, screenshot=screenshot,
145
+ hardware=hardware, warmup_data=warmup_data, strict=strict, gif=gif,
146
+ gif_opts=gif_opts, video=video, video_opts=video_opts))
147
+
148
+
149
+ def record_gif(app, path, *, seconds=4.0, hardware=False, **gif_opts):
150
+ """Render ``app`` headlessly and save an animated GIF of the run to ``path``.
151
+
152
+ A thin convenience over :func:`run_headless` with ``gif=path`` — the natural
153
+ way to make a shareable preview of an app (docs, README, a bug report).
154
+ Extra keyword args (``target_width``, ``max_colors``, ``frame_step``, ``fps``)
155
+ are forwarded to ``display.save_gif`` to tune size/quality. Returns the saved
156
+ GIF path, or None if the display can't record (e.g. real hardware / no pygame).
157
+ """
158
+ return run_headless(app, seconds=seconds, gif=path, hardware=hardware,
159
+ gif_opts=gif_opts or None).gif
160
+
161
+
162
+ def record_video(app, path, *, seconds=4.0, hardware=False, **video_opts):
163
+ """Render ``app`` headlessly and save an MP4 of the run to ``path``.
164
+
165
+ The MP4 sibling of :func:`record_gif` (via ``display.save_video``) — far
166
+ smaller and smoother than a GIF, the right format for a site hero. Extra
167
+ keyword args (``crf``, ``target_width``, ``border``, ``fps``, ``preset``) are
168
+ forwarded to ``display.save_video``. Returns the saved path, or None if the
169
+ display can't record (e.g. real hardware / no ffmpeg).
170
+ """
171
+ return run_headless(app, seconds=seconds, video=path, hardware=hardware,
172
+ video_opts=video_opts or None).video
173
+
174
+
175
+ async def run_headless_async(app, frames=None, seconds=None, screenshot=None,
176
+ hardware=True, warmup_data=False, strict=False,
177
+ gif=None, gif_opts=None, video=None, video_opts=None):
178
+ """Async core of :func:`run_headless` (use inside a running event loop)."""
179
+ if frames is None and seconds is not None:
180
+ frames = max(1, int(round(seconds * TARGET_FPS)))
181
+ if frames is None:
182
+ frames = DEFAULT_FRAMES
183
+
184
+ # strict enforcement needs the timing model active, so it implies hardware.
185
+ if strict:
186
+ hardware = True
187
+
188
+ # Headless pygame + opt the simulator into hardware timing via its env hook,
189
+ # so this works regardless of how the app builds its display. All are
190
+ # restored afterwards so we don't perturb the caller's environment.
191
+ prev_sdl = os.environ.get("SDL_VIDEODRIVER")
192
+ os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
193
+ prev_hw = os.environ.get("SCROLLKIT_HW_SIM")
194
+ prev_strict = os.environ.get("SCROLLKIT_HW_STRICT")
195
+ if hardware:
196
+ os.environ["SCROLLKIT_HW_SIM"] = "1"
197
+ if strict:
198
+ os.environ["SCROLLKIT_HW_STRICT"] = "1"
199
+
200
+ errors = []
201
+ warnings = []
202
+ first_sig = None
203
+ last_sig = None
204
+
205
+ try:
206
+ await app._initialize_display()
207
+
208
+ # If a GIF was requested, start capturing frames from the simulator's
209
+ # LED panel now (no-op on displays that can't record, e.g. hardware).
210
+ if (gif or video) and app.display is not None \
211
+ and hasattr(app.display, "start_recording"):
212
+ app.display.start_recording()
213
+
214
+ # Headless runs must stay fast, deterministic, and quiet: never crawl in
215
+ # real time and never print ambient nags, even if the app built its
216
+ # display with throttle=True or SCROLLKIT_HW_THROTTLE is set.
217
+ try:
218
+ from scrollkit.simulator.core.performance_manager import get_active
219
+ _pm = get_active()
220
+ if _pm is not None:
221
+ _pm.throttle = False
222
+ _pm.ambient_warnings = False
223
+ except Exception:
224
+ pass
225
+
226
+ import time
227
+ app.running = True
228
+ app._run_start = time.monotonic() if hasattr(time, "monotonic") else None
229
+
230
+ try:
231
+ await app.setup()
232
+ except Exception as e: # surface, don't crash the harness
233
+ errors.append("setup() failed: %r" % (e,))
234
+
235
+ if warmup_data:
236
+ try:
237
+ await app.update_data()
238
+ except Exception as e:
239
+ warnings.append("update_data() failed: %r" % (e,))
240
+
241
+ # Step the display loop deterministically through the app's OWN frame
242
+ # implementation (SLDKApp.step_frame — settings application, transition
243
+ # firing/rendering, content render, show), minus the 20 FPS sleep and
244
+ # periodic memory report. Never a copy of the loop: the strict gate
245
+ # must exercise exactly the code path that ships, transitions included.
246
+ app._reset_frame_state()
247
+ for i in range(frames):
248
+ try:
249
+ closed = await app.step_frame() is False
250
+ if closed:
251
+ warnings.append("display closed at frame %d" % (i + 1))
252
+ break
253
+ if app._current_content is not None and app.display:
254
+ buf = _metrics.buffer_from_display(app.display)
255
+ sig = _metrics.signature(buf) if buf is not None else None
256
+ if first_sig is None:
257
+ first_sig = sig
258
+ last_sig = sig
259
+ except Exception as e:
260
+ # The strict feasibility gate raises FeasibilityError mid-render
261
+ # (deep inside display.show()); surface it with a clear tag so
262
+ # callers can tell a budget bust from an ordinary crash.
263
+ from scrollkit.exceptions import FeasibilityError
264
+ if isinstance(e, FeasibilityError):
265
+ errors.append("feasibility: frame %d busts the device budget: %s"
266
+ % (i + 1, e))
267
+ else:
268
+ errors.append("frame %d failed: %r" % (i + 1, e))
269
+ break
270
+
271
+ shot = None
272
+ if screenshot and app.display and hasattr(app.display, "screenshot"):
273
+ try:
274
+ shot = app.display.screenshot(screenshot)
275
+ if shot is None:
276
+ warnings.append("screenshot unavailable (no pygame surface)")
277
+ except Exception as e:
278
+ warnings.append("screenshot failed: %r" % (e,))
279
+
280
+ gif_path = None
281
+ if gif and app.display and hasattr(app.display, "save_gif"):
282
+ try:
283
+ gif_path = app.display.save_gif(gif, **(gif_opts or {}))
284
+ if gif_path is None:
285
+ warnings.append("gif unavailable (no recorded frames / no pygame)")
286
+ except Exception as e:
287
+ warnings.append("gif save failed: %r" % (e,))
288
+
289
+ video_path = None
290
+ if video and app.display and hasattr(app.display, "save_video"):
291
+ try:
292
+ video_path = app.display.save_video(video, **(video_opts or {}))
293
+ if video_path is None:
294
+ warnings.append("video unavailable (no recorded frames / no ffmpeg)")
295
+ except Exception as e:
296
+ warnings.append("video save failed: %r" % (e,))
297
+
298
+ buf = _metrics.buffer_from_display(app.display)
299
+ snap = _metrics.snapshot(buf)
300
+ if not snap["available"]:
301
+ warnings.append("no pixel buffer — pixel metrics unavailable "
302
+ "(display is not the pygame simulator)")
303
+
304
+ hw_dict = None
305
+ hw_text = None
306
+ if hardware:
307
+ try:
308
+ from scrollkit.simulator.core.performance_manager import get_active
309
+ pm = get_active()
310
+ if pm is not None:
311
+ report = pm.report()
312
+ hw_dict = report.as_dict()
313
+ hw_text = report.as_text()
314
+ for w in report.warnings:
315
+ if w not in warnings:
316
+ warnings.append(w)
317
+ else:
318
+ warnings.append("hardware timing requested but no model is "
319
+ "active (display is not the simulator?)")
320
+ except Exception as e:
321
+ warnings.append("feasibility report unavailable: %r" % (e,))
322
+
323
+ advanced = (first_sig is not None and last_sig is not None
324
+ and first_sig != last_sig)
325
+
326
+ try:
327
+ content_desc = app.describe().get("current_content")
328
+ except Exception:
329
+ content_desc = None
330
+ try:
331
+ memory = app.memory_estimate()
332
+ except Exception:
333
+ memory = None
334
+
335
+ return RunResult(
336
+ frames=app._frame_count,
337
+ estimated_hardware_fps=(hw_dict or {}).get("estimated_hardware_fps"),
338
+ bright_pixels=snap["bright_pixels"],
339
+ lit_pixels=snap["lit_pixels"],
340
+ coverage=snap["coverage"],
341
+ is_blank=snap["is_blank"],
342
+ advanced=advanced,
343
+ current_content=content_desc,
344
+ memory=memory,
345
+ hardware=hw_dict,
346
+ hardware_text=hw_text,
347
+ screenshot=shot,
348
+ gif=gif_path,
349
+ video=video_path,
350
+ errors=errors,
351
+ warnings=warnings,
352
+ )
353
+ finally:
354
+ app.running = False
355
+ # Release any still-open recording (save_gif already clears it on the
356
+ # happy path; this covers an early error before the save).
357
+ try:
358
+ if app.display is not None and hasattr(app.display, "stop_recording"):
359
+ app.display.stop_recording()
360
+ except Exception:
361
+ pass
362
+ try:
363
+ await app.cleanup()
364
+ except Exception:
365
+ pass
366
+ # Don't leak the module-global active perf manager across runs/tests.
367
+ try:
368
+ from scrollkit.simulator.core.performance_manager import set_active
369
+ set_active(None)
370
+ except Exception:
371
+ pass
372
+ if prev_hw is None:
373
+ os.environ.pop("SCROLLKIT_HW_SIM", None)
374
+ else:
375
+ os.environ["SCROLLKIT_HW_SIM"] = prev_hw
376
+ if prev_strict is None:
377
+ os.environ.pop("SCROLLKIT_HW_STRICT", None)
378
+ else:
379
+ os.environ["SCROLLKIT_HW_STRICT"] = prev_strict
380
+ # Restore SDL too: leaving "dummy" set would make a LATER live-window
381
+ # run in the same process silently headless.
382
+ if prev_sdl is None:
383
+ os.environ.pop("SDL_VIDEODRIVER", None)
@@ -0,0 +1,91 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Pixel-level metrics for headless verification (desktop-only, numpy).
3
+
4
+ Reads the simulator's true-color RGB888 buffer
5
+ (``matrix.pixel_buffer.get_buffer()`` -> ndarray ``(H, W, 3)`` uint8 — the same
6
+ array ``demos/.../rainbow.py::calibrate()`` inspects) and reduces it to a few
7
+ numbers an AI agent can reason about: is anything lit, how much of the panel is
8
+ covered, how many "bright" pixels there are, and a cheap signature for detecting
9
+ motion between frames.
10
+
11
+ These read the *logical* pixel buffer (pre-brightness, pre-LED-spacing), so the
12
+ numbers are independent of how the simulator happens to draw LEDs to the window.
13
+ """
14
+
15
+ import numpy as np
16
+
17
+ # A pixel counts as "bright" when its channels sum above this — matches the
18
+ # ``r + g + b > 250`` rule the render smoke test uses.
19
+ BRIGHT_SUM_THRESHOLD = 250
20
+
21
+
22
+ def buffer_from_display(display):
23
+ """Return the ``(H, W, 3)`` uint8 RGB buffer for a simulator display, or None.
24
+
25
+ None when the display isn't a pixel-buffer simulator (e.g. a terminal or
26
+ real hardware), so callers degrade gracefully instead of crashing.
27
+ """
28
+ matrix = getattr(display, "matrix", None)
29
+ if matrix is None:
30
+ return None
31
+ pb = getattr(matrix, "pixel_buffer", None)
32
+ if pb is None or not hasattr(pb, "get_buffer"):
33
+ return None
34
+ return pb.get_buffer()
35
+
36
+
37
+ def lit_mask(buffer):
38
+ """Boolean ``(H, W)`` mask of pixels that are not pure black."""
39
+ return np.any(buffer > 0, axis=2)
40
+
41
+
42
+ def bright_pixels(buffer, threshold=BRIGHT_SUM_THRESHOLD):
43
+ """Count pixels whose ``R + G + B`` exceeds ``threshold``."""
44
+ sums = buffer.astype(np.int32).sum(axis=2)
45
+ return int(np.count_nonzero(sums > threshold))
46
+
47
+
48
+ def lit_pixels(buffer):
49
+ """Count pixels that are lit at all (any channel > 0)."""
50
+ return int(np.count_nonzero(lit_mask(buffer)))
51
+
52
+
53
+ def coverage(buffer):
54
+ """Fraction (0..1) of the panel that is lit."""
55
+ height, width = buffer.shape[0], buffer.shape[1]
56
+ total = height * width
57
+ if total == 0:
58
+ return 0.0
59
+ return lit_pixels(buffer) / total
60
+
61
+
62
+ def is_blank(buffer):
63
+ """True when nothing at all is lit."""
64
+ return lit_pixels(buffer) == 0
65
+
66
+
67
+ def signature(buffer):
68
+ """Cheap content fingerprint for detecting change/motion between frames.
69
+
70
+ Sensitive to any pixel change; stable within a process. Compare the
71
+ signature of an early frame to a late frame to tell whether anything moved.
72
+ """
73
+ return hash(buffer.tobytes())
74
+
75
+
76
+ def snapshot(buffer):
77
+ """All scalar metrics for one frame as a JSON-able dict.
78
+
79
+ ``available`` is False (and everything zero/blank) when no pixel buffer was
80
+ available, so the harness can report honestly rather than inventing numbers.
81
+ """
82
+ if buffer is None:
83
+ return {"bright_pixels": 0, "lit_pixels": 0, "coverage": 0.0,
84
+ "is_blank": True, "available": False}
85
+ return {
86
+ "bright_pixels": bright_pixels(buffer),
87
+ "lit_pixels": lit_pixels(buffer),
88
+ "coverage": round(coverage(buffer), 4),
89
+ "is_blank": is_blank(buffer),
90
+ "available": True,
91
+ }
@@ -0,0 +1,174 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Measured performance trade-offs for the MatrixPortal S3 — for AI authoring.
3
+
4
+ Turns the raw microbenchmark table (``simulator/core/device_benchmarks.json``,
5
+ captured on a real device by ``test/claude/device_benchmarks.py``) into a curated
6
+ guide an agent can reason about: which calls are cheap C vs slow interpreted
7
+ Python, the bit_depth/refresh ladder, allocation costs, and the cardinal rules
8
+ that follow. Numbers come straight from the device, so the guidance can't drift.
9
+
10
+ Desktop-only (imported via ``scrollkit.dev``).
11
+ """
12
+
13
+ import json
14
+ import os
15
+
16
+ _BENCH_DIR = os.path.join(os.path.dirname(__file__), "..", "simulator", "core")
17
+ _DEFAULT_BOARD_ID = "adafruit_matrixportal_s3"
18
+ # The S3 benchmark file keeps its historical name; others follow the canonical-id
19
+ # convention (matching test/claude/device_benchmarks.py --board output).
20
+ _BENCH_FILENAMES = {
21
+ "adafruit_matrixportal_s3": "device_benchmarks.json",
22
+ "pimoroni_interstate75_w": "pimoroni_interstate75_w_benchmarks.json",
23
+ }
24
+
25
+
26
+ def _bench_path(board_id):
27
+ filename = _BENCH_FILENAMES.get(board_id, "%s_benchmarks.json" % board_id)
28
+ return os.path.join(_BENCH_DIR, filename)
29
+
30
+
31
+ def _load_doc(board_id):
32
+ with open(os.path.abspath(_bench_path(board_id))) as f:
33
+ return json.load(f)
34
+
35
+
36
+ def _by_name(rows):
37
+ return {r["name"]: r for r in rows}
38
+
39
+
40
+ def performance_guide(board_id=None):
41
+ """Return a JSON-able, device-measured performance guide for ``board_id``.
42
+
43
+ Sections: ``pixel_write`` (the C-vs-interpreted spread), ``refresh`` (the
44
+ bit_depth ladder + FPS ceiling), ``allocation``, ``compute``, ``gc``, and
45
+ ``rules`` (the actionable cardinal rules). ``board_id`` defaults to the
46
+ MatrixPortal S3. Falls back to an empty/quiet guide if that board has no
47
+ captured benchmark file yet (e.g. a board wired in code but not yet
48
+ calibrated).
49
+ """
50
+ if board_id is None:
51
+ board_id = _DEFAULT_BOARD_ID
52
+ try:
53
+ doc = _load_doc(board_id)
54
+ except Exception:
55
+ return {"available": False, "board": board_id,
56
+ "note": "no device benchmark data for %s; run "
57
+ "test/claude/device_benchmarks.py --board %s"
58
+ % (board_id, board_id)}
59
+ rows = doc.get("benchmarks", [])
60
+ b = _by_name(rows)
61
+ src_board = doc.get("board", board_id)
62
+ src_cp = doc.get("cp")
63
+ source = ("measured on %s, CircuitPython %s" % (src_board, src_cp)
64
+ if src_cp else "measured on %s" % src_board)
65
+
66
+ def val(name):
67
+ r = b.get(name)
68
+ return r["value"] if r and r.get("iters") else None
69
+
70
+ # Pixel-write spread (per pixel): interpreted set vs C blit vs C fill.
71
+ set_px = val("bitmap_setpixel") # ns/px (interpreted)
72
+ blit = val("bitmaptools_blit_16x16")
73
+ blit_px = (blit / 256.0) if blit else None # 16x16 region
74
+ fill = val("bitmap_fill_64x32")
75
+ fill_px = (fill / 2048.0) if fill else None # 64x32 region
76
+
77
+ # Refresh / bit_depth ladder.
78
+ refresh = {}
79
+ for bd in (1, 2, 4, 6):
80
+ v = val("refresh_full_bd%d" % bd)
81
+ if v:
82
+ refresh["bit_depth_%d" % bd] = {
83
+ "full_refresh_ms": round(v / 1e6, 2),
84
+ "fps_ceiling": int(1e9 / v),
85
+ }
86
+
87
+ guide = {
88
+ "available": True,
89
+ "board": src_board,
90
+ "source": source,
91
+ "pixel_write_ns_per_px": {
92
+ "interpreted_setpixel": _r(set_px),
93
+ "c_bitmaptools_blit": _r(blit_px),
94
+ "c_bitmap_fill": _r(fill_px),
95
+ "note": _ratio_note(set_px, blit_px, fill_px),
96
+ },
97
+ "refresh": refresh,
98
+ "allocation_ns": {
99
+ "bitmap_32x16": val("bitmap_alloc_32x16"),
100
+ "tilegrid": val("tilegrid_create"),
101
+ "bytearray_256": val("bytearray_alloc_256"),
102
+ "note": "Allocating objects every frame is a real per-frame tax; "
103
+ "create once and mutate.",
104
+ },
105
+ "compute_ns_per_op": {
106
+ "loop_iter": val("noop_loop_iter"),
107
+ "int_op": val("int_add"),
108
+ "func_call": val("func_call"),
109
+ "note": "~500k simple ops/sec. CircuitPython is interpreted; a "
110
+ "1000-op calculation costs ~1.5 ms straight out of the frame "
111
+ "budget (it's cooperative — there is no background thread).",
112
+ },
113
+ "gc_collect_ns": {
114
+ "value": val("gc_collect"),
115
+ "note": "Scales with live objects / heap size (~0.3 ms clean, ~0.9 ms "
116
+ "after churn). Fewer live allocations = shorter GC pauses.",
117
+ },
118
+ "rules": _rules(),
119
+ }
120
+ return guide
121
+
122
+
123
+ def _rules():
124
+ return [
125
+ "Reuse a Label and change .text only when the value actually changes; "
126
+ "for scrolling, move .x and leave .text alone (a text change rebuilds the "
127
+ "glyph bitmap pixel-by-pixel — the dominant per-frame cost).",
128
+ "Never push pixels in a Python loop. Use the C bulk calls — bitmap.fill "
129
+ "for solids, bitmaptools.blit to copy — they are ~11x (blit) to ~1500x "
130
+ "(fill) faster per pixel than interpreted bitmap[x,y]=... writes.",
131
+ "Keep bit_depth at 4 unless you need smooth color gradients: a full "
132
+ "refresh is ~4.5 ms at bit_depth<=4 but ~13.7 ms at 6 (~3x slower, so "
133
+ "~220 vs ~73 FPS ceiling).",
134
+ "Don't allocate per frame (Label/Bitmap/TileGrid/Group). Create the "
135
+ "display objects once and mutate them; allocation is tens of microseconds "
136
+ "each and feeds the GC.",
137
+ "Heavy computation competes directly with rendering (cooperative "
138
+ "multitasking, ~500k Python ops/sec). Break long work into chunks across "
139
+ "frames so scrolling keeps moving.",
140
+ ]
141
+
142
+
143
+ def _r(x):
144
+ return round(x, 1) if x is not None else None
145
+
146
+
147
+ def _ratio_note(set_px, blit_px, fill_px):
148
+ if not (set_px and blit_px and fill_px):
149
+ return "interpreted per-pixel writes are far slower than the C bulk ops"
150
+ return ("interpreted bitmap[x,y]= is ~%dx slower than a C blit and ~%dx "
151
+ "slower than a C fill, per pixel"
152
+ % (round(set_px / blit_px), round(set_px / fill_px)))
153
+
154
+
155
+ def performance_text(guide=None):
156
+ """Render the performance guide as a compact human/AI summary."""
157
+ guide = guide or performance_guide()
158
+ if not guide.get("available"):
159
+ return "Performance guide unavailable (no device benchmark data)."
160
+ lines = ["=== %s performance (measured) ===" % guide.get("board", "device")]
161
+ pw = guide["pixel_write_ns_per_px"]
162
+ lines.append("Pixel write ns/px: interpreted=%s | C blit=%s | C fill=%s"
163
+ % (pw["interpreted_setpixel"], pw["c_bitmaptools_blit"],
164
+ pw["c_bitmap_fill"]))
165
+ lines.append(" -> %s" % pw["note"])
166
+ if guide["refresh"]:
167
+ parts = ["%s=%sms(~%dfps)" % (k.replace("bit_depth_", "bd"),
168
+ v["full_refresh_ms"], v["fps_ceiling"])
169
+ for k, v in guide["refresh"].items()]
170
+ lines.append("Full refresh by depth: " + " | ".join(parts))
171
+ lines.append("Rules:")
172
+ for r in guide["rules"]:
173
+ lines.append(" - " + r)
174
+ return "\n".join(lines)