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,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
+ """