Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,18 @@ Docs are markdown under `docs/`, published only via GitHub Pages
never imported by `bledev/__init__.py` or a backend.
`AutoDisplay` is `displaydev.auto` only — never re-exported from
`displaydev/__init__.py`. Backends must not import `.auto`.
Likewise, synchronous `Timer` providers are explicit `multimer` modules
(`multimer.machine`, `multimer.librt`, `multimer.win32`, `multimer.sdl2`,
`multimer.threading`, or `multimer.polling`). Automatic selection is
`multimer.auto` only and providers must not import it. The package root is
backend-neutral and owns shared clocks, scheduling, `AsyncTimer`, and the
lazy `asyncio` export.
Likewise, `multimer`'s wake sources are private modules
(`multimer._src_signal`, `_src_pending`, `_src_asyncio`, `_src_machine`,
`_src_wasm`, `_src_native`, `_src_none`) that the dispatcher picks once, on
the first armed timer; nothing else imports them, and `import multimer`
touches no host mechanism. There is one public `Timer`.

## Do not

- Put product libraries or their release pipeline back in the examples repo.
- Instantiate `appdev.App` (or any traffic controller) in a board config.
- Import `displaydev.auto` from `displaydev/__init__.py` or any backend.
- Import `multimer.auto` from `multimer/__init__.py` or any provider.
- Import a `multimer._src_*` module from anywhere but the dispatcher.
- Commit large generated assets unrelated to boards/drivers.
- Rename the GitHub repo casually — MIP URLs and docs pin this name.
- Add `board_peripherals.py` under `board_configs/cp/`.
Expand Down
4 changes: 0 additions & 4 deletions board_configs/cp/fbdisplay/qualia_tl040hds20/board_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,4 @@ def read(self):
keypad = _Keypad()

touch_read = _touch_points
# Sync + multimer polling Timer: CircuitPython has no machine.Timer and
# (on this build) no frozen asyncio — timer_async would use _mpasyncio and
# leave LVGL unarmed / blank after ``import lv_test_timer``.
timer_async = False
keypad_read = keypad.read
1 change: 0 additions & 1 deletion board_configs/desktop/board_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
)

host_read = display_drv.get_events
timer_async = env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer)

display_drv.fill(0)

Expand Down
1 change: 0 additions & 1 deletion board_configs/jndisplay/board_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
display_drv = JNDisplay(width, height)

host_read = display_drv.get_events
timer_async = display_drv.requires_async_timer

display_drv.fill(0)

Expand Down
1 change: 0 additions & 1 deletion board_configs/psdisplay/board_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
display_drv = PSDisplay("display_canvas", width, height)

host_read = display_drv.get_events
timer_async = display_drv.requires_async_timer

display_drv.fill(0)

Expand Down
17 changes: 9 additions & 8 deletions docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ still launches `main.py` first.
| Looping entry + **Ctrl+C** | `KeyboardInterrupt`, then banner + `>>>` |
| Bare `android.py -i` | Clean `>>>` (`main.py` removed for this session) |

