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,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,6 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """ScrollKit web — default settings UI on native adafruit_httpserver."""
3
+
4
+ from .settings_server import SettingsWebServer
5
+
6
+ __all__ = ["SettingsWebServer"]
@@ -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("&", "&amp;").replace("<", "&lt;")
42
+ .replace(">", "&gt;").replace('"', "&quot;"))
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