App and board config

August 21, 2026 · View on GitHub

Every application needs a board_config.py on sys.path that describes its hardware or host. The board config exports hardware capabilities; the application decides which coordinator, if any, to instantiate.

Board-config contract

SymbolRequiredRole
display_drvyes for display appsDisplay interface from displaydev
host_readhosted input onlyCallable that returns host events
touch_readtouch boards onlyCallable that returns contact points
touch_rotation_tableoptionalFour rotation masks for touch coordinates
keypad_readoptionalKeypad reader
encoder_read / encoder_button_readoptionalEncoder readers
joystick_driver / emulateoptionalJoystick input and optional emulation mapping
timer_asyncoptionalHost preference for async timing

Board configs do not import appdev and do not export app.

Standard applications

Applications opt into the optional event traffic controller by instantiating the coordinator directly:

import board_config
from board_config import display_drv
import appdev

app = appdev.App(board_config)

app.run()

Reusable appdev remains independent of board hardware details.

You may also provide overrides:

app = appdev.App(
    board_config,
    refresh_period=16,
    timer_async=True,
)

LVGL applications

LVGL supplies its own coordinator:

from display_driver import app

That implementation bridges LVGL to displaydev and multimer, owns LVGL tick/task handling and input-device adapters, and does not import appdev.

Direct constructor

appdev.App(
    display=None,
    host_read=None,
    touch_read=None,
    touch_rotation_table=None,
    refresh_period=None,
    timer_async=False,
)

Bare appdev.App() is valid for custom wiring. Additional devices can be attached after construction:

app.add_keypad(read=buttons.read)
app.add_joystick(joystick_driver=drv)
app.add_encoder(read=pos_read, button_read=btn_read, button=2)

App loop & run()

The supplied coordinator can manage the application loop:

def on_click(event):
    ...

app.on(app.events.MOUSEBUTTONDOWN, on_click)
app.run()

Application lifecycle

An app keeps itself alive past the end of the script body, so a trailing app.run() is optional:

app = appdev.App(board_config)

@app.every(20)
def on_frame(timer=None):
    ...

# no app.run() -- the app keeps running until it quits

App picks one of three strategies at construction, readable as app.strategy:

app.strategyWhereWhat happens
"ambient"browser/PyScript, Jupyter, python -i, MCU REPLThe host already runs a loop that outlives the script. Timers arm immediately.
"exit_hook"script mode on CPython / MicroPython / CircuitPythonAn interpreter exit hook takes the main thread when the script ends and pumps until the app quits.
"none"-m / -c entry points, or no hook availableNothing will drive the app. app.run() is required.

Call app.run() when you want the script to block at that point, or when you need a nonzero process exit code — an exit-hook-driven app always exits 0, because SystemExit cannot be raised usefully from an interpreter exit hook.

How app.run() Behaves

When called explicitly, app.run() adapts to the interpreter environment and timer model:

  1. Interactive REPL (python -i, micropython -i, MCU prompt):

    • When running with hardware interrupts or signal-based timers (machine.Timer, Linux librt, Windows uwin32), run() immediately returns.
    • The interactive prompt (>>>) stays open for live debugging and introspection while the UI continues running and responding to inputs in the background.
  2. Standalone Desktop CLI (python app.py):

    • In non-interactive desktop scripts, run() sleeps in a keep-alive loop until a quit event occurs.
    • This prevents the desktop OS process from exiting immediately after drawing the initial window.
  3. Async / Cooperative / Pumped Modes (asyncio, CircuitPython, Browser):

    • run() runs the event loop continuously to pump timer ticks and process queued events.

Or an application can explicitly poll:

while not app.quit_requested:
    for event in app.poll():
        handle(event)
    draw_frame()

Hosted displays that set needs_refresh are presented by the coordinator. Display-only MCU applications can omit appdev entirely and call display_drv.show() according to their own policy.

timer_async

Board configs publish a neutral timer_async preference. Current defaults are:

HostValue
PyScript / JupyterTrue
PG/SDL desktopFalse, optionally overridden by PYDEVICES_TIMER_ASYNC
MCU board configselected by that config

Examples do not read the environment variable directly. The selected coordinator consumes board_config.timer_async; test harnesses can use their --timer-async option.

Touch read contract

touch_read is called once per poll. It returns either a falsy value for no contacts or a sequence of (x, y[, id[, …]]) contacts. The app maps the primary contact to mouse-style events and exposes all rotated contacts as app.touch_dev.points.

Return valueMeaning
None, (), or []no touch; releases an active press
sequence of point tuplescurrent contacts
legacy bare (x, y[, …])one contact; supported for compatibility

Coordinates are panel/pre-rotation coordinates. touch_rotation_table maps them to the active display rotation. New drivers should prefer read_points() returning a sequence, even for one contact.

Refresh ownership

A GUI layer that presents frames itself can pause appdev-driven refresh:

with app.display_refresh_paused():
    run_game()

LVGL does not use this appdev mechanism: its own coordinator owns presentation from the outset.

Quit lifecycle

On QUIT, appdev runs its optional before_quit hook, releases the display, and stops its timer. app.quit_requested remains true after the first quit.

See Events, Architecture, and Board configs.

Background work on MicroPython (_thread)

On ESP32, MicroPython worker threads (_thread / mp_thread) get a very small stack. Do not run network I/O, discovery, or other deep call stacks on a new thread spawned from a soft timer or an input callback — that overflows the stack (Stack protection fault in task mp_thread).

Queue the work and run it on the main tick instead: appdev.App.on_tick, an LVGL lv.timer, or a soft multimer.auto.Timer pump. Keep UI mutations on that same main path. Desktop CPython can still use threads freely — this constraint is specific to MCU MicroPython.