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,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System utilities for hardware and system operations.
|
|
3
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
4
|
+
|
|
5
|
+
Time-setting is deliberately resilient rather than precise: for these displays
|
|
6
|
+
"close enough" is fine, but the clock must get set even on hostile networks.
|
|
7
|
+
``set_system_clock`` cascades through independent sources:
|
|
8
|
+
|
|
9
|
+
1. NTP across several reliable servers (the gold standard) — but a single shot
|
|
10
|
+
at pool.ntp.org is unreliable (random dead/slow pool members) and NTP needs
|
|
11
|
+
outbound UDP/123, which guest wifi / captive portals / some ISPs block.
|
|
12
|
+
2. The HTTP ``Date`` response header from a reachable host — works wherever
|
|
13
|
+
HTTPS works, i.e. exactly the networks that block NTP. Accurate to ~1s.
|
|
14
|
+
"""
|
|
15
|
+
import sys # noqa: F401 (kept for parity with the embedded build)
|
|
16
|
+
import time
|
|
17
|
+
import asyncio # noqa: F401
|
|
18
|
+
|
|
19
|
+
# Import hardware-specific modules or mock them
|
|
20
|
+
try:
|
|
21
|
+
import rtc
|
|
22
|
+
import microcontroller # noqa: F401
|
|
23
|
+
HAS_HARDWARE = True
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
# Mocking the unavailable modules in non-embedded environments
|
|
26
|
+
from adafruit_datetime import datetime
|
|
27
|
+
|
|
28
|
+
class rtc:
|
|
29
|
+
class RTC:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.datetime = datetime()
|
|
32
|
+
|
|
33
|
+
HAS_HARDWARE = False
|
|
34
|
+
|
|
35
|
+
# Try to import adafruit_ntp, with fallback if not available
|
|
36
|
+
try:
|
|
37
|
+
import adafruit_ntp
|
|
38
|
+
HAS_NTP = True
|
|
39
|
+
except Exception:
|
|
40
|
+
HAS_NTP = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# NTP servers tried in order. A single query to pool.ntp.org is unreliable in the
|
|
44
|
+
# field: it resolves to a RANDOM pool member, many of which are dead, slow
|
|
45
|
+
# (10-20s), or rate-limiting ("aggressive denial"). So we lead with single-operator
|
|
46
|
+
# anycast servers that don't have that problem and fall through to the pool last.
|
|
47
|
+
# Override via the `servers` argument.
|
|
48
|
+
DEFAULT_NTP_SERVERS = ("time.cloudflare.com", "time.google.com", "pool.ntp.org")
|
|
49
|
+
|
|
50
|
+
# HTTPS hosts to read the 'Date' response header from when NTP is unreachable.
|
|
51
|
+
# Any HTTPS host returns a Date header; these are highly available. If your app
|
|
52
|
+
# already fetches from a reliable host, pass it via `http_date_hosts` to avoid an
|
|
53
|
+
# extra dependency.
|
|
54
|
+
DEFAULT_HTTP_DATE_HOSTS = ("https://time.cloudflare.com", "https://www.google.com")
|
|
55
|
+
|
|
56
|
+
_MONTHS = {"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
|
|
57
|
+
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ['set_system_clock', 'set_system_clock_ntp', 'DEFAULT_NTP_SERVERS', 'DEFAULT_HTTP_DATE_HOSTS']
|
|
61
|
+
|
|
62
|
+
async def set_system_clock_ntp(socket_pool, tz_offset=None, servers=None,
|
|
63
|
+
socket_timeout=5):
|
|
64
|
+
"""
|
|
65
|
+
Set device time using NTP, trying several servers until one responds.
|
|
66
|
+
|
|
67
|
+
NTP is the right technique for these devices, but a single shot at
|
|
68
|
+
pool.ntp.org fails often in the field (random dead/slow pool members). This
|
|
69
|
+
tries a list of independent, reliable servers and uses the first that answers,
|
|
70
|
+
with a short per-server timeout so a dead server fails fast.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
socket_pool: SocketPool instance for NTP (must have getaddrinfo).
|
|
74
|
+
tz_offset: Timezone offset in hours (defaults to US Eastern, -5).
|
|
75
|
+
servers: Iterable of NTP server hostnames to try in order
|
|
76
|
+
(defaults to DEFAULT_NTP_SERVERS).
|
|
77
|
+
socket_timeout: Per-server timeout in seconds (default 5).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if any server set the clock, False if every server failed.
|
|
81
|
+
"""
|
|
82
|
+
from scrollkit.utils.error_handler import ErrorHandler
|
|
83
|
+
logger = ErrorHandler("error_log")
|
|
84
|
+
|
|
85
|
+
if not HAS_NTP or not HAS_HARDWARE:
|
|
86
|
+
logger.info("NTP module not available or hardware not supported")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Validate socket_pool is a proper socket pool object
|
|
90
|
+
if socket_pool is None or not hasattr(socket_pool, 'getaddrinfo'):
|
|
91
|
+
logger.error(None, "Invalid socket pool provided for NTP, socket pool must have getaddrinfo")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
# Timezone offset: default to EST (-5 hours)
|
|
95
|
+
if tz_offset is None:
|
|
96
|
+
tz_offset = -5
|
|
97
|
+
|
|
98
|
+
for server in (servers if servers is not None else DEFAULT_NTP_SERVERS):
|
|
99
|
+
try:
|
|
100
|
+
logger.info(f"Getting time from NTP server {server} (tz_offset {tz_offset})")
|
|
101
|
+
ntp = adafruit_ntp.NTP(socket_pool, server=server, tz_offset=tz_offset,
|
|
102
|
+
socket_timeout=socket_timeout)
|
|
103
|
+
current_time = ntp.datetime
|
|
104
|
+
|
|
105
|
+
# Convert to a tuple for the RTC module
|
|
106
|
+
datetime_tuple = (
|
|
107
|
+
current_time.tm_year, # Year
|
|
108
|
+
current_time.tm_mon, # Month
|
|
109
|
+
current_time.tm_mday, # Day
|
|
110
|
+
current_time.tm_hour, # Hour
|
|
111
|
+
current_time.tm_min, # Minute
|
|
112
|
+
current_time.tm_sec, # Second
|
|
113
|
+
current_time.tm_wday, # Day of week (0-6)
|
|
114
|
+
-1, # Day of year (not necessary)
|
|
115
|
+
-1 # DST flag (not necessary)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
rtc.RTC().datetime = datetime_tuple
|
|
119
|
+
logger.info(f"System clock set to {datetime_tuple} via NTP ({server})")
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(e, f"NTP server {server} failed, trying next")
|
|
124
|
+
# Reclaim the failed NTP object (and any socket/SSL allocations it
|
|
125
|
+
# held) before trying the next server. On the RAM-constrained ESP32-S3
|
|
126
|
+
# this curbs heap fragmentation across the failover loop — cheap
|
|
127
|
+
# insurance on a once-at-boot path. No-op cost on desktop.
|
|
128
|
+
import gc
|
|
129
|
+
gc.collect()
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
logger.error(None, "All NTP servers failed to respond")
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_http_date(date_header):
|
|
137
|
+
"""Parse an RFC 1123 HTTP 'Date' header (always UTC) into a UTC time tuple.
|
|
138
|
+
|
|
139
|
+
e.g. 'Wed, 21 Oct 2025 07:28:00 GMT' -> (2025, 10, 21, 7, 28, 0).
|
|
140
|
+
Returns None if it can't be parsed.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
parts = date_header.split(" ")
|
|
144
|
+
# ['Wed,', '21', 'Oct', '2025', '07:28:00', 'GMT']
|
|
145
|
+
day = int(parts[1])
|
|
146
|
+
month = _MONTHS[parts[2]]
|
|
147
|
+
year = int(parts[3])
|
|
148
|
+
hour, minute, second = (int(x) for x in parts[4].split(":"))
|
|
149
|
+
return (year, month, day, hour, minute, second)
|
|
150
|
+
except (ValueError, KeyError, IndexError):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _utc_to_local_tuple(utc, tz_offset):
|
|
155
|
+
"""Shift a (Y, M, D, h, m, s) UTC tuple by tz_offset hours into an RTC tuple.
|
|
156
|
+
|
|
157
|
+
Uses time.mktime/localtime, which on CircuitPython operate in UTC (no timezone
|
|
158
|
+
database), so this yields the correct local wall-clock for the given offset and
|
|
159
|
+
normalizes any day/month rollover. Returns None on overflow/parse error.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
year, month, day, hour, minute, second = utc
|
|
163
|
+
epoch = time.mktime((year, month, day, hour, minute, second, 0, -1, -1))
|
|
164
|
+
lt = time.localtime(epoch + int(tz_offset * 3600))
|
|
165
|
+
return (lt.tm_year, lt.tm_mon, lt.tm_mday, lt.tm_hour,
|
|
166
|
+
lt.tm_min, lt.tm_sec, lt.tm_wday, -1, -1)
|
|
167
|
+
except (OverflowError, OSError, ValueError):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _set_clock_from_http_date(http_client, tz_offset, hosts, logger):
|
|
172
|
+
"""Set the RTC from the HTTP 'Date' response header of a reachable host.
|
|
173
|
+
|
|
174
|
+
Works on networks that block NTP (UDP/123) but allow HTTPS. ~1s accurate.
|
|
175
|
+
Returns the datetime tuple that was set, or None if no host answered.
|
|
176
|
+
"""
|
|
177
|
+
for url in hosts:
|
|
178
|
+
try:
|
|
179
|
+
resp = await http_client.get(url)
|
|
180
|
+
headers = getattr(resp, "headers", None) or {}
|
|
181
|
+
date_hdr = headers.get("Date") or headers.get("date")
|
|
182
|
+
if not date_hdr:
|
|
183
|
+
logger.info(f"No Date header from {url}")
|
|
184
|
+
continue
|
|
185
|
+
utc = _parse_http_date(date_hdr)
|
|
186
|
+
if utc is None:
|
|
187
|
+
logger.error(None, f"Could not parse Date header from {url}: {date_hdr}")
|
|
188
|
+
continue
|
|
189
|
+
dt = _utc_to_local_tuple(utc, tz_offset)
|
|
190
|
+
if dt is None:
|
|
191
|
+
continue
|
|
192
|
+
if HAS_HARDWARE:
|
|
193
|
+
rtc.RTC().datetime = dt
|
|
194
|
+
logger.info(f"System clock set to {dt} via HTTP Date header ({url})")
|
|
195
|
+
return dt
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(e, f"HTTP time from {url} failed")
|
|
198
|
+
continue
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def set_system_clock(http_client, socket_pool=None, tz_offset=None,
|
|
203
|
+
http_date_hosts=None):
|
|
204
|
+
"""
|
|
205
|
+
Set the device clock as reliably as possible (accuracy is not critical).
|
|
206
|
+
|
|
207
|
+
Cascades through independent sources, first that answers wins:
|
|
208
|
+
1. NTP across several reliable servers (needs UDP/123 open).
|
|
209
|
+
2. The HTTP 'Date' response header (works where NTP is blocked).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
http_client: HTTP client used for the Date-header fallback.
|
|
213
|
+
socket_pool: Optional socket pool for NTP.
|
|
214
|
+
tz_offset: Timezone offset in hours (defaults to US Eastern, -5).
|
|
215
|
+
http_date_hosts: Optional list of HTTPS hosts for the Date-header
|
|
216
|
+
fallback (defaults to DEFAULT_HTTP_DATE_HOSTS). Prefer a host you
|
|
217
|
+
already fetch from.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The datetime tuple that was set, or None if every source failed.
|
|
221
|
+
"""
|
|
222
|
+
from scrollkit.utils.error_handler import ErrorHandler
|
|
223
|
+
logger = ErrorHandler("error_log")
|
|
224
|
+
|
|
225
|
+
if tz_offset is None:
|
|
226
|
+
tz_offset = -5
|
|
227
|
+
|
|
228
|
+
# 1. NTP (multi-server failover) — accurate, but needs UDP/123 open.
|
|
229
|
+
if HAS_NTP and socket_pool is not None:
|
|
230
|
+
logger.info("Setting time via NTP...")
|
|
231
|
+
try:
|
|
232
|
+
if await set_system_clock_ntp(socket_pool, tz_offset=tz_offset):
|
|
233
|
+
logger.info("System clock set via NTP")
|
|
234
|
+
return rtc.RTC().datetime
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(e, "NTP failed; falling back to the HTTP Date header")
|
|
237
|
+
|
|
238
|
+
# 2. HTTP 'Date' header — survives networks that block NTP.
|
|
239
|
+
logger.info("Setting time via the HTTP Date header...")
|
|
240
|
+
hosts = http_date_hosts if http_date_hosts is not None else DEFAULT_HTTP_DATE_HOSTS
|
|
241
|
+
dt = await _set_clock_from_http_date(http_client, tz_offset, hosts, logger)
|
|
242
|
+
if dt is None:
|
|
243
|
+
logger.error(None, "Failed to set system clock - no time source reachable "
|
|
244
|
+
"(NTP and HTTP Date both failed)")
|
|
245
|
+
return dt
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL utilities for handling URL encoding and decoding.
|
|
3
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ['url_decode', 'load_credentials']
|
|
8
|
+
|
|
9
|
+
def url_decode(input_string):
|
|
10
|
+
"""
|
|
11
|
+
Decode URL-encoded strings
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
input_string: The URL-encoded string to decode
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The decoded string
|
|
18
|
+
"""
|
|
19
|
+
input_string = input_string.replace('+', ' ')
|
|
20
|
+
hex_chars = "0123456789abcdef"
|
|
21
|
+
result = ""
|
|
22
|
+
i = 0
|
|
23
|
+
while i < len(input_string):
|
|
24
|
+
if input_string[i] == "%" and i < len(input_string) - 2:
|
|
25
|
+
hex_value = input_string[i + 1:i + 3].lower()
|
|
26
|
+
if all(c in hex_chars for c in hex_value):
|
|
27
|
+
result += chr(int(hex_value, 16))
|
|
28
|
+
i += 3
|
|
29
|
+
continue
|
|
30
|
+
result += input_string[i]
|
|
31
|
+
i += 1
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_credentials():
|
|
36
|
+
"""
|
|
37
|
+
Load WiFi credentials from secrets.py
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A tuple of (ssid, password)
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
from secrets import secrets
|
|
44
|
+
return secrets['ssid'], secrets['password']
|
|
45
|
+
except ImportError:
|
|
46
|
+
return "", ""
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Default settings web UI for ScrollKit apps — native ``adafruit_httpserver``.
|
|
2
|
+
|
|
3
|
+
``SettingsWebServer`` reads ``SettingsManager._schema`` (populated by
|
|
4
|
+
``SettingsManager.define()``) and auto-generates an HTML settings form.
|
|
5
|
+
No app-level web code is required: just call ``define()`` on the settings
|
|
6
|
+
object and the base class wires the server up automatically.
|
|
7
|
+
|
|
8
|
+
The same server object runs on desktop CPython (stdlib ``socket`` as the
|
|
9
|
+
pool) and on the MatrixPortal S3 (``socketpool.SocketPool(wifi.radio)``).
|
|
10
|
+
``adafruit_httpserver`` is lazy-imported inside ``_build_server()`` so the
|
|
11
|
+
module is safe to import on memory-constrained CircuitPython before the
|
|
12
|
+
memory gate in ``_web_server_process()`` clears.
|
|
13
|
+
|
|
14
|
+
Routes:
|
|
15
|
+
GET / — render the settings form
|
|
16
|
+
POST /save — persist settings, call ``app.notify_settings_changed()``
|
|
17
|
+
(a flag the display loop applies at its next frame — this
|
|
18
|
+
server never mutates display/queue state itself), redirect
|
|
19
|
+
303 to /
|
|
20
|
+
|
|
21
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
_SEE_OTHER = (303, "See Other")
|
|
26
|
+
_POLL_INTERVAL = 0.05 # seconds between poll() calls (~20×/s, matches display loop)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ['SettingsWebServer']
|
|
30
|
+
|
|
31
|
+
# adafruit_httpserver does not URL-decode form values. The shared
|
|
32
|
+
# CircuitPython-safe decoder lives in utils — one implementation, not two.
|
|
33
|
+
from ..utils.url_utils import url_decode as _url_decode
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
# HTML helpers
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
|
|
40
|
+
def _esc(text):
|
|
41
|
+
return (str(text).replace("&", "&").replace("<", "<")
|
|
42
|
+
.replace(">", ">").replace('"', """))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_PAGE_HEADER = """<!DOCTYPE html>
|
|
46
|
+
<html><head><meta charset="UTF-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
48
|
+
<title>Settings</title>
|
|
49
|
+
<style>
|
|
50
|
+
body{font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 16px;background:#f5f5f5}
|
|
51
|
+
h1{font-size:1.2em;margin-bottom:24px;color:#222}
|
|
52
|
+
.field{margin-bottom:16px}
|
|
53
|
+
label{display:block;font-size:.85em;color:#555;margin-bottom:4px}
|
|
54
|
+
input[type=text],input[type=number],select{
|
|
55
|
+
width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;
|
|
56
|
+
box-sizing:border-box;font-size:1em;background:#fff}
|
|
57
|
+
input[type=range]{width:100%;vertical-align:middle}
|
|
58
|
+
.range-row{display:flex;gap:8px;align-items:center}
|
|
59
|
+
.range-val{min-width:2.5em;text-align:right;color:#333;font-size:.9em}
|
|
60
|
+
input[type=color]{width:48px;height:32px;border:1px solid #ccc;
|
|
61
|
+
border-radius:4px;padding:2px;cursor:pointer}
|
|
62
|
+
.checkbox-row{display:flex;align-items:center;gap:8px;cursor:pointer}
|
|
63
|
+
.checkbox-row input{width:16px;height:16px}
|
|
64
|
+
button{background:#333;color:#fff;border:none;padding:10px 28px;
|
|
65
|
+
border-radius:4px;font-size:1em;cursor:pointer;margin-top:8px}
|
|
66
|
+
button:hover{background:#555}
|
|
67
|
+
</style></head>
|
|
68
|
+
<body><h1>Settings</h1>
|
|
69
|
+
<form method="POST" action="/save">
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_PAGE_FOOTER = """<button type="submit">Save</button>
|
|
73
|
+
</form></body></html>
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _color_to_html(value):
|
|
78
|
+
"""Convert stored color value (int or hex string) to #rrggbb for HTML."""
|
|
79
|
+
if isinstance(value, int):
|
|
80
|
+
return "#{:06x}".format(value & 0xFFFFFF)
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
if value.startswith("#"):
|
|
83
|
+
return value
|
|
84
|
+
if value.startswith("0x") or value.startswith("0X"):
|
|
85
|
+
return "#" + value[2:].lower()
|
|
86
|
+
return "#ffffff"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
# SettingsWebServer
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
|
|
93
|
+
class SettingsWebServer:
|
|
94
|
+
"""Auto-generating settings web server driven by ``SettingsManager._schema``.
|
|
95
|
+
|
|
96
|
+
Implements the ``ScrollKitApp`` web contract:
|
|
97
|
+
``await start()`` → bool, ``get_server_url()`` → str,
|
|
98
|
+
``await run_forever()``, ``await stop()``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, settings_manager, app=None, host="0.0.0.0", port=80):
|
|
102
|
+
self._sm = settings_manager
|
|
103
|
+
self._app = app
|
|
104
|
+
self._host = host
|
|
105
|
+
self._port = port
|
|
106
|
+
self._server = None
|
|
107
|
+
self._running = False
|
|
108
|
+
self._server_url = ""
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ #
|
|
111
|
+
# Internal: build server + routes (lazy adafruit_httpserver import)
|
|
112
|
+
# ------------------------------------------------------------------ #
|
|
113
|
+
|
|
114
|
+
def _build_server(self):
|
|
115
|
+
import sys
|
|
116
|
+
import socket as _stdlib_socket
|
|
117
|
+
from adafruit_httpserver import Server, Response, Redirect, GET, POST
|
|
118
|
+
|
|
119
|
+
is_cp = (hasattr(sys, "implementation")
|
|
120
|
+
and sys.implementation.name == "circuitpython")
|
|
121
|
+
|
|
122
|
+
if is_cp:
|
|
123
|
+
import socketpool
|
|
124
|
+
import wifi
|
|
125
|
+
pool = socketpool.SocketPool(wifi.radio)
|
|
126
|
+
display_host = str(wifi.radio.ipv4_address)
|
|
127
|
+
else:
|
|
128
|
+
pool = _stdlib_socket
|
|
129
|
+
display_host = self._host if self._host != "0.0.0.0" else "localhost"
|
|
130
|
+
|
|
131
|
+
self._server = Server(pool, root_path=None, debug=False)
|
|
132
|
+
self._server_url = "http://{}:{}/".format(display_host, self._port)
|
|
133
|
+
|
|
134
|
+
# Capture references for route closures.
|
|
135
|
+
_self = self
|
|
136
|
+
sm = self._sm
|
|
137
|
+
app = self._app
|
|
138
|
+
|
|
139
|
+
@self._server.route("/", [GET])
|
|
140
|
+
def _index(request):
|
|
141
|
+
return Response(request, _self._render_form(), content_type="text/html")
|
|
142
|
+
|
|
143
|
+
@self._server.route("/save", [POST])
|
|
144
|
+
def _save(request):
|
|
145
|
+
_self._apply(request, sm, app)
|
|
146
|
+
return Redirect(request, "/", status=_SEE_OTHER)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
# Internal: form rendering
|
|
150
|
+
# ------------------------------------------------------------------ #
|
|
151
|
+
|
|
152
|
+
def _render_form(self):
|
|
153
|
+
sm = self._sm
|
|
154
|
+
parts = [_PAGE_HEADER]
|
|
155
|
+
|
|
156
|
+
for field in sm._schema:
|
|
157
|
+
key = field["key"]
|
|
158
|
+
ftype = field["type"]
|
|
159
|
+
label = _esc(field["label"])
|
|
160
|
+
value = sm.get(key, field["default"])
|
|
161
|
+
|
|
162
|
+
if ftype == "checkbox":
|
|
163
|
+
checked = " checked" if value else ""
|
|
164
|
+
parts.append(
|
|
165
|
+
'<div class="field"><label class="checkbox-row">'
|
|
166
|
+
'<input type="checkbox" name="%s" value="1"%s>'
|
|
167
|
+
' %s</label></div>' % (key, checked, label)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
elif ftype == "color":
|
|
171
|
+
html_val = _color_to_html(value)
|
|
172
|
+
parts.append(
|
|
173
|
+
'<div class="field"><label>%s</label>'
|
|
174
|
+
'<input type="color" name="%s" value="%s"></div>'
|
|
175
|
+
% (label, key, html_val)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
elif ftype == "range":
|
|
179
|
+
val_str = str(value)
|
|
180
|
+
mn = "" if field["min"] is None else str(field["min"])
|
|
181
|
+
mx = "" if field["max"] is None else str(field["max"])
|
|
182
|
+
st = "1" if field["step"] is None else str(field["step"])
|
|
183
|
+
parts.append(
|
|
184
|
+
'<div class="field"><label>%s</label>'
|
|
185
|
+
'<div class="range-row">'
|
|
186
|
+
'<input type="range" name="%s" min="%s" max="%s" step="%s"'
|
|
187
|
+
' value="%s" oninput="this.nextElementSibling.textContent=this.value">'
|
|
188
|
+
'<span class="range-val">%s</span>'
|
|
189
|
+
'</div></div>'
|
|
190
|
+
% (label, key, mn, mx, st, val_str, val_str)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
elif ftype == "select":
|
|
194
|
+
opts = []
|
|
195
|
+
for o in (field["options"] or []):
|
|
196
|
+
sel = " selected" if o == value else ""
|
|
197
|
+
opts.append('<option%s>%s</option>' % (sel, _esc(o)))
|
|
198
|
+
parts.append(
|
|
199
|
+
'<div class="field"><label>%s</label>'
|
|
200
|
+
'<select name="%s">%s</select></div>'
|
|
201
|
+
% (label, key, "".join(opts))
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
elif ftype == "number":
|
|
205
|
+
mn = ("" if field["min"] is None
|
|
206
|
+
else ' min="%s"' % field["min"])
|
|
207
|
+
mx = ("" if field["max"] is None
|
|
208
|
+
else ' max="%s"' % field["max"])
|
|
209
|
+
st = ("" if field["step"] is None
|
|
210
|
+
else ' step="%s"' % field["step"])
|
|
211
|
+
parts.append(
|
|
212
|
+
'<div class="field"><label>%s</label>'
|
|
213
|
+
'<input type="number" name="%s"%s%s%s value="%s"></div>'
|
|
214
|
+
% (label, key, mn, mx, st, str(value))
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
else: # text
|
|
218
|
+
parts.append(
|
|
219
|
+
'<div class="field"><label>%s</label>'
|
|
220
|
+
'<input type="text" name="%s" value="%s"></div>'
|
|
221
|
+
% (label, key, _esc(str(value)))
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
parts.append(_PAGE_FOOTER)
|
|
225
|
+
return "".join(parts)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ #
|
|
228
|
+
# Internal: apply POSTed form to settings
|
|
229
|
+
# ------------------------------------------------------------------ #
|
|
230
|
+
|
|
231
|
+
def _apply(self, request, sm, app):
|
|
232
|
+
fd = getattr(request, "form_data", None)
|
|
233
|
+
|
|
234
|
+
# Collect present keys so absent checkboxes can be detected.
|
|
235
|
+
form_keys = set()
|
|
236
|
+
if fd is not None:
|
|
237
|
+
try:
|
|
238
|
+
for k in fd.keys():
|
|
239
|
+
form_keys.add(k)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def _get(key):
|
|
244
|
+
if fd is None:
|
|
245
|
+
return None
|
|
246
|
+
try:
|
|
247
|
+
v = fd.get(key)
|
|
248
|
+
if isinstance(v, bytes):
|
|
249
|
+
v = v.decode("utf-8", "replace")
|
|
250
|
+
if isinstance(v, str):
|
|
251
|
+
v = _url_decode(v)
|
|
252
|
+
return v
|
|
253
|
+
except Exception:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
for field in sm._schema:
|
|
257
|
+
key = field["key"]
|
|
258
|
+
ftype = field["type"]
|
|
259
|
+
|
|
260
|
+
if ftype == "checkbox":
|
|
261
|
+
sm.set(key, key in form_keys)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
raw = _get(key)
|
|
265
|
+
if raw is None:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if ftype == "color":
|
|
269
|
+
try:
|
|
270
|
+
value = int(raw.lstrip("#"), 16)
|
|
271
|
+
except (ValueError, AttributeError):
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
elif ftype in ("range", "number"):
|
|
275
|
+
try:
|
|
276
|
+
if isinstance(field["default"], float):
|
|
277
|
+
value = float(raw)
|
|
278
|
+
else:
|
|
279
|
+
value = int(float(raw))
|
|
280
|
+
except (ValueError, TypeError):
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
else: # text, select
|
|
284
|
+
value = raw
|
|
285
|
+
|
|
286
|
+
sm.set(key, value)
|
|
287
|
+
|
|
288
|
+
sm.save_settings()
|
|
289
|
+
|
|
290
|
+
# The web server must never mutate display/queue state itself (see
|
|
291
|
+
# notify_settings_changed()) — it may only write settings and flag the
|
|
292
|
+
# main display loop to apply them at a safe frame boundary.
|
|
293
|
+
if app is not None and hasattr(app, "notify_settings_changed"):
|
|
294
|
+
app.notify_settings_changed()
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------ #
|
|
297
|
+
# ScrollKitApp web contract
|
|
298
|
+
# ------------------------------------------------------------------ #
|
|
299
|
+
|
|
300
|
+
async def start(self):
|
|
301
|
+
try:
|
|
302
|
+
self._build_server()
|
|
303
|
+
self._server.start(self._host, self._port)
|
|
304
|
+
self._running = True
|
|
305
|
+
return True
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print("SettingsWebServer start failed:", e)
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
def get_server_url(self):
|
|
311
|
+
return self._server_url
|
|
312
|
+
|
|
313
|
+
async def run_forever(self):
|
|
314
|
+
import asyncio
|
|
315
|
+
while self._running:
|
|
316
|
+
try:
|
|
317
|
+
self._server.poll()
|
|
318
|
+
except OSError as e:
|
|
319
|
+
print("settings server poll error:", e)
|
|
320
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
321
|
+
|
|
322
|
+
async def stop(self):
|
|
323
|
+
self._running = False
|
|
324
|
+
try:
|
|
325
|
+
if self._server is not None:
|
|
326
|
+
self._server.stop()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|