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,331 @@
1
+ """WiFi onboarding portal — configure Wi-Fi from a phone, no file editing.
2
+
3
+ This is the restored no-``secrets.py`` setup flow (see the original in git at
4
+ ``149ed9c:src/wifimgr.py`` — this is a redesign against the current
5
+ architecture, not a revert). The flow:
6
+
7
+ 1. The device can't join Wi-Fi (no/wrong credentials), so the app calls
8
+ ``WiFiManager.run_setup_portal(display=...)`` — usually right after a
9
+ failed ``connect()``.
10
+ 2. The device starts its own access point (``WifiManager_XXXX``) and shows
11
+ join instructions on the LED panel.
12
+ 3. The user's phone joins that AP and opens ``http://192.168.4.1``: a page
13
+ with the scanned nearby networks (signal bars), a manual-SSID field, and
14
+ a password field.
15
+ 4. Submitting saves ``wifi_ssid``/``wifi_password`` through the
16
+ ``SettingsManager`` (settings.json — never a code file), then the device
17
+ reboots and ``WiFiManager`` connects with the saved credentials.
18
+
19
+ Contract notes (same discipline as ``settings_server``):
20
+ - The portal only ever WRITES SETTINGS. It never touches the content queue.
21
+ It runs as a blocking boot-phase flow that owns the screen exclusively —
22
+ before the app's display loop starts — like ``OTAProgressDisplay.
23
+ install_pending()``.
24
+ - ``adafruit_httpserver``/``socketpool``/``wifi`` are imported lazily inside
25
+ methods, so importing this module costs nothing and the device only pays
26
+ for the portal on an unconfigured boot.
27
+ - Desktop CPython works too (stdlib ``socket`` as the pool, localhost) so
28
+ the flow is testable off-device.
29
+
30
+ Copyright (c) 2024-2026 Michael Winslow Czeiszperger
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import time
36
+
37
+ from ..utils.url_utils import url_decode as _url_decode
38
+
39
+ _SEE_OTHER = (303, "See Other")
40
+ _POLL_INTERVAL = 0.05 # seconds between poll() calls (matches settings_server)
41
+ # Keep serving briefly after a save so the browser finishes receiving the
42
+ # confirmation page before the server (and on hardware, the AP) goes away.
43
+ _LINGER_S = 2.0
44
+
45
+
46
+ __all__ = ['WiFiSetupPortal']
47
+
48
+
49
+ def _esc(text):
50
+ return (str(text).replace("&", "&amp;").replace("<", "&lt;")
51
+ .replace(">", "&gt;").replace('"', "&quot;"))
52
+
53
+
54
+ def _bars(rssi):
55
+ """Rough signal-strength bars from RSSI (dBm)."""
56
+ try:
57
+ rssi = int(rssi)
58
+ except (ValueError, TypeError):
59
+ return 1
60
+ if rssi >= -55:
61
+ return 4
62
+ if rssi >= -65:
63
+ return 3
64
+ if rssi >= -75:
65
+ return 2
66
+ return 1
67
+
68
+
69
+ _PAGE_HEADER = """<!DOCTYPE html>
70
+ <html><head><meta charset="UTF-8">
71
+ <meta name="viewport" content="width=device-width,initial-scale=1">
72
+ <title>Wi-Fi Setup</title>
73
+ <style>
74
+ body{font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 16px;background:#f5f5f5}
75
+ h1{font-size:1.2em;margin-bottom:8px;color:#222}
76
+ p.hint{font-size:.85em;color:#555;margin-top:0}
77
+ .field{margin-bottom:16px}
78
+ label{display:block;font-size:.85em;color:#555;margin-bottom:4px}
79
+ input[type=text],input[type=password],select{
80
+ width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;
81
+ box-sizing:border-box;font-size:1em;background:#fff}
82
+ button{background:#333;color:#fff;border:none;padding:10px 28px;
83
+ border-radius:4px;font-size:1em;cursor:pointer;margin-top:8px}
84
+ button:hover{background:#555}
85
+ </style></head>
86
+ <body><h1>Wi-Fi Setup</h1>
87
+ <p class="hint">Pick your network (or type its name), enter the password,
88
+ and the display will restart and connect.</p>
89
+ <form method="POST" action="/save">
90
+ """
91
+
92
+ _PAGE_FOOTER = """<button type="submit">Connect</button>
93
+ </form></body></html>
94
+ """
95
+
96
+ _SAVED_PAGE = """<!DOCTYPE html>
97
+ <html><head><meta charset="UTF-8">
98
+ <meta name="viewport" content="width=device-width,initial-scale=1">
99
+ <title>Wi-Fi Saved</title></head>
100
+ <body style="font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 16px">
101
+ <h1>Saved</h1>
102
+ <p>The display is restarting and will join <b>%s</b>.</p>
103
+ <p>You can now reconnect this phone to your normal Wi-Fi.</p>
104
+ </body></html>
105
+ """
106
+
107
+
108
+ class WiFiSetupPortal:
109
+ """Access-point onboarding portal over a ``WiFiManager``.
110
+
111
+ Usually not constructed directly — call
112
+ ``WiFiManager.run_setup_portal(display=...)`` instead.
113
+ """
114
+
115
+ def __init__(self, wifi_manager, display=None, port=80):
116
+ self._wm = wifi_manager
117
+ self._display = display
118
+ self._port = port
119
+ self._server = None
120
+ self._server_url = ""
121
+ self._networks = []
122
+ self.saved = False
123
+ self.saved_ssid = None
124
+ self.error = None
125
+
126
+ # ------------------------------------------------------------------ #
127
+ # Form rendering
128
+ # ------------------------------------------------------------------ #
129
+
130
+ def _render_form(self):
131
+ parts = [_PAGE_HEADER]
132
+
133
+ if self._networks:
134
+ opts = ['<option value="" disabled selected>Choose a network...'
135
+ '</option>']
136
+ for net in self._networks:
137
+ ssid = net.get("ssid", "")
138
+ if not ssid:
139
+ continue
140
+ rssi = net.get("rssi", -99)
141
+ opts.append('<option value="%s">%s (%s bars, %s dBm)</option>'
142
+ % (_esc(ssid), _esc(ssid), _bars(rssi), rssi))
143
+ parts.append(
144
+ '<div class="field"><label>Nearby networks</label>'
145
+ '<select name="ssid">%s</select></div>' % "".join(opts)
146
+ )
147
+
148
+ parts.append(
149
+ '<div class="field"><label>Or type a network name</label>'
150
+ '<input type="text" name="ssid_manual" value=""></div>'
151
+ )
152
+ parts.append(
153
+ '<div class="field"><label>Password</label>'
154
+ '<input type="password" name="password" value=""></div>'
155
+ )
156
+ if self.error:
157
+ parts.append('<p style="color:#a00">%s</p>' % _esc(self.error))
158
+ parts.append(_PAGE_FOOTER)
159
+ return "".join(parts)
160
+
161
+ # ------------------------------------------------------------------ #
162
+ # POST /save
163
+ # ------------------------------------------------------------------ #
164
+
165
+ def _apply(self, request):
166
+ """Read the submitted form; save credentials through the settings
167
+ manager. Returns True when credentials were saved. Only ever writes
168
+ settings — never display/queue state."""
169
+ fd = getattr(request, "form_data", None)
170
+
171
+ def _get(key):
172
+ if fd is None:
173
+ return ""
174
+ try:
175
+ v = fd.get(key)
176
+ except Exception:
177
+ return ""
178
+ if v is None:
179
+ return ""
180
+ if isinstance(v, bytes):
181
+ v = v.decode("utf-8", "replace")
182
+ if isinstance(v, str):
183
+ v = _url_decode(v)
184
+ return v.strip()
185
+
186
+ # A typed name beats the dropdown (covers hidden networks and an
187
+ # empty scan); the dropdown is the common path.
188
+ ssid = _get("ssid_manual") or _get("ssid")
189
+ password = _get("password")
190
+
191
+ if not ssid:
192
+ self.error = "Please choose or type a network name."
193
+ return False
194
+
195
+ self._wm.ssid = ssid
196
+ self._wm.password = password
197
+ self._wm.save_credentials() # -> settings.json, never a code file
198
+ self.saved = True
199
+ self.saved_ssid = ssid
200
+ self.error = None
201
+ return True
202
+
203
+ # ------------------------------------------------------------------ #
204
+ # Server plumbing (lazy adafruit_httpserver import)
205
+ # ------------------------------------------------------------------ #
206
+
207
+ def _build_server(self):
208
+ import sys
209
+ import socket as _stdlib_socket
210
+ from adafruit_httpserver import Server, Response, Redirect, GET, POST
211
+
212
+ is_cp = (hasattr(sys, "implementation")
213
+ and sys.implementation.name == "circuitpython")
214
+ if is_cp:
215
+ import socketpool
216
+ import wifi
217
+ pool = socketpool.SocketPool(wifi.radio)
218
+ host = self._wm.ap_ip_address()
219
+ else:
220
+ pool = _stdlib_socket
221
+ host = "localhost"
222
+
223
+ self._server = Server(pool, root_path=None, debug=False)
224
+ self._server_url = "http://{}:{}/".format(host, self._port)
225
+ self._host = host
226
+
227
+ _self = self
228
+
229
+ @self._server.route("/", [GET])
230
+ def _index(request):
231
+ return Response(request, _self._render_form(),
232
+ content_type="text/html")
233
+
234
+ @self._server.route("/save", [POST])
235
+ def _save(request):
236
+ if _self._apply(request):
237
+ return Response(request, _SAVED_PAGE % _esc(_self.saved_ssid),
238
+ content_type="text/html")
239
+ return Redirect(request, "/", status=_SEE_OTHER)
240
+
241
+ # ------------------------------------------------------------------ #
242
+ # Panel instructions
243
+ # ------------------------------------------------------------------ #
244
+
245
+ def _instructions(self):
246
+ wm = self._wm
247
+ return ("WiFi setup: join \"%s\" (password: %s) then open http://%s"
248
+ % (wm.AP_SSID, wm.AP_PASSWORD, wm.ap_ip_address()))
249
+
250
+ def _make_status_content(self):
251
+ """A ScrollingText with the join instructions, or None if the display
252
+ stack isn't usable (the portal must never die because of the panel)."""
253
+ if self._display is None:
254
+ return None
255
+ try:
256
+ from ..display.content import ScrollingText
257
+ return ScrollingText(self._instructions(), y=20, color=0x00A0FF)
258
+ except Exception as e:
259
+ print("wifi portal: status content unavailable:", e)
260
+ return None
261
+
262
+ # ------------------------------------------------------------------ #
263
+ # The blocking onboarding loop
264
+ # ------------------------------------------------------------------ #
265
+
266
+ async def run(self, timeout_s=None):
267
+ """Scan, start the AP, and serve the portal until credentials are
268
+ saved (returns True), the timeout passes, or the simulator window is
269
+ closed (returns False). Stops the server and the AP either way."""
270
+ wm = self._wm
271
+
272
+ # Scan BEFORE AP mode: some radio builds can't scan while running an
273
+ # access point, and the list only needs to be fresh-ish.
274
+ try:
275
+ self._networks = wm.scan_networks() or []
276
+ except Exception as e:
277
+ print("wifi portal: scan failed:", e)
278
+ self._networks = []
279
+
280
+ wm.start_access_point()
281
+ try:
282
+ self._build_server()
283
+ self._server.start(getattr(self, "_host", "localhost"), self._port)
284
+ print("WiFi setup portal on %s (AP %r)"
285
+ % (self._server_url, wm.AP_SSID))
286
+
287
+ status = self._make_status_content()
288
+ if status is not None:
289
+ try:
290
+ await status.start()
291
+ except Exception:
292
+ status = None
293
+
294
+ start = time.monotonic()
295
+ linger_until = None
296
+ while True:
297
+ try:
298
+ self._server.poll()
299
+ except OSError as e:
300
+ print("wifi portal poll error:", e)
301
+
302
+ # Serve a little longer after the save so the confirmation
303
+ # page reaches the phone, then exit.
304
+ if self.saved and linger_until is None:
305
+ linger_until = time.monotonic() + _LINGER_S
306
+ if linger_until is not None and time.monotonic() >= linger_until:
307
+ break
308
+ if (timeout_s is not None
309
+ and time.monotonic() - start >= timeout_s):
310
+ break
311
+
312
+ if status is not None:
313
+ try:
314
+ await self._display.clear()
315
+ await status.render(self._display)
316
+ if await self._display.show() is False:
317
+ break # simulator window closed
318
+ except Exception as e:
319
+ print("wifi portal display error:", e)
320
+ status = None
321
+
322
+ await asyncio.sleep(_POLL_INTERVAL)
323
+ finally:
324
+ try:
325
+ if self._server is not None:
326
+ self._server.stop()
327
+ except Exception:
328
+ pass
329
+ wm.stop_access_point()
330
+
331
+ return self.saved
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: scrollkit
3
+ Version: 0.8.3
4
+ Summary: LED Matrix Display Framework for CircuitPython and Desktop
5
+ Author-email: Michael Winslow Czeiszperger <michael@czei.org>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/czei/scrollkit
8
+ Project-URL: Repository, https://github.com/czei/scrollkit
9
+ Keywords: circuitpython,led-matrix,display,matrixportal,simulator
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: System :: Hardware
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: simulator
26
+ Requires-Dist: pygame>=2.0; extra == "simulator"
27
+ Requires-Dist: Pillow>=9.0; extra == "simulator"
28
+ Requires-Dist: numpy>=1.21; extra == "simulator"
29
+ Provides-Extra: web
30
+ Requires-Dist: aiohttp>=3.8; extra == "web"
31
+ Provides-Extra: dev
32
+ Requires-Dist: scrollkit[simulator]; extra == "dev"
33
+ Requires-Dist: pytest>=7.0; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
36
+ Requires-Dist: pytest-timeout>=2.1; extra == "dev"
37
+ Requires-Dist: ruff>=0.1; extra == "dev"
38
+ Requires-Dist: requests>=2.28; extra == "dev"
39
+ Provides-Extra: docs
40
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
41
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
42
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
43
+ Dynamic: license-file
44
+
45
+ # ScrollKit
46
+
47
+ Most LED-matrix libraries get you a scrolling "Hello, World" and stop. I built ScrollKit for what comes next: over-the-air updates to boards in the field, fault-tolerant data refresh, real transitions and effects, and a built-in web server users control from a browser. The hard part isn't any single feature. It's running all of them at once on a microcontroller without the display stuttering. It also runs on a desktop simulator I wrote that exports its own GIFs and videos, like the one below.
48
+
49
+ *Built by [Michael Czeiszperger](http://czei.org)*
50
+
51
+ 📖 **Full documentation: [scrollkit.dev](https://scrollkit.dev)**
52
+
53
+ <p align="center">
54
+ <!-- Absolute URL so the image also renders on the PyPI project page -->
55
+ <img src="https://raw.githubusercontent.com/czei/scrollkit/master/docs/assets/video/scrollkit-hero.gif" alt="ScrollKit hero: a swarm assembles the ScrollKit logo, sheen sweeps over it, then it colorizes to electric-blue/magenta/gold, all rendered on a 64×32 LED panel" width="640">
56
+ </p>
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ # Desktop development with simulator
62
+ pip install "scrollkit[simulator]"
63
+
64
+ # To modify ScrollKit itself (or run the demos): clone and install editable
65
+ git clone https://github.com/czei/scrollkit.git
66
+ cd scrollkit && pip install -e ".[simulator]"
67
+
68
+ # CircuitPython — copy scrollkit/ to your device's lib/ alongside your source
69
+ ```
70
+
71
+ ## Quick Start
72
+
73
+ ```python
74
+ import asyncio
75
+ from scrollkit.app.base import ScrollKitApp
76
+ from scrollkit.display.content import ScrollingText
77
+
78
+ class HelloWorldApp(ScrollKitApp):
79
+ async def setup(self):
80
+ self.content_queue.add(
81
+ ScrollingText("Hello, LED Matrix!", y=12, color=0x00AAFF))
82
+
83
+ asyncio.run(HelloWorldApp().run()) # auto-detects MatrixPortal hardware vs desktop simulator
84
+ ```
85
+
86
+ > The top-level `scrollkit` package deliberately performs **no** imports (every
87
+ > import costs RAM on CircuitPython), so you always import from submodules, e.g.
88
+ > `from scrollkit.app.base import ScrollKitApp`. See the
89
+ > [getting-started guide](https://scrollkit.dev/getting-started/)
90
+ > for the full `ScrollKitApp` / `UnifiedDisplay` API.
91
+
92
+ ## Architecture
93
+
94
+ ScrollKit runs unchanged on the MatrixPortal S3 (CircuitPython) and a desktop
95
+ pygame simulator. Your app subclasses `ScrollKitApp` and talks to one display
96
+ abstraction; the library picks a backend at import time and brokers every external
97
+ system the sign touches:
98
+
99
+ ```mermaid
100
+ flowchart TB
101
+ app["Your app<br/>(subclasses ScrollKitApp)"] --> core["ScrollKitApp · UnifiedDisplay<br/>ContentQueue · effects · config"]
102
+ core -->|CircuitPython| hw["MatrixPortal S3<br/>displayio → RGBMatrix panel"]
103
+ core -->|desktop| sim["pygame simulator"]
104
+ core <-->|HttpClient — synchronous| api(["HTTP data API"])
105
+ core <-->|SettingsWebServer| browser(["Browser config UI"])
106
+ core -->|raw.githubusercontent.com| gh(["GitHub OTA"])
107
+ ```
108
+
109
+ Subsystem dependencies (dashed = lazy import; `dev` and `simulator` are
110
+ desktop-only, raising `ImportError` on the device):
111
+
112
+ ```mermaid
113
+ flowchart LR
114
+ app["app"] --> display["display"]
115
+ app --> config["config"]
116
+ app -.->|lazy| utils["utils"]
117
+ app -.->|lazy| effects["effects"]
118
+ app -.->|lazy| web["web"]
119
+ effects --> display
120
+ display -.->|desktop| simulator["simulator"]
121
+ config -.->|lazy| utils
122
+ network["network"] --> config
123
+ network --> utils
124
+ ota["ota"] --> exceptions["exceptions"]
125
+ dev["dev"] --> display
126
+ dev --> effects
127
+ dev --> simulator
128
+ classDef desktop stroke-dasharray:6 4;
129
+ class dev,simulator desktop;
130
+ ```
131
+
132
+ See the [Architecture guide](docs/guide/architecture.md) for the full write-up,
133
+ including the invariants this graph enforces.
134
+
135
+ ## Package Structure
136
+
137
+ ```
138
+ scrollkit/
139
+ ├── app/ # ScrollKitApp base class, async run loop, memory helpers
140
+ ├── display/ # UnifiedDisplay (auto-detects hardware vs simulator), content
141
+ │ ├── unified.py # Production display (device + desktop)
142
+ │ ├── content.py # DisplayContent / StaticText / ScrollingText / ContentQueue / Priority
143
+ │ ├── bitmap_text.py # Animated bitmap-font text + palette effects
144
+ │ ├── gradient_text.py # Gradient/multi-color text fill (GradientTextLayer)
145
+ │ └── colors.py # Continuous 24-bit color generators
146
+ ├── effects/ # Transition contract (transitions.py) + standalone splash/particle helpers
147
+ ├── network/ # Networking utilities
148
+ │ ├── http_client.py # Dual-implementation HTTP client (raises NetworkError)
149
+ │ ├── wifi_manager.py # WiFi connection lifecycle
150
+ │ └── mdns.py # <hostname>.local advertising (CircuitPython; no-op on desktop)
151
+ ├── config/ # Configuration management
152
+ │ └── settings_manager.py # JSON-based persistent settings
153
+ ├── ota/ # Over-the-air updates
154
+ │ ├── client.py # GitHub-release-based OTA client
155
+ │ ├── manifest.py # Update manifest model
156
+ │ ├── display_progress.py # Display-progress adapter over OTAClient
157
+ │ └── publish.py # Host-side release publishing (desktop/CI only)
158
+ └── utils/ # Utilities
159
+ ├── error_handler.py # Logging and error handling
160
+ ├── diagnostics.py # NVM boot/crash record + reboot-loop safe-mode breaker
161
+ ├── color_utils.py # Named colors + settings-UI hex-string color table (no conversion helpers; int-based conversions live in display/colors.py)
162
+ ├── system_utils.py # NTP / HTTP-Date system clock sync
163
+ └── url_utils.py # URL decoding and credential loading
164
+ ```
165
+
166
+ ## Core API
167
+
168
+ ### Display
169
+
170
+ ```python
171
+ from scrollkit.display.unified import UnifiedDisplay
172
+ from scrollkit.display.content import ContentQueue, ScrollingText
173
+
174
+ # Create display (auto-detects CircuitPython vs desktop)
175
+ display = UnifiedDisplay(width=64, height=32)
176
+ display.initialize()
177
+
178
+ # ScrollKitApp drives this queue's render loop for you (see Quick Start above);
179
+ # add() is all a subclass's setup() typically needs to call.
180
+ queue = ContentQueue()
181
+ queue.add(ScrollingText("Scrolling text", y=12, color=0x00AAFF))
182
+ ```
183
+
184
+ ### HTTP Client
185
+
186
+ ```python
187
+ from scrollkit.network.http_client import HttpClient
188
+ from scrollkit.exceptions import NetworkError
189
+
190
+ client = HttpClient()
191
+ try:
192
+ response = await client.get("https://api.example.com/data")
193
+ data = response.json()
194
+ except NetworkError as e:
195
+ print("fetch failed:", e)
196
+ ```
197
+
198
+ ### Settings
199
+
200
+ ```python
201
+ from scrollkit.config.settings_manager import SettingsManager
202
+
203
+ settings = SettingsManager("app_settings.json",
204
+ defaults={"hostname": "mydevice", "brightness": "0.5"},
205
+ bool_keys=["dark_mode"])
206
+ settings.set("hostname", "new-name")
207
+ settings.save_settings()
208
+ ```
209
+
210
+ ### Utilities
211
+
212
+ ```python
213
+ from scrollkit.utils.error_handler import ErrorHandler
214
+ from scrollkit.display.colors import scale
215
+ from scrollkit.network.wifi_manager import is_dev_mode
216
+
217
+ logger = ErrorHandler("app.log")
218
+ logger.info("Application started")
219
+
220
+ color = scale(0xff0000, 0.5) # Dim red to 50%
221
+
222
+ if is_dev_mode():
223
+ print("running on desktop, not CircuitPython")
224
+ ```
225
+
226
+ ## Platform Support
227
+
228
+ | Platform | Backend | Status |
229
+ |---|---|---|
230
+ | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
231
+ | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
232
+ | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
233
+ | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
234
+
235
+ ## How this was built
236
+
237
+ I wrote the first two shipping versions by hand in 2024, when all of this was
238
+ still one application. Splitting it into a library and a separate app layer, then
239
+ documenting the result, is the kind of project that dies quietly in a spare-time
240
+ backlog. So I used Claude Code and spec-driven development to handle the
241
+ refactoring and the first drafts, then went back through all of it in my own
242
+ voice, with my own screenshots. Yes, AI has touched a lot of this code. It was
243
+ also directed by an engineer who has shipped production software for a living,
244
+ including time on one of Sun Microsystems' API teams. Both are true.
245
+
246
+ ## License
247
+
248
+ MIT