With `multimer` **threading** (`timer_async=False`, Android's usual path) there is
no MicroPython soft-IRQ into the REPL mid-loop — matching `micropython.exe -i` on
Windows desktop. MicroPython's signals / `machine.Timer` path can return from
`run` immediately so `>>>` coexists with ticks; Android does not fake that.
`multimer`'s `pending` source delivers between two bytecodes of the main
thread, so `>>>` coexists with ticks on Android as it does on a board: the
prompt is served while it waits, and a long statement typed there is
interrupted by the app's timers like any other main-line code.

TTY editing aims for MicroPython REPL parity:

Expand Down Expand Up @@ -172,10 +172,11 @@ not drive the Android window size; desktop `SDLDisplay` still uses software

## Timers

`multimer` skips auto **`sdl2`** on Android — CPython's `SDL_AddTimer` is not on
the GLES thread and raises `EGL_BAD_ACCESS`. Auto-select falls through to
**`threading`**; the launcher also sets `MULTIMER_BACKEND=threading`.
See [multimer](multimer.md).
`multimer` uses its `pending` source on Android: a worker thread keeps time
and the callback runs on the main (GLES) thread between two bytecodes, so
SDL's timer thread is never involved and `EGL_BAD_ACCESS` has no path to
happen through. Nothing needs setting in the launcher. See
[multimer](multimer.md).

## Audio

Expand Down
38 changes: 10 additions & 28 deletions docs/app-and-board-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ application decides which coordinator, if any, to instantiate.
| `keypad_read` | optional | Keypad reader |
| `encoder_read` / `encoder_button_read` | optional | Encoder readers |
| `joystick_driver` / `emulate` | optional | Joystick input and optional emulation mapping |
| `timer_async` | optional | Host preference for async timing |

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

Expand All @@ -41,7 +40,6 @@ You may also provide overrides:
app = appdev.App(
board_config,
refresh_period=16,
timer_async=True,
)
```

Expand All @@ -65,7 +63,6 @@ appdev.App(
touch_read=None,
touch_rotation_table=None,
refresh_period=None,
timer_async=False,
)
```

Expand Down Expand Up @@ -119,18 +116,17 @@ 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:
When called explicitly, `app.run()` adapts to the host:

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.
1. **A host that owns a loop (`python -i`, `micropython -i`, an MCU prompt, a
notebook, a browser page)**: `run()` **returns at once**. The prompt (or
the page) stays open for live debugging and introspection while the UI
keeps running; `multimer.report()` shows its timers.

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.
2. **A script (`python app.py`, `code.py`)**: `run()` blocks in
`multimer.run_until`, delivering timers and events, until a quit event.
Without `run()` the interpreter's exit hook does the same after the last
line, so the call is optional.

Or an application can explicitly poll:

Expand All @@ -146,20 +142,6 @@ 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:

| Host | Value |
|---|---|
| PyScript / Jupyter | `True` |
| PG/SDL desktop | `False`, optionally overridden by `PYDEVICES_TIMER_ASYNC` |
| MCU board config | selected 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
Expand Down Expand Up @@ -205,6 +187,6 @@ thread spawned from a soft timer or an input callback — that overflows the sta
(`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`](multimer.md) pump. Keep UI
an LVGL `lv.timer`, or a [`multimer.Timer`](multimer.md). Keep UI
mutations on that same main path. Desktop CPython can still use threads freely —
this constraint is specific to MCU MicroPython.
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ flowchart TB
| `displaydev` | Cross-platform display interfaces (`BusDisplay`, `FBDisplay`, `SDLDisplay`, `PyScriptDisplay`). |
| `audiodev` | Cross-platform audio output/input interfaces (`I2SAudio`, `SDLAudio`). |
| `events` / `keys` | Neutral event definitions, key codes, modifier keys, and touch gestures. |
| `multimer` | Cross-platform timing primitives (explicit `Timer` providers, optional `auto`, `AsyncTimer`, and ticks). |
| `multimer` | One `machine.Timer`-shaped `Timer` on every host, with the clock, `sleep_ms`, `schedule`, `hold` and `report`. |
| `appdev` | Optional event traffic controller and input queue for applications using native PyDevices dispatch. |
| `display_driver` | LVGL coordinator bridging LVGL widgets to `displaydev` and `multimer`. |

Expand Down
27 changes: 6 additions & 21 deletions docs/board-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ exports hardware only:
```python
display_drv = AutoDisplay(...)
host_read = display_drv.get_events
timer_async = env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer)
```

An application opting into `appdev` then creates its own traffic controller:
Expand All @@ -187,31 +186,17 @@ app = appdev.App(board_config)

LVGL instead creates an independent coordinator in `display_driver`.

| Branch | `display_drv.requires_async_timer` | `timer_async` export |
|--------|-----------------------------------|-------------------------------|
| PyScript / Jupyter | `True` | `True` (default; **`PYDEVICES_TIMER_ASYNC=0` → appdev.App raises**) |
| PG/SDL desktop | `False` | `False` unless **`PYDEVICES_TIMER_ASYNC`** is set |

`appdev.App` rejects `timer_async=False` when any attached display has
`requires_async_timer` (PS/JN), so a forced sync override fails at construction
instead of hanging.

Panel size overrides (before `import board_config`): `PYDEVICES_WIDTH`,
`PYDEVICES_HEIGHT`, `PYDEVICES_ROTATION`, `PYDEVICES_SCALE`. Apps should read
geometry from `display_drv`, not module-level names on `board_config`.

Set the env var **before** `import board_config` (or any import that loads it).
Truthy: `1`, `true`, `yes`, `on`. Falsey: `0`, `false`, `no`, `off`. Unknown
values fall back to the desktop default (`False`). Parsing lives in
[`displaydev.env_bool`](https://github.com/PyDevices/pydevices/blob/main/lib/displaydev/__init__.py).

```bash
# Force asyncio timers on desktop (LVGL async smoke, matrix column)
PYDEVICES_TIMER_ASYNC=1 python my_example.py
```
Set the env vars **before** `import board_config` (or any import that loads
it). Parsing lives in
[`displaydev.env_int`](https://github.com/PyDevices/pydevices/blob/main/lib/displaydev/__init__.py)
and friends.

Per-board configs under `board_configs/` may export `timer_async`; they never
construct a app.
Per-board configs under `board_configs/` describe hardware; they never
construct an app.

## Custom config

Expand Down
8 changes: 2 additions & 6 deletions docs/displaydev.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ input source depends on what each platform exposes:

With appdev, handlers see the same `events` objects, so application code does
not need to know which backend is active. LVGL instead connects these neutral
backend capabilities through its own `display_driver` coordinator. Desktop board configs also use
`timer_async=env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer)`
(`requires_async_timer` is `True` only on PS/JN). `appdev.App` raises if
`timer_async=False` while any attached display has `requires_async_timer`.
backend capabilities through its own `display_driver` coordinator.

### Desktop (SDL2, PyGame)

Expand Down Expand Up @@ -124,7 +121,6 @@ display_drv = PSDisplay("display_canvas", width, height)
app = appdev.App(
displays=[display_drv],
host_read=display_drv.get_events,
timer_async=display_drv.requires_async_timer,
)
```

Expand Down Expand Up @@ -173,7 +169,7 @@ Anything you can draw on implements the framebuf API:
pydevices-examples does not include a task scheduler. Options:

- **`asyncio`** — works on CPython, MicroPython, and PyScript (required there)
- **[multimer](multimer.md)** — explicit or auto-selected `Timer` providers for sync loops; `AsyncTimer` for async/PyScript apps
- **[multimer](multimer.md)** — one `Timer` on every host; it rides the page's loop on PyScript

## Vertical scrolling

Expand Down
19 changes: 10 additions & 9 deletions docs/jupyter.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

PyDevices runs in JupyterLab, Jupyter Notebook, and VS Code / Cursor notebooks
through the **`JNDisplay`** backend. `displaydev.auto.AutoDisplay` detects the
notebook (`get_ipython()`) and selects it with `timer_async=True`.
notebook (`get_ipython()`) and selects it.

Board config: [`board_configs/jndisplay/board_config.py`](../board_configs/jndisplay/board_config.py). It exports the Jupyter
display and host reader; `appdev.App(board_config)`
Expand Down Expand Up @@ -54,8 +54,9 @@ See [Displays → how displays expose input](displaydev.md).
## Async execution model

The kernel already runs an `asyncio` loop, so a blocking poll loop would starve it
and never receive widget events. The Jupyter board config therefore exports
`timer_async=True` and the application coordinator consumes that preference.
and never receive widget events. `multimer` uses that loop as its wake source
in a notebook (`multimer.report()` in a cell says `source=asyncio`), so timers
armed in one cell keep firing between cells and nothing is configured.

Subscribe callbacks and the app keeps itself alive — the kernel's loop is the
host loop (`app.strategy == "ambient"`), so no trailing `app.run()` is needed.
Expand All @@ -66,18 +67,18 @@ Jupyter, `run_async` schedules `main` as a background task and returns
immediately (the cell finishes while the coroutine continues); on desktop or MCU
with no loop running yet it blocks via `asyncio.run`.

Custom wait-for-touch loops should import `asyncio` from `multimer` and
`await asyncio.sleep(0)` each iteration so the kernel can dispatch widget events
Custom wait-for-touch loops should `await multimer.asleep_ms(0)` (or the
loop's own sleep) each iteration so the kernel can dispatch widget events
between polls. See [App and board config](app-and-board-config.md) and [multimer](multimer.md).

## Stopping a running example

A task scheduled with `run_async` / `create_task` runs in the background on the
kernel loop, so the cell returns immediately and the square **Stop** button will
not interrupt it — use **Kernel → Restart**. Synchronous examples
(`timer_async` false) keep the cell running, and **Stop** raises
`KeyboardInterrupt` there; such examples should call `sleep_ms(1)` each iteration
so Stop can take effect.
not interrupt it — use **Kernel → Restart**. An example that blocks in its
own loop keeps the cell running, and **Stop** raises `KeyboardInterrupt`
there; such examples should call `multimer.sleep_ms(1)` each iteration so
Stop can take effect.

## VS Code / Cursor

Expand Down
Loading
Loading