Skip to content

App Framework

scrollkit.app.base.ScrollKitApp is the entry point for building applications.

ScrollKitApp

scrollkit.app.base.ScrollKitApp — the full-featured async base class. Subclass it and override the hooks you need.

class MyApp(ScrollKitApp):
    def __init__(self):
        super().__init__(enable_web=True, update_interval=300)

    async def setup(self):            # once, at startup
        ...
    async def update_data(self):      # every update_interval seconds
        ...
    async def prepare_display_content(self):   # each display frame
        return await self.content_queue.get_current()   # default behaviour

What a ScrollKitApp owns

The base class composes the three things every app needs — a display, a content queue, and settings — and it creates the web server lazily when memory allows. It deliberately does not own networking or OTA: apps construct those and drive them from setup() / update_data() (see Networking and OTA Updates).

classDiagram
    class ScrollKitApp {
        +setup()
        +update_data()
        +prepare_display_content()
        +on_settings_changed()
        +run()
        -_display_process()
        -_data_update_process()
        -_web_server_process()
        -_settings_dirty
    }
    class DisplayInterface
    class ContentQueue
    class SettingsManager
    class SettingsWebServer
    ScrollKitApp *-- DisplayInterface : display
    ScrollKitApp *-- ContentQueue : content_queue
    ScrollKitApp *-- SettingsManager : settings
    ScrollKitApp ..> SettingsWebServer : creates (web task)

(ScrollKitApp is the public name; SLDKApp is a backward-compatible alias.)

Three-process architecture

run() launches up to three cooperative async tasks, gated by available RAM:

Process Runs when Job
Display always render content at ~20 FPS
Data update ≥ ~30 KB free call update_data() every update_interval
Web server enable_web and ≥ ~50 KB free serve the config UI

On low-memory devices the data and web processes are skipped automatically so the display always keeps running — graceful degradation rather than a crash.

Naming

ScrollKitApp is the public name; SLDKApp remains as a backward-compatible alias.

The run loop

run() initializes the display, calls your setup(), arms the watchdog, then spawns the memory-gated tasks. The display loop is the device's heartbeat: it feeds the watchdog, applies any pending settings save, pulls the current queue item, fires a transition when the queue advances, renders, and paces itself to ~20 FPS. A data fetch is synchronous, so while update_data() runs it freezes this entire loop — which is why the data task paints a loading frame first.

sequenceDiagram
    participant App as run()
    participant Disp as _display_process
    participant Data as _data_update_process
    participant Q as ContentQueue
    participant D as UnifiedDisplay

    App->>App: _initialize_display() → create_display() → initialize()
    App->>App: setup()  (app fills the queue)
    App->>App: _arm_watchdog()  (hardware only, after boot)
    Note over App,D: create tasks, gated by free RAM
    App->>Disp: always
    App->>Data: if free RAM > 30 KB

    loop every frame (~20 FPS)
        Disp->>Disp: _feed_watchdog()
        alt _settings_dirty
            Disp->>Disp: _apply_library_settings() + on_settings_changed()
        end
        Disp->>Q: get_current()
        Q-->>Disp: content (+ _advance_count)
        opt queue advanced
            Disp->>Disp: _get_transition() → transition.start(display, swap)
        end
        Disp->>D: clear()
        Disp->>D: content.render()
        opt transition active
            Disp->>D: transition.render(content)
        end
        Disp->>D: show()
        Disp->>Disp: sleep(0.05)
    end

    loop every update_interval
        Data->>D: _render_loading()  ("Updating…")
        Data->>Data: update_data() — SYNCHRONOUS fetch, freezes the loop
    end

Pausing the display during a blocking update

update_data() often paints an off-queue status frame ("Updating…") and then makes a blocking fetch. Because the synchronous fetch freezes the loop, the previous queue item can ghost over the status frame. Suspend rendering for that window — the queue keeps its items, so a failed fetch resumes the last-good content instead of going black:

async def update_data(self):
    with self.suspended_render():          # always resumes, even on exception
        await self._teardown_active_content()
        await self.paint_status_frame("Updating")
        ok = await self.fetch()            # loop frozen anyway → no ghosting

suspend_render() / resume_render() and the render_suspended property are also available if you can't use the context manager. While suspended the base prepare_display_content() returns None, so you no longer override it just to gate rendering. Default is not suspended.

Reliability: watchdog + NVM diagnostics

Pair the hardware watchdog (enable_watchdog=True) with scrollkit.utils.diagnostics for a device that self-heals and can explain itself:

from scrollkit.utils import diagnostics

diag = diagnostics.open()                       # NVM on device, no-op on desktop
diag.record_boot(diagnostics.read_reset_reason())
if diag.safe_mode:                              # too many fault-reboots in a row
    ...                                         # skip the fetch; keep the config UI up
diag.note_fetch_result(ok=True)                 # on a healthy refresh

The record lives in microcontroller.nvm, so it survives both soft resets and power loss (unlike a flash log a crash can wipe). After RAPID_BOOT_LIMIT fault-reboots with no clean run it trips safe mode — break a deterministic boot loop instead of resetting forever — and it keeps the last reset reason + exception text for a config page post-mortem.