scrollkit 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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("&", "&").replace("<", "<")
|
|
51
|
+
.replace(">", ">").replace('"', """))
|
|
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
|