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
scrollkit/app/base.py ADDED
@@ -0,0 +1,918 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Base application class for SLDK.
3
+
4
+ Provides the three-process architecture with graceful degradation for ESP32.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+
12
+ __all__ = ['SLDKApp', 'ScrollKitApp']
13
+
14
+ def create_task(coro):
15
+ """Schedule a coroutine as a Task.
16
+
17
+ MUST be a plain function (not ``async def``): ``run()`` does
18
+ ``tasks.append(create_task(...))`` and then ``await gather(*tasks)``. If this
19
+ were ``async def`` it would return a coroutine, ``gather`` would await the
20
+ wrapper (which spawns a detached task and returns instantly), and ``run()``
21
+ would fall straight through to its ``finally`` — killing the display loop
22
+ after a single frame. Works on desktop and CircuitPython asyncio.
23
+ """
24
+ return asyncio.create_task(coro)
25
+
26
+
27
+ async def gather(*aws, return_exceptions=False):
28
+ return await asyncio.gather(*aws, return_exceptions=return_exceptions)
29
+
30
+
31
+ async def sleep(seconds):
32
+ await asyncio.sleep(seconds)
33
+
34
+ import gc
35
+ try:
36
+ from typing import Optional, List
37
+ except ImportError: # CircuitPython has no 'typing' module
38
+ pass
39
+ from ..display.content import ContentQueue
40
+ from ..display.interface import DisplayInterface
41
+ from .memory import free_memory
42
+
43
+
44
+ class _SuspendRender:
45
+ """Sync context manager for SLDKApp.suspended_render(): suspend on enter,
46
+ always resume on exit (even if the wrapped block raises)."""
47
+
48
+ def __init__(self, app):
49
+ self._app = app
50
+
51
+ def __enter__(self):
52
+ self._app.suspend_render()
53
+ return self._app
54
+
55
+ def __exit__(self, exc_type, exc, tb):
56
+ self._app.resume_render()
57
+ return False
58
+
59
+
60
+ class SLDKApp:
61
+ """Base class for SLDK applications.
62
+
63
+ Implements three-process architecture:
64
+ 1. Display process - Always runs
65
+ 2. Data update process - Runs if memory allows
66
+ 3. Web server process - Optional, runs if memory allows
67
+ """
68
+
69
+ # Skip a data refresh below this much free heap (the synchronous JSON parse
70
+ # spikes memory; starting one with too little headroom OOM-crashes the device
71
+ # to black). Skipping a cycle shows last-good data instead — far better than a
72
+ # crash. On a ~160 KB device running the web server + display, a 50 KB floor is
73
+ # unreachable, so refreshes would be skipped forever (permanently stale); 25 KB
74
+ # is reachable while still leaving parse headroom (a too-low parse now fails
75
+ # gracefully rather than OOMing, so a high floor isn't needed). Tunable; the app
76
+ # can lower it or use the free_mem_before_parse breadcrumb to calibrate.
77
+ MIN_FREE_FOR_UPDATE = 25000
78
+
79
+ # Force a refresh attempt after this many consecutive low-memory skips, so a
80
+ # device that never quite clears MIN_FREE_FOR_UPDATE can't serve stale data
81
+ # forever. The forced parse fails gracefully if there genuinely isn't room.
82
+ MAX_LOW_MEM_SKIPS = 5
83
+
84
+ # Last-resort auto-reboot: after this many CONSECUTIVE failed data refreshes
85
+ # (reported via note_refresh_result), reboot to clear a wedged radio/HTTP
86
+ # session. Default 12 ≈ one hour at the 300 s default refresh cadence (an app
87
+ # using a faster stale-retry cadence reaches it sooner — tune per app). Only
88
+ # acts when enable_auto_reboot is set. See note_refresh_result()/_auto_reboot().
89
+ MAX_REFRESH_FAILURES = 12
90
+
91
+ def __init__(self, enable_web: bool = True, update_interval: int = 300,
92
+ enable_watchdog: bool = False, watchdog_timeout: int = 8,
93
+ enable_auto_reboot: bool = False,
94
+ max_refresh_failures: int = None) -> None:
95
+ """Initialize SLDK application.
96
+
97
+ Args:
98
+ enable_web: Whether to enable web server (if memory allows)
99
+ update_interval: Data update interval in seconds
100
+ enable_watchdog: Arm a hardware watchdog (CircuitPython only) that
101
+ resets the board if the display loop stops feeding it — recovers
102
+ from a true freeze (e.g. a hung synchronous socket). Default False
103
+ so existing apps are unaffected; opt in per app.
104
+ watchdog_timeout: Watchdog timeout in seconds. MUST exceed the longest
105
+ expected gap between display-loop iterations (a blocking fetch
106
+ pauses feeding), so keep HTTP timeouts comfortably below it and
107
+ feed it during any long blocking work. Default 8s — short enough for
108
+ quick freeze recovery. (Hardware note: the ESP32-S3 accepts AND
109
+ honors any value on both CP 9.2.7 and the target CP 10.2.1 — the
110
+ once-assumed ~8.3s ValueError cap does not exist; see
111
+ test/claude/RELIABILITY_TESTING.md.)
112
+ enable_auto_reboot: Opt in to the last-resort auto-reboot. The watchdog
113
+ only catches a FROZEN display loop; a box whose outbound fetches all
114
+ fail isn't frozen — the loop runs, the web server serves, the panel
115
+ just shows stale data forever (the real 637-failures-over-2-days
116
+ field outage). When enabled, ``note_refresh_result(ok=False)`` past
117
+ ``max_refresh_failures`` reboots to re-init the radio/session.
118
+ Default False so existing apps are unaffected.
119
+ max_refresh_failures: Consecutive refresh failures before an
120
+ auto-reboot. Defaults to ``MAX_REFRESH_FAILURES`` (12). Composes
121
+ with the diagnostics boot-loop breaker: in safe mode the app stops
122
+ fetching, so it stops reporting failures and this never fires — the
123
+ ~hourly reboot cadence can't spin the way a deterministic crash loop
124
+ would.
125
+ """
126
+ self.enable_web = enable_web
127
+ self.update_interval = update_interval
128
+ self.enable_watchdog = enable_watchdog
129
+ self.watchdog_timeout = watchdog_timeout
130
+ self._watchdog = None # set by _arm_watchdog() on hardware when enabled
131
+
132
+ # --- data-refresh resilience (last-resort auto-reboot) --------------
133
+ self.enable_auto_reboot = enable_auto_reboot
134
+ self.max_refresh_failures = (max_refresh_failures
135
+ if max_refresh_failures is not None
136
+ else self.MAX_REFRESH_FAILURES)
137
+ self._consecutive_refresh_failures = 0
138
+ self._last_refresh_error = None
139
+ self._last_refresh_success_time = None
140
+
141
+ self.running: bool = False
142
+ # When True, the display loop skips rendering queue content (the queue
143
+ # keeps its items) — see suspend_render()/suspended_render(). Used while an
144
+ # app paints an off-queue status frame and then blocks on a fetch, so stale
145
+ # content can't ghost over the status frame. Default off: existing apps and
146
+ # the dev harness are unaffected.
147
+ self._render_suspended: bool = False
148
+ # Web-server -> main-loop handoff: the web server (which runs in its own
149
+ # cooperative task) must never mutate display/queue state itself — see
150
+ # notify_settings_changed(). The display loop applies pending settings at
151
+ # a safe frame boundary instead.
152
+ self._settings_dirty: bool = False
153
+ self.display: Optional[DisplayInterface] = None
154
+ self.content_queue = ContentQueue()
155
+ self._tasks: List[asyncio.Task] = []
156
+
157
+ # Memory tracking
158
+ self._last_memory_report: float = 0
159
+ self._memory_report_interval = 60 # Report every minute
160
+
161
+ # Runtime metrics (cheap counters — used by the dev/verification harness)
162
+ self._frame_count: int = 0
163
+ self._current_content = None
164
+ self._run_start = None # time.monotonic() when run() began the loop
165
+
166
+ # Per-frame display-loop state, shared by _display_process and the dev
167
+ # harness (both drive frames through step_frame(), so the strict
168
+ # feasibility gate exercises the SAME code path — transitions included —
169
+ # that ships on the device).
170
+ self._reset_frame_state()
171
+
172
+ # Default settings — built-in fields (brightness_scale, scroll_speed,
173
+ # default_color) are registered by SettingsManager.__init__ via define().
174
+ from ..config.settings_manager import SettingsManager
175
+ self.settings = SettingsManager("settings.json")
176
+
177
+ # Give the content module a reference so ScrollingText/StaticText can
178
+ # read library defaults at construction time without being coupled to the app.
179
+ from ..display import content as _content_mod
180
+ _content_mod._settings = self.settings
181
+
182
+ # Abstract methods to be implemented by subclasses
183
+
184
+ async def setup(self) -> None:
185
+ """Initialize application resources.
186
+
187
+ Called once at startup. Set up your display content here.
188
+ """
189
+ raise NotImplementedError("Subclass must implement setup()")
190
+
191
+ async def update_data(self) -> None:
192
+ """Update application data.
193
+
194
+ Called periodically by data update process.
195
+ This is where you fetch new data, update content, etc.
196
+ """
197
+ pass # Optional - subclass can override if needed
198
+
199
+ async def prepare_display_content(self):
200
+ """Prepare content for display.
201
+
202
+ Called by display process to get content.
203
+ Return a DisplayContent instance or None.
204
+ """
205
+ # While rendering is suspended, draw nothing from the queue (the queue keeps
206
+ # its items, so it resumes exactly where it was). Lets an app paint an
207
+ # off-queue status frame + block on a fetch without stale content ghosting.
208
+ if self._render_suspended:
209
+ return None
210
+ # Default implementation uses content queue
211
+ return await self.content_queue.get_current()
212
+
213
+ # --- render suspension --------------------------------------------------
214
+ # An app sometimes needs to paint an off-queue frame (e.g. "Updating…") and
215
+ # then make a blocking call (a synchronous HTTP fetch pauses the whole loop).
216
+ # Suspending render keeps the queue intact but stops the loop drawing it, so
217
+ # the previous item can't ghost over the status frame. Prefer the context
218
+ # manager — it always resumes, even if the blocking work raises.
219
+
220
+ def suspend_render(self) -> None:
221
+ """Stop the display loop from drawing queue content (queue is preserved)."""
222
+ self._render_suspended = True
223
+
224
+ def resume_render(self) -> None:
225
+ """Resume drawing queue content after suspend_render()."""
226
+ self._render_suspended = False
227
+
228
+ @property
229
+ def render_suspended(self) -> bool:
230
+ """Whether queue rendering is currently suspended."""
231
+ return self._render_suspended
232
+
233
+ def suspended_render(self):
234
+ """Context manager: suspend rendering for the block, always resume after.
235
+
236
+ with app.suspended_render():
237
+ await app.paint_status_frame()
238
+ await app.blocking_fetch() # loop frozen anyway; no ghosting
239
+ """
240
+ return _SuspendRender(self)
241
+
242
+ async def cleanup(self) -> None:
243
+ """Clean up resources on shutdown.
244
+
245
+ Called when application is stopping.
246
+ """
247
+ pass # Optional - subclass can override
248
+
249
+ async def show_loading(self) -> None:
250
+ """Optional hook: render a "loading" frame before a blocking update.
251
+
252
+ ``update_data()`` typically makes blocking HTTP calls — ``adafruit_requests``
253
+ is synchronous on CircuitPython, so the call pauses the display loop until
254
+ it returns. Override this to draw a static loading indicator first, so the
255
+ pause looks intentional rather than a freeze (spec FR-029). Default: no-op.
256
+ """
257
+ pass
258
+
259
+ async def _render_loading(self) -> None:
260
+ """Invoke show_loading() defensively (never let it kill the data loop)."""
261
+ try:
262
+ await self.show_loading()
263
+ except Exception as e:
264
+ print(f"show_loading error: {e}")
265
+
266
+ # Core process implementations
267
+
268
+ def _reset_frame_state(self) -> None:
269
+ """Reset the cross-frame state used by step_frame().
270
+
271
+ Called from __init__ and at the start of each frame-driving run
272
+ (_display_process, dev harness) so a reused app starts clean.
273
+ """
274
+ self._frame_prev_content = None
275
+ self._frame_active_transition = None
276
+ self._frame_prev_advance = 0 # tracks content_queue._advance_count
277
+ # Survives across frames: set when content advances (queue cycle or
278
+ # rebuild); cleared only when a transition fires (or no transition is
279
+ # configured). This lets saves that arrive during a playing transition
280
+ # queue up and fire when the current one finishes.
281
+ self._frame_wants_transition = False
282
+
283
+ async def step_frame(self) -> bool:
284
+ """Run exactly ONE display-loop frame; the single frame implementation.
285
+
286
+ Applies pending web-settings saves, prepares content, fires/advances
287
+ the configured transition, renders, and shows. Both _display_process
288
+ (the shipping loop) and scrollkit.dev's run_headless drive frames
289
+ through here — never a copy — so the strict feasibility gate verifies
290
+ the exact code path the device runs, transitions included.
291
+
292
+ Returns False when the display reports closed (simulator window),
293
+ True otherwise. Exceptions propagate to the caller: the run loop logs
294
+ and continues; the harness surfaces FeasibilityError as a gate failure.
295
+ """
296
+ if self._settings_dirty:
297
+ self._settings_dirty = False
298
+ try:
299
+ self._apply_library_settings()
300
+ except Exception as e:
301
+ print("_apply_library_settings error:", e)
302
+ try:
303
+ self.on_settings_changed()
304
+ except Exception as e:
305
+ print("on_settings_changed error:", e)
306
+
307
+ content = await self.prepare_display_content()
308
+ self._current_content = content
309
+
310
+ # Detect queue advances (loop, multi-item advance, or rebuild).
311
+ # The advance counter increments on every start() call; _prev_advance > 0
312
+ # skips the very first play (nothing to transition from yet).
313
+ advance = getattr(self.content_queue, "_advance_count", 0)
314
+ if advance != self._frame_prev_advance and self._frame_prev_advance > 0:
315
+ self._frame_wants_transition = True
316
+ self._frame_prev_advance = advance
317
+
318
+ if content and self.display:
319
+ # Fallback: object identity catches custom prepare_display_content()
320
+ # implementations that don't use ContentQueue.
321
+ if (content is not self._frame_prev_content
322
+ and self._frame_prev_content is not None):
323
+ self._frame_wants_transition = True
324
+
325
+ # Fire the deferred transition as soon as nothing is active.
326
+ if self._frame_wants_transition and self._frame_active_transition is None:
327
+ t = self._get_transition()
328
+ if t is not None:
329
+ await t.start(self.display, lambda: None)
330
+ self._frame_active_transition = t
331
+ self._frame_wants_transition = False
332
+
333
+ await self.display.clear()
334
+
335
+ # Let the active transition adjust content position before
336
+ # it renders (used by DropFromSky to animate y each frame).
337
+ if self._frame_active_transition is not None and hasattr(
338
+ self._frame_active_transition, 'pre_render_hook'):
339
+ self._frame_active_transition.pre_render_hook(content)
340
+
341
+ await content.render(self.display)
342
+
343
+ if self._frame_active_transition is not None:
344
+ await self._frame_active_transition.render(self.display, content=content)
345
+ if self._frame_active_transition.is_complete:
346
+ self._frame_active_transition = None
347
+
348
+ # show() returns False when the simulator window closes.
349
+ if await self.display.show() is False:
350
+ self._frame_prev_content = content
351
+ return False
352
+
353
+ self._frame_count += 1
354
+
355
+ self._frame_prev_content = content
356
+ return True
357
+
358
+ async def _display_process(self) -> None:
359
+ """Process 1: Handle display updates."""
360
+ print("Display process started")
361
+ self._reset_frame_state()
362
+
363
+ while self.running:
364
+ try:
365
+ # The display loop is the device's liveness heartbeat: feeding the
366
+ # watchdog here means a wedged loop (e.g. a hung synchronous fetch
367
+ # that froze the whole event loop) stops feeding and triggers a
368
+ # self-healing reset. A caught render error just continues + refeeds.
369
+ self._feed_watchdog()
370
+
371
+ if await self.step_frame() is False:
372
+ self._request_shutdown()
373
+ return
374
+
375
+ await sleep(0.05) # 20 FPS
376
+ await self._report_memory()
377
+
378
+ except Exception as e:
379
+ print(f"Display error: {e}")
380
+ await sleep(1)
381
+
382
+ def _request_shutdown(self) -> None:
383
+ """Stop the app and cancel sibling tasks so it exits promptly.
384
+
385
+ Without cancelling siblings, a data-update task asleep for
386
+ ``update_interval`` seconds would keep the process alive long after the
387
+ window closed.
388
+ """
389
+ self.running = False
390
+ current = asyncio.current_task()
391
+ for task in list(self._tasks):
392
+ if task is not current:
393
+ task.cancel()
394
+
395
+ async def _data_update_process(self) -> None:
396
+ """Process 2: Handle data updates."""
397
+ print("Data update process started")
398
+
399
+ # Initial update
400
+ try:
401
+ await self._render_loading()
402
+ await self.update_data()
403
+ except Exception as e:
404
+ print(f"Initial data update error: {e}")
405
+
406
+ low_mem_skips = 0
407
+ while self.running:
408
+ try:
409
+ # Wait for update interval
410
+ await sleep(self.update_interval)
411
+
412
+ # Check if we have enough memory
413
+ gc.collect()
414
+ free_mem = free_memory()
415
+
416
+ # After too many consecutive low-memory skips, force one attempt so a
417
+ # device that never quite clears the floor can't lock onto stale data
418
+ # forever (the forced parse fails gracefully if there's truly no room).
419
+ force = low_mem_skips >= self.MAX_LOW_MEM_SKIPS
420
+ if free_mem > self.MIN_FREE_FOR_UPDATE or force:
421
+ if force:
422
+ print(f"Forcing data update after {low_mem_skips} low-memory skips: {free_mem}")
423
+ low_mem_skips = 0
424
+ # Show a loading frame before the (possibly blocking) fetch.
425
+ await self._render_loading()
426
+ await self.update_data()
427
+ else:
428
+ # Too little headroom for the parse: skip this cycle and keep
429
+ # last-good data rather than risk an OOM crash to black.
430
+ low_mem_skips += 1
431
+ print(f"Skipping data update - low memory: {free_mem}")
432
+
433
+ except Exception as e:
434
+ print(f"Data update error: {e}")
435
+ await sleep(30) # Back off on error
436
+
437
+ def _apply_library_settings(self):
438
+ """Apply display-level settings to the live display and content queue.
439
+
440
+ Called by the display loop (see _display_process) when a web save sets
441
+ _settings_dirty, immediately before on_settings_changed(). Handles
442
+ settings the library owns: brightness (pushed to the display hardware)
443
+ and scroll speed (propagated to every queue item that exposes a .speed
444
+ attribute). Runs on the display-loop task, never from the web server.
445
+ """
446
+ if self.display is not None:
447
+ try:
448
+ brightness = float(self.settings.get("brightness_scale", 0.5))
449
+ brightness = max(0.0, min(1.0, brightness))
450
+ self.display._brightness = brightness
451
+ # .display is the underlying displayio display on every path
452
+ # (S3 wrapper display, Interstate 75 FramebufferDisplay, or the
453
+ # simulator Display). self.display.hardware may be a raw
454
+ # rgbmatrix.RGBMatrix with no .display attribute — never reach
455
+ # through it.
456
+ disp = getattr(self.display, "display", None)
457
+ if disp is not None:
458
+ disp.brightness = brightness
459
+ except Exception as e:
460
+ print("brightness apply error:", e)
461
+
462
+ try:
463
+ speed_px = self.settings.get_scroll_speed_px()
464
+ for item in self.content_queue:
465
+ key = getattr(item, "_color_setting", None)
466
+ if key is not None:
467
+ item.color = self.settings.get(key, 0xFFFFFF)
468
+ if getattr(item, "_speed_is_default", False):
469
+ item.speed = speed_px
470
+ except Exception as e:
471
+ print("content settings apply error:", e)
472
+
473
+ def _get_transition(self):
474
+ """Return a fresh Transition for the current transition_style setting, or None.
475
+
476
+ The name -> class dispatch lives in effects.transitions.transition_factory
477
+ (kept in lockstep with config.transition_names.TRANSITION_NAMES, which is
478
+ what the settings UI offers). Imported lazily so a "None" transition style
479
+ never drags the effects package onto the RAM-constrained device.
480
+ """
481
+ style = self.settings.get("transition_style", "None")
482
+ if not style or style == "None":
483
+ return None
484
+ try:
485
+ from ..effects.transitions import transition_factory
486
+ except ImportError:
487
+ return None
488
+ t = transition_factory(style)
489
+ if t is None:
490
+ # A stale/unknown saved value shouldn't silently disable transitions
491
+ # without a trace — make it visible (non-fatal).
492
+ print("Unknown transition_style %r; using None" % (style,))
493
+ return t
494
+
495
+ def notify_settings_changed(self):
496
+ """Web-server -> main-loop handoff: request that saved settings be applied.
497
+
498
+ This is the ONLY thing a web server may do besides writing settings —
499
+ it must never mutate display/queue state itself (that's owned solely by
500
+ the display-loop task). Sets a flag; the display loop applies the
501
+ settings (and calls on_settings_changed()) at its next frame boundary.
502
+ Safe to call from a synchronous route handler, and safe to call more
503
+ than once before the loop next runs (multiple saves coalesce into one
504
+ apply — settings are re-read from disk, not queued).
505
+ """
506
+ self._settings_dirty = True
507
+
508
+ def on_settings_changed(self):
509
+ """Called by the display loop after a web-saved settings change is applied.
510
+
511
+ Override to immediately rebuild display content. Must be synchronous
512
+ and fast — it runs on the display-loop task, not in its own task, so it
513
+ blocks rendering while it runs.
514
+ """
515
+ pass
516
+
517
+ async def create_web_server(self):
518
+ """Create web server instance.
519
+
520
+ The default implementation returns a ``SettingsWebServer`` driven by
521
+ ``self.settings._schema`` (populated by ``SettingsManager.define()``).
522
+ Override to replace the auto-generated UI with a custom web server.
523
+ Return ``None`` to disable the web server entirely.
524
+
525
+ Returns:
526
+ Web server instance (must implement start/run_forever/stop/get_server_url),
527
+ or None to skip the web server process.
528
+ """
529
+ settings = getattr(self, "settings", None)
530
+ if settings is None or not getattr(settings, "_schema", None):
531
+ return None
532
+ try:
533
+ import sys
534
+ from ..web.settings_server import SettingsWebServer
535
+ is_cp = (hasattr(sys, "implementation")
536
+ and sys.implementation.name == "circuitpython")
537
+ port = 80 if is_cp else 8080
538
+ return SettingsWebServer(settings, app=self, port=port)
539
+ except ImportError:
540
+ return None
541
+
542
+ async def _web_server_process(self) -> None:
543
+ """Process 3: Handle web interface."""
544
+ if not self.enable_web:
545
+ return
546
+
547
+ # Check if we have enough memory for web server
548
+ gc.collect()
549
+ free_mem = free_memory()
550
+
551
+ if free_mem < 50000: # Need 50KB free
552
+ print(f"Web server disabled - insufficient memory: {free_mem}")
553
+ return
554
+
555
+ try:
556
+ # Create web server
557
+ web_server = await self.create_web_server()
558
+ if not web_server:
559
+ return
560
+
561
+ print("Web server process started")
562
+
563
+ # Start the web server
564
+ if await web_server.start():
565
+ print(f"Web interface available at: {web_server.get_server_url()}")
566
+
567
+ # Run web server
568
+ await web_server.run_forever()
569
+ else:
570
+ print("Failed to start web server")
571
+
572
+ except ImportError:
573
+ print("Web server not available - adafruit_httpserver is required")
574
+ except Exception as e:
575
+ print(f"Web server error: {e}")
576
+
577
+ async def _report_memory(self) -> None:
578
+ """Report memory usage periodically."""
579
+ try:
580
+ import time
581
+ now = time.monotonic() if hasattr(time, 'monotonic') else 0
582
+
583
+ if now - self._last_memory_report > self._memory_report_interval:
584
+ gc.collect()
585
+ free = free_memory()
586
+ if free > 0:
587
+ print(f"Free memory: {free} bytes")
588
+ self._last_memory_report = now
589
+
590
+ except Exception:
591
+ pass # Ignore memory reporting errors
592
+
593
+ # Main application lifecycle
594
+
595
+ async def run(self) -> None:
596
+ """Run the application with three processes."""
597
+ print("Starting SLDK application")
598
+
599
+ # Initialize display
600
+ await self._initialize_display()
601
+
602
+ try:
603
+ self.running = True
604
+
605
+ import time
606
+ self._run_start = time.monotonic() if hasattr(time, "monotonic") else None
607
+
608
+ # Run setup
609
+ await self.setup()
610
+
611
+ # Arm the watchdog AFTER the (possibly long, blocking) boot sequence so
612
+ # boot's network calls can't trip it; the display loop feeds it from here on.
613
+ self._arm_watchdog()
614
+
615
+ # Create tasks based on available memory
616
+ tasks = []
617
+
618
+ # Display process always runs
619
+ tasks.append(create_task(self._display_process()))
620
+
621
+ # Data update process if memory allows
622
+ gc.collect()
623
+ free_mem = free_memory()
624
+
625
+ if free_mem > 30000: # 30KB free
626
+ tasks.append(create_task(self._data_update_process()))
627
+ else:
628
+ print(f"Data updates disabled - low memory: {free_mem}")
629
+
630
+ # Web server if enabled and memory allows
631
+ if self.enable_web and free_mem > 50000:
632
+ tasks.append(create_task(self._web_server_process()))
633
+
634
+ self._tasks = tasks
635
+
636
+ # Run until stopped
637
+ await gather(*tasks, return_exceptions=True)
638
+
639
+ finally:
640
+ self.running = False
641
+ await self.cleanup()
642
+
643
+ # Release the module-level settings hook set in __init__ (used by
644
+ # content._resolve_color/_resolve_speed) so a finished app doesn't
645
+ # leak its settings into content built by a LATER app in the same
646
+ # process (the dev-harness/test norm).
647
+ from ..display import content as _content_mod
648
+ if getattr(_content_mod, "_settings", None) is self.settings:
649
+ _content_mod._settings = None
650
+
651
+ # Cancel any remaining tasks
652
+ for task in self._tasks:
653
+ try:
654
+ task.cancel()
655
+ except Exception:
656
+ pass
657
+
658
+ async def create_display(self) -> DisplayInterface:
659
+ """Create display instance.
660
+
661
+ Override this method to use custom hardware.
662
+
663
+ Returns:
664
+ DisplayInterface instance
665
+ """
666
+ # Use unified display which auto-detects platform
667
+ from ..display import UnifiedDisplay
668
+ return UnifiedDisplay()
669
+
670
+ async def _initialize_display(self) -> None:
671
+ """Initialize the display based on platform."""
672
+ try:
673
+ # Allow application to override display creation
674
+ self.display = await self.create_display()
675
+ await self.display.initialize()
676
+
677
+ except ImportError as e:
678
+ print(f"Failed to initialize display: {e}")
679
+ print("Install simulator with 'pip install \"scrollkit[simulator]\"' for desktop development")
680
+ except OSError as e:
681
+ print(f"Display initialization failed: {e}")
682
+
683
+ def _arm_watchdog(self) -> None:
684
+ """Arm the hardware watchdog (CircuitPython only) if enabled.
685
+
686
+ Fed from the display loop (the device's liveness heartbeat): if anything
687
+ wedges that loop — most importantly a hung synchronous HTTP call — feeding
688
+ stops and the board resets, self-recovering instead of sitting frozen/black
689
+ until someone power-cycles it. No-op on desktop, when disabled, or while a
690
+ USB serial console is attached (so the watchdog doesn't reboot during
691
+ interactive debugging)."""
692
+ if not self.enable_watchdog or self._watchdog is not None:
693
+ return
694
+ try:
695
+ import sys
696
+ if not (hasattr(sys, "implementation")
697
+ and sys.implementation.name == "circuitpython"):
698
+ return
699
+ try:
700
+ import supervisor
701
+ if getattr(supervisor.runtime, "serial_connected", False):
702
+ print("Watchdog NOT armed: USB serial connected (debugging)")
703
+ return
704
+ except Exception:
705
+ pass
706
+ import microcontroller
707
+ from watchdog import WatchDogMode
708
+ wdt = microcontroller.watchdog
709
+ # The ESP32-S3 accepts and honors any timeout we set on both CP 9.2.7 and
710
+ # the target CP 10.2.1 — hardware-verified; the once-assumed ~8.3s
711
+ # ValueError cap does not exist (see test/claude/RELIABILITY_TESTING.md).
712
+ # So set it directly. If some future board/version did reject the value,
713
+ # the broad `except` below logs it and leaves the watchdog disarmed.
714
+ wdt.timeout = self.watchdog_timeout
715
+ wdt.mode = WatchDogMode.RESET
716
+ self._watchdog = wdt
717
+ print(f"Watchdog armed: {self.watchdog_timeout}s (RESET)")
718
+ except Exception as e:
719
+ print(f"Watchdog unavailable: {e}")
720
+
721
+ def _feed_watchdog(self) -> None:
722
+ """Pet the watchdog so it doesn't reset the board. Safe when disarmed."""
723
+ wdt = self._watchdog
724
+ if wdt is not None:
725
+ try:
726
+ wdt.feed()
727
+ except Exception:
728
+ pass
729
+
730
+ # --- data-refresh resilience -------------------------------------------
731
+ # The watchdog only catches a frozen display loop. A box whose every outbound
732
+ # fetch fails is NOT frozen — the loop runs, the web UI serves, the panel just
733
+ # shows stale data indefinitely (the real field outage: 637 failed fetches
734
+ # over ~2 days, never self-recovered). This is the generic, app-driven last
735
+ # resort: the app reports each refresh's outcome and, opt-in, the box reboots
736
+ # to re-init the radio/session after a sustained failure run.
737
+
738
+ def note_refresh_result(self, ok: bool, reason=None) -> int:
739
+ """Report the outcome of one data refresh (the app's resilience hook).
740
+
741
+ Call once per refresh attempt (typically at the end of ``update_data()``)
742
+ with whether it actually fetched + applied fresh data. On success the
743
+ failure streak resets and the last-success time is stamped; on failure the
744
+ streak grows and — if ``enable_auto_reboot`` is set and the streak reaches
745
+ ``max_refresh_failures`` — a last-resort reboot is triggered to clear a
746
+ wedged radio/session.
747
+
748
+ Args:
749
+ ok: True if the refresh succeeded.
750
+ reason: Optional diagnostic string for a failure (e.g.
751
+ ``str(http_client.last_error)``). Surfaced via ``last_refresh_error``
752
+ and persisted to NVM before an auto-reboot so the outage is
753
+ diagnosable after recovery.
754
+
755
+ Returns:
756
+ The current consecutive-failure count (0 after a success).
757
+ """
758
+ if ok:
759
+ self._consecutive_refresh_failures = 0
760
+ self._last_refresh_error = None
761
+ import time
762
+ self._last_refresh_success_time = (
763
+ time.monotonic() if hasattr(time, "monotonic") else None)
764
+ return 0
765
+
766
+ self._consecutive_refresh_failures += 1
767
+ if reason is not None:
768
+ self._last_refresh_error = reason
769
+ if (self.enable_auto_reboot
770
+ and self._consecutive_refresh_failures >= self.max_refresh_failures):
771
+ self._auto_reboot(
772
+ "%d consecutive refresh failures; last_error=%s"
773
+ % (self._consecutive_refresh_failures, self._last_refresh_error))
774
+ return self._consecutive_refresh_failures
775
+
776
+ @property
777
+ def consecutive_refresh_failures(self) -> int:
778
+ """Consecutive failed refreshes reported via ``note_refresh_result``."""
779
+ return self._consecutive_refresh_failures
780
+
781
+ @property
782
+ def data_stale(self) -> bool:
783
+ """True when the most recent reported refresh failed (showing old data)."""
784
+ return self._consecutive_refresh_failures > 0
785
+
786
+ @property
787
+ def last_refresh_error(self):
788
+ """Diagnostic reason from the last failed refresh, or None."""
789
+ return self._last_refresh_error
790
+
791
+ def seconds_since_last_refresh_success(self):
792
+ """Seconds since the last successful refresh, or None if never succeeded.
793
+
794
+ A staleness signal the app can read to drive an on-panel "stale data"
795
+ indicator — the box can look alive while the data is hours old.
796
+ """
797
+ if self._last_refresh_success_time is None:
798
+ return None
799
+ import time
800
+ if not hasattr(time, "monotonic"):
801
+ return None
802
+ return time.monotonic() - self._last_refresh_success_time
803
+
804
+ def _auto_reboot(self, reason: str) -> None:
805
+ """Last-resort reboot to clear a wedged radio/session (device-only).
806
+
807
+ Records the cause to NVM (survives the reset; shown on the config UI after
808
+ recovery) then resets via ``_hardware_reset()``. A no-op on desktop/sim, so
809
+ the simulator and tests never reboot.
810
+ """
811
+ wifi_state = self._wifi_connected()
812
+ full_reason = "auto-reboot: %s (wifi_connected=%s)" % (reason, wifi_state)
813
+ print(full_reason)
814
+ # Best-effort: persist the cause so the next outage is diagnosable. A
815
+ # reboot fixes the wedge; if the link is genuinely down it won't — but an
816
+ # ~hourly retry-reboot is acceptable, and wifi_state records which it was.
817
+ try:
818
+ from ..utils import diagnostics
819
+ diagnostics.open().record_crash(full_reason)
820
+ except Exception as e:
821
+ print("auto-reboot diag record failed:", e)
822
+ self._hardware_reset()
823
+
824
+ def _hardware_reset(self) -> None:
825
+ """Reset the board on CircuitPython; no-op elsewhere (test/sim seam)."""
826
+ if not self._is_circuitpython():
827
+ return
828
+ try:
829
+ import microcontroller
830
+ microcontroller.reset()
831
+ except Exception as e:
832
+ print("auto-reboot reset failed:", e)
833
+
834
+ @staticmethod
835
+ def _is_circuitpython() -> bool:
836
+ import sys
837
+ return (hasattr(sys, "implementation")
838
+ and sys.implementation.name == "circuitpython")
839
+
840
+ @staticmethod
841
+ def _wifi_connected():
842
+ """Best-effort WiFi-associated check; None when undetectable (desktop)."""
843
+ try:
844
+ import wifi
845
+ radio = wifi.radio
846
+ if getattr(radio, "connected", False):
847
+ return True
848
+ return getattr(radio, "ipv4_address", None) is not None
849
+ except Exception:
850
+ return None
851
+
852
+ def stop(self) -> None:
853
+ """Stop the application."""
854
+ self.running = False
855
+
856
+ # Runtime metrics -------------------------------------------------------
857
+ # Cheap, device-safe introspection used by the desktop verification
858
+ # harness (``scrollkit.dev.run_headless``) so an AI agent can confirm the
859
+ # app actually rendered/advanced. All a few integer reads — no allocation.
860
+
861
+ @property
862
+ def frame_count(self) -> int:
863
+ """Number of frames actually shown since ``run()`` started."""
864
+ return self._frame_count
865
+
866
+ def fps(self) -> float:
867
+ """Average displayed frames per second since ``run()`` started.
868
+
869
+ Returns 0.0 before the loop starts. On the simulator this is desktop
870
+ speed; the *hardware* estimate lives in the feasibility report.
871
+ """
872
+ if self._run_start is None:
873
+ return 0.0
874
+ import time
875
+ if not hasattr(time, "monotonic"):
876
+ return 0.0
877
+ elapsed = time.monotonic() - self._run_start
878
+ return (self._frame_count / elapsed) if elapsed > 0 else 0.0
879
+
880
+ def describe(self) -> dict:
881
+ """A small, JSON-able snapshot of the app's current state.
882
+
883
+ Reads a supported summary of the content being shown (via its
884
+ ``describe()``) instead of poking private attributes.
885
+ """
886
+ content = self._current_content
887
+ if content is not None and hasattr(content, "describe"):
888
+ try:
889
+ content_desc = content.describe()
890
+ except Exception:
891
+ content_desc = type(content).__name__
892
+ elif content is not None:
893
+ content_desc = type(content).__name__
894
+ else:
895
+ content_desc = None
896
+ return {
897
+ "running": self.running,
898
+ "frame_count": self._frame_count,
899
+ "fps": round(self.fps(), 1),
900
+ "enable_web": self.enable_web,
901
+ "update_interval": self.update_interval,
902
+ "current_content": content_desc,
903
+ }
904
+
905
+ def memory_estimate(self) -> dict:
906
+ """Estimated free RAM: real on hardware, modeled/large on desktop.
907
+
908
+ Delegates to ``scrollkit.app.memory.free_memory`` so the value matches
909
+ what the memory ladder in the run loop actually gates on.
910
+ """
911
+ from .memory import free_memory
912
+ free = free_memory()
913
+ return {"free_bytes": free}
914
+
915
+
916
+ # Public name for the merged ScrollKit library. `SLDKApp` is retained as a
917
+ # backward-compatible alias for code/tests that still reference the old name.
918
+ ScrollKitApp = SLDKApp