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
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
|