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,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WiFi connection management.
|
|
3
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
# Check if running on CircuitPython
|
|
10
|
+
is_circuitpython = hasattr(sys, 'implementation') and sys.implementation.name == 'circuitpython'
|
|
11
|
+
|
|
12
|
+
# Only import platform if not running on CircuitPython
|
|
13
|
+
if not is_circuitpython:
|
|
14
|
+
import platform
|
|
15
|
+
|
|
16
|
+
from scrollkit.config.settings_manager import SettingsManager
|
|
17
|
+
from scrollkit.utils.url_utils import load_credentials
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ['WiFiManager', 'is_dev_mode']
|
|
21
|
+
|
|
22
|
+
def _logger():
|
|
23
|
+
# Lazy: constructing ErrorHandler does a filesystem write-test, so it must
|
|
24
|
+
# not run merely from importing this module. Its own __new__ singleton
|
|
25
|
+
# guard makes repeat calls cheap.
|
|
26
|
+
from scrollkit.utils.error_handler import ErrorHandler
|
|
27
|
+
return ErrorHandler("error_log")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_dev_mode():
|
|
31
|
+
"""True when there is no real WiFi radio available (desktop dev environment).
|
|
32
|
+
|
|
33
|
+
Replaces the retired ``display_factory.is_dev_mode``. On CircuitPython this is
|
|
34
|
+
always False (real hardware). On desktop it's True unless a ``wifi`` module is
|
|
35
|
+
importable (which the test suite mocks to exercise the production path).
|
|
36
|
+
"""
|
|
37
|
+
if is_circuitpython:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
import wifi # noqa: F401
|
|
41
|
+
return False
|
|
42
|
+
except ImportError:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
class WiFiManager:
|
|
46
|
+
"""
|
|
47
|
+
Manages WiFi connections for the application
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, settings_manager):
|
|
51
|
+
"""
|
|
52
|
+
Initialize the WiFi manager
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
settings_manager: The settings manager
|
|
56
|
+
"""
|
|
57
|
+
self.settings_manager = settings_manager
|
|
58
|
+
self.ssid, self.password = self._resolve_credentials()
|
|
59
|
+
self.is_connected = False
|
|
60
|
+
self.wifi_client = None
|
|
61
|
+
self.ap_enabled = False
|
|
62
|
+
|
|
63
|
+
# Development mode values
|
|
64
|
+
self.AP_SSID = "WifiManager_DEV"
|
|
65
|
+
self.AP_PASSWORD = "password"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Check if in development mode
|
|
69
|
+
if is_dev_mode():
|
|
70
|
+
# In dev mode, simulate WiFi capabilities
|
|
71
|
+
_logger().info("Running in development mode, using simulated WiFi")
|
|
72
|
+
self.wifi = None
|
|
73
|
+
self.HAS_WIFI = False
|
|
74
|
+
# Set dummy values for development
|
|
75
|
+
self.AP_SSID = "WifiManager_DEV"
|
|
76
|
+
self.AP_PASSWORD = "password"
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Try to import CircuitPython specific modules
|
|
80
|
+
import wifi
|
|
81
|
+
self.wifi = wifi
|
|
82
|
+
self.HAS_WIFI = True
|
|
83
|
+
# extract access point mac address
|
|
84
|
+
mac_ap = ' '.join([hex(i) for i in self.wifi.radio.mac_address_ap])
|
|
85
|
+
mac_ap = mac_ap.replace('0x', '').replace(' ', '').upper()
|
|
86
|
+
# access point settings
|
|
87
|
+
self.AP_SSID = "WifiManager_" + mac_ap[5:10] + mac_ap[1:2]
|
|
88
|
+
self.AP_PASSWORD = "password"
|
|
89
|
+
self.AP_AUTHMODES = [self.wifi.AuthMode.WPA2, self.wifi.AuthMode.PSK]
|
|
90
|
+
|
|
91
|
+
except (ImportError, AttributeError) as e:
|
|
92
|
+
# Mock for non-CircuitPython environments
|
|
93
|
+
self.wifi = None
|
|
94
|
+
self.HAS_WIFI = False
|
|
95
|
+
_logger().debug(f"WiFi module not available or incomplete: {e}")
|
|
96
|
+
|
|
97
|
+
def _resolve_credentials(self):
|
|
98
|
+
"""Resolve WiFi credentials: settings.json first, secrets.py fallback.
|
|
99
|
+
|
|
100
|
+
Settings win because they are what the no-file-editing setup portal
|
|
101
|
+
writes (see run_setup_portal) — the user's latest choice must beat a
|
|
102
|
+
stale secrets.py. Returns ("", "") when neither source has an SSID.
|
|
103
|
+
"""
|
|
104
|
+
sm = self.settings_manager
|
|
105
|
+
if sm is not None:
|
|
106
|
+
try:
|
|
107
|
+
ssid = sm.get("wifi_ssid")
|
|
108
|
+
password = sm.get("wifi_password")
|
|
109
|
+
except Exception:
|
|
110
|
+
ssid = password = None
|
|
111
|
+
# Strict str checks: settings.json could hold garbage after a bad
|
|
112
|
+
# write, and tests hand in MagicMock settings managers.
|
|
113
|
+
if isinstance(ssid, str) and ssid:
|
|
114
|
+
return ssid, password if isinstance(password, str) else ""
|
|
115
|
+
return load_credentials()
|
|
116
|
+
|
|
117
|
+
async def reset(self):
|
|
118
|
+
"""Reset the microcontroller after delay"""
|
|
119
|
+
await asyncio.sleep(4)
|
|
120
|
+
if not is_dev_mode():
|
|
121
|
+
try:
|
|
122
|
+
import microcontroller
|
|
123
|
+
microcontroller.reset()
|
|
124
|
+
except ImportError:
|
|
125
|
+
_logger().debug("Microcontroller module not available, skipping reset")
|
|
126
|
+
# In non-hardware environments, just simulate a reset
|
|
127
|
+
os._exit(0)
|
|
128
|
+
|
|
129
|
+
async def connect(self, display_callback=None):
|
|
130
|
+
"""
|
|
131
|
+
Connect to WiFi
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
display_callback: Optional callback function to update display during connection attempts
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if connected, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
if is_dev_mode() or not self.HAS_WIFI:
|
|
140
|
+
_logger().debug("WiFi not available or in dev mode, simulating connection")
|
|
141
|
+
self.is_connected = True
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
if not self.ssid or not self.password:
|
|
146
|
+
_logger().error(ValueError("Missing WiFi credentials"), "WiFi credentials not found")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
_logger().info(f"Connecting to WiFi network: {self.ssid}")
|
|
150
|
+
|
|
151
|
+
# Maximum connection attempts
|
|
152
|
+
max_attempts = 3
|
|
153
|
+
for attempt in range(max_attempts):
|
|
154
|
+
try:
|
|
155
|
+
# Connect to the network
|
|
156
|
+
self.wifi.radio.connect(self.ssid, self.password)
|
|
157
|
+
self.is_connected = True
|
|
158
|
+
break
|
|
159
|
+
except Exception as conn_err:
|
|
160
|
+
# Only log on final attempt, otherwise just try again
|
|
161
|
+
if attempt == max_attempts - 1:
|
|
162
|
+
_logger().error(conn_err, f"Failed to connect to WiFi after {max_attempts} attempts")
|
|
163
|
+
|
|
164
|
+
# Update display if callback provided
|
|
165
|
+
if display_callback:
|
|
166
|
+
await display_callback(f"Attempt {attempt+1}/{max_attempts}")
|
|
167
|
+
|
|
168
|
+
# Short delay before retry
|
|
169
|
+
await asyncio.sleep(1)
|
|
170
|
+
|
|
171
|
+
if self.is_connected:
|
|
172
|
+
# Log connection info
|
|
173
|
+
ip_address = self.wifi.radio.ipv4_address
|
|
174
|
+
_logger().info(f"Connected to WiFi. IP address: {ip_address}")
|
|
175
|
+
|
|
176
|
+
# Now that we're connected, create the HTTP session
|
|
177
|
+
# This should only happen AFTER a successful WiFi connection
|
|
178
|
+
try:
|
|
179
|
+
session = self.create_http_session()
|
|
180
|
+
_logger().info("Created HTTP session after WiFi connection")
|
|
181
|
+
except Exception as session_error:
|
|
182
|
+
_logger().error(session_error, "Failed to create HTTP session after WiFi connection")
|
|
183
|
+
|
|
184
|
+
return True
|
|
185
|
+
else:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
_logger().error(e, "Error connecting to WiFi")
|
|
190
|
+
self.is_connected = False
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def create_http_session(self):
|
|
194
|
+
"""
|
|
195
|
+
Create and return a new HTTP session
|
|
196
|
+
This should only be called after WiFi is connected
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A new adafruit_requests.Session or None if not available
|
|
200
|
+
"""
|
|
201
|
+
if is_dev_mode() or not self.HAS_WIFI or not self.is_connected:
|
|
202
|
+
_logger().debug("Cannot create HTTP session without WiFi connection or in dev mode")
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
import ssl
|
|
207
|
+
import socketpool
|
|
208
|
+
import adafruit_requests
|
|
209
|
+
|
|
210
|
+
# Create a fresh socket pool from the radio
|
|
211
|
+
pool = socketpool.SocketPool(self.wifi.radio)
|
|
212
|
+
|
|
213
|
+
# Create a new SSL context
|
|
214
|
+
ssl_context = ssl.create_default_context()
|
|
215
|
+
|
|
216
|
+
# Create and return the session
|
|
217
|
+
session = adafruit_requests.Session(pool, ssl_context)
|
|
218
|
+
return session
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
_logger().error(e, "Error creating HTTP session")
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
async def reconnect(self):
|
|
225
|
+
"""
|
|
226
|
+
Reconnect to WiFi if disconnected
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if connected, False otherwise
|
|
230
|
+
"""
|
|
231
|
+
if self.is_connected:
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
# Try to reconnect
|
|
235
|
+
return await self.connect()
|
|
236
|
+
|
|
237
|
+
def save_credentials(self):
|
|
238
|
+
"""
|
|
239
|
+
Save WiFi credentials to settings manager
|
|
240
|
+
"""
|
|
241
|
+
if hasattr(self, 'settings_manager') and self.settings_manager:
|
|
242
|
+
try:
|
|
243
|
+
# Save SSID and password to settings
|
|
244
|
+
self.settings_manager.settings["wifi_ssid"] = self.ssid
|
|
245
|
+
self.settings_manager.settings["wifi_password"] = self.password
|
|
246
|
+
|
|
247
|
+
# Save settings to disk
|
|
248
|
+
self.settings_manager.save_settings()
|
|
249
|
+
_logger().info(f"Saved WiFi credentials to settings manager")
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
_logger().error(e, "Failed to save WiFi credentials to settings manager")
|
|
253
|
+
|
|
254
|
+
def scan_networks(self):
|
|
255
|
+
"""
|
|
256
|
+
Scan for available WiFi networks
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of network info (SSID, RSSI, channel, security)
|
|
260
|
+
"""
|
|
261
|
+
if is_dev_mode() or not self.HAS_WIFI:
|
|
262
|
+
_logger().debug("WiFi not available or in dev mode, returning mock networks")
|
|
263
|
+
# Return mock data for testing
|
|
264
|
+
return [
|
|
265
|
+
{"ssid": "HomeNetwork", "rssi": -65, "channel": 6},
|
|
266
|
+
{"ssid": "GuestWiFi", "rssi": -70, "channel": 11}
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
_logger().debug("Scanning for WiFi networks...")
|
|
271
|
+
networks = []
|
|
272
|
+
|
|
273
|
+
# Scan for networks
|
|
274
|
+
for network in self.wifi.radio.start_scanning_networks():
|
|
275
|
+
# Skip hidden networks
|
|
276
|
+
if not network.ssid:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
net_info = {
|
|
280
|
+
"ssid": network.ssid,
|
|
281
|
+
"rssi": network.rssi,
|
|
282
|
+
"channel": network.channel
|
|
283
|
+
}
|
|
284
|
+
networks.append(net_info)
|
|
285
|
+
|
|
286
|
+
# Sort networks by signal strength (strongest first)
|
|
287
|
+
networks.sort(key=lambda x: x["rssi"], reverse=True)
|
|
288
|
+
|
|
289
|
+
self.wifi.radio.stop_scanning_networks()
|
|
290
|
+
_logger().debug(f"Found {len(networks)} WiFi networks")
|
|
291
|
+
return networks
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
_logger().error(e, "Error scanning for WiFi networks")
|
|
295
|
+
# Return empty list on error
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------ #
|
|
299
|
+
# Access-point mode + the no-file-editing setup portal
|
|
300
|
+
# ------------------------------------------------------------------ #
|
|
301
|
+
|
|
302
|
+
def start_access_point(self):
|
|
303
|
+
"""Start the device's own WiFi access point (for the setup portal)."""
|
|
304
|
+
if is_dev_mode() or not self.HAS_WIFI:
|
|
305
|
+
_logger().debug("Simulating access point in dev mode")
|
|
306
|
+
self.ap_enabled = True
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
self.wifi.radio.enabled = True
|
|
310
|
+
if not self.ap_enabled:
|
|
311
|
+
authmodes = getattr(self, "AP_AUTHMODES", None)
|
|
312
|
+
open_ap = bool(authmodes) and authmodes[0] == self.wifi.AuthMode.OPEN
|
|
313
|
+
if open_ap:
|
|
314
|
+
self.wifi.radio.start_ap(ssid=self.AP_SSID, authmode=authmodes)
|
|
315
|
+
else:
|
|
316
|
+
self.wifi.radio.start_ap(ssid=self.AP_SSID,
|
|
317
|
+
password=self.AP_PASSWORD,
|
|
318
|
+
authmode=authmodes)
|
|
319
|
+
self.ap_enabled = True
|
|
320
|
+
|
|
321
|
+
def stop_access_point(self):
|
|
322
|
+
"""Stop the device's WiFi access point."""
|
|
323
|
+
if is_dev_mode() or not self.HAS_WIFI:
|
|
324
|
+
self.ap_enabled = False
|
|
325
|
+
return
|
|
326
|
+
try:
|
|
327
|
+
self.wifi.radio.stop_ap()
|
|
328
|
+
except Exception as e:
|
|
329
|
+
_logger().error(e, "Error stopping access point")
|
|
330
|
+
self.ap_enabled = False
|
|
331
|
+
|
|
332
|
+
def ap_ip_address(self):
|
|
333
|
+
"""The IP a phone should browse to while joined to the setup AP."""
|
|
334
|
+
if is_dev_mode() or not self.HAS_WIFI:
|
|
335
|
+
return "127.0.0.1"
|
|
336
|
+
try:
|
|
337
|
+
return str(self.wifi.radio.ipv4_address_ap)
|
|
338
|
+
except Exception:
|
|
339
|
+
# CircuitPython's soft-AP default; better than nothing on panels.
|
|
340
|
+
return "192.168.4.1"
|
|
341
|
+
|
|
342
|
+
async def run_setup_portal(self, display=None, *, port=80, reboot=True,
|
|
343
|
+
timeout_s=None):
|
|
344
|
+
"""Run the no-file-editing WiFi onboarding portal (blocking).
|
|
345
|
+
|
|
346
|
+
Starts the device's own access point and serves a setup page where
|
|
347
|
+
the user picks a network + password from a phone; credentials are
|
|
348
|
+
saved via the SettingsManager (settings.json — never a code file) and
|
|
349
|
+
the device reboots to connect. Typical wiring, at the START of an
|
|
350
|
+
app's setup() — before the display loop owns the screen::
|
|
351
|
+
|
|
352
|
+
wm = WiFiManager(self.settings)
|
|
353
|
+
if not await wm.connect():
|
|
354
|
+
await wm.run_setup_portal(display=self.display)
|
|
355
|
+
# (device reboots on save; on desktop this just returns)
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
display: Optional DisplayInterface — join instructions are
|
|
359
|
+
scrolled on the panel while the portal runs.
|
|
360
|
+
port: HTTP port for the portal (default 80).
|
|
361
|
+
reboot: Reboot the device after a successful save (hardware
|
|
362
|
+
only; desktop always just returns). A fresh boot picks the
|
|
363
|
+
saved credentials up via _resolve_credentials().
|
|
364
|
+
timeout_s: Optional give-up timeout in seconds.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
True when credentials were saved, else False.
|
|
368
|
+
|
|
369
|
+
Imported lazily so an already-configured boot never pays RAM for the
|
|
370
|
+
portal. Contract: the portal only writes settings; it owns the
|
|
371
|
+
display exclusively (boot phase), never the content queue.
|
|
372
|
+
"""
|
|
373
|
+
from scrollkit.web.wifi_setup import WiFiSetupPortal
|
|
374
|
+
portal = WiFiSetupPortal(self, display=display, port=port)
|
|
375
|
+
saved = await portal.run(timeout_s=timeout_s)
|
|
376
|
+
if saved and reboot and is_circuitpython:
|
|
377
|
+
# Hardware only: a clean reboot re-runs boot with the new
|
|
378
|
+
# credentials. reset() sleeps ~4s first, letting the linger page
|
|
379
|
+
# note on the panel be seen.
|
|
380
|
+
await self.reset()
|
|
381
|
+
return saved
|
|
382
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""ScrollKit OTA (over-the-air update) package.
|
|
3
|
+
|
|
4
|
+
Kept import-light on purpose: import the concrete classes from their submodules
|
|
5
|
+
(``scrollkit.ota.client.OTAClient``, ``scrollkit.ota.manifest.UpdateManifest``)
|
|
6
|
+
rather than from the package root, so merely importing the package does not pull
|
|
7
|
+
in the HTTP stack.
|
|
8
|
+
|
|
9
|
+
Producing/publishing a release is the desktop/CI-only ``scrollkit.ota.publish``
|
|
10
|
+
module (``build_manifest`` / ``publish_to_branch``); it imports ``os.walk``,
|
|
11
|
+
``subprocess`` and the ``git`` CLI and raises ``ImportError`` on CircuitPython,
|
|
12
|
+
so it is never imported from the package root either.
|
|
13
|
+
"""
|