New examples/companion_radio/ui-lvgl/ over the shared UI Core (env Wio_Tracker_L2_companion_solo_lvgl, LVGL 9.2.2, pool in PSRAM): status bar, home (clock, unread), conversation list (channels + recent DMs with unread badges), contact picker, conversation view with bubbles, delivery / relay markers and on-screen keyboard, toasts for Core events, display auto-off with wake on touch / button. Board glue (LovyanGFX flush + GT911 touch) isolated in LvglPort.h; design tokens in Theme.h. UiCore gains the first frontend actions, sendDirectText() and sendChannelText(), and drives DM auto-resend + unread reconcile from its own loop (removed from ui-new's loop). MessageHistory exposes newest-first DM ring iteration and a public contactByPrefix(). Verified on L2 hardware: navigation, send/receive, sleep/wake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 KiB
UI Core + frontends — design
Status: 📋 draft for review (2026-09-24). Branch: wio-tracker-l2.
Why
The Wio Tracker L2 (320×240 colour touch LCD, no joystick) gets a new LVGL 9
interface. Rather than a second, parallel copy of everything Solo does, the
application logic moves out of ui-new into a hardware-independent UI Core
that every frontend shares:
| Frontend | Devices | Toolkit | Input |
|---|---|---|---|
ui-lvgl (rich) |
Wio Tracker L2 first; later other ESP32-S3 colour boards | LVGL 9 | pointer (touch) + focus (buttons, CardKB) |
ui-new (lite) |
OLED / e-ink / nRF52 (Wio L1, T-Echo Lite, Heltec, …) | DisplayDriver |
focus (joystick, buttons, CardKB) |
A feature written once in the Core (live share, locator, unread tracking, a new
setting) then shows up on both. ui-new is not rewritten: it is re-pointed at
the Core and stays the lite frontend.
Non-goals: changing the mesh protocol, MyMesh, persistence formats
(NodePrefs layout, /scopes1, trail files), or the companion app protocol.
Current state
ui-new is 18.5k lines. UITask implements AbstractUITask (the interface
MyMesh calls into) and hosts the screens, so logic and drawing are
interleaved. Classified:
Models — already UI-free, move as-is
MessageHistory.h— channel (48) / DM (32) rings, unread counters + overflow flags. ✅ moved toui-core/.Trail.h/TrailStore, waypoints (WaypointsViewstorage half),ScopeList.h.
Engines — logic living inside UITask.cpp / UITask.h
- Unread tracking — DM unread table ✅ (
ui-core/DmUnreadTable.h), room unread (still inUITask). - Notifications ✅ split: the Core decides what happened (
MessageArrivedwith kind / DM sender / channel slot,AdvertHeard); the frontend decides how to show it (showAlert,SoundNotifier, vibration, LED, wake-on-message) — those are platform services. - Live share ✅
ui-core/LiveShareEngine.h— session timer, movement/heartbeat gate, send + scope guard, peers'LiveTrackStore+ expiry. - Locator ✅
ui-core/LocatorEngine.h— active target, geofence state machine, proximity beeper. - Trail ✅
ui-core/TrailEngine.h— store, sampling, auto-pause, low-battery auto-save. - Course over ground + current position ✅
ui-core/CourseEngine.h. - Clock tools — alarm / countdown / ring ✅
ui-core/ClockEngine.h. - Ping ✅
ui-core/PingEngine.h(timeout still decided byNearbyScreen). - Device controls — GPS on/off, GPIO (
setGpioMode, bot GPIO), buzzer mode/volume, brightness, radio apply (applyTxPower,applyApc,applyRadioParams, …). - Bot hooks —
botSetGPS,botBuzz,botSetGPIO, …
Screen-embedded logic (harder to extract)
SettingsScreen.h(1.3k lines) — ~40 items, each with inline get/format/step/apply code.NearbyScreen.h— contact discovery/sorting;AdminScreen.h— repeater admin session;BotScreen.h;RepeaterScreen.h;MessagesScreen.h(2.6k lines) — list ordering, favourites, context-menu actions mixed with rendering.
Pure view / widgets — stay in ui-new
- Rendering of every screen,
KeyboardWidget,PopupMenu,AccordionList,TabBar,icons.h,GfxUtils.h, marquee, badges.
Architecture
MyMesh ──(AbstractUITask callbacks)──► UiCore ──events──► Frontend (ui-new | ui-lvgl)
▲ │ ▲ │
└──────────── mesh actions ────────────┘ └──── actions ─────┘
engines, models, settings schema
Platform services (per board): Sound, DisplayPower, Input sources, Led
1. UiCore owns the mesh-facing interface
MyMesh talks to the UI only through MyMesh::Listener (set with
setListener()), ported from upstream's "Abstract UI overhaul" (PR #3431 and
follow-ups 3caf033d, 5c3d9281, 30dd723c, b3b17025, 64434c53) with
identical names/signatures. A second block in the same interface holds this
fork's extensions (own-send mirroring, relay echoes, room login/admin replies,
[LOC] shares, contact/channel removal, bot device actions,
requestShutdown), each defaulting to a no-op.
For ui-new, UiCore is the Listener (UITaskBase::meshListener() hands it
to MyMesh::setListener()): it applies the display filter, labels room posts,
files history, keeps unread counters, runs the engines and emits events. The
frontend implements UiCoreHost (ui-core/UiCoreHost.h) for what the Core
still asks of it synchronously: "is this conversation on screen", "keep the
selection after an insert", and forwarding for not-yet-extracted parts (room
login / admin sessions, bot device actions, prefs cleanup on contact/channel
removal, shutdown). ui-orig / ui-tiny keep AbstractUITask, which is
UITaskBase + the Listener glue, unchanged. This is the single point that
makes both frontends receive identical behaviour.
2. Engines
One class per engine from the inventory above, each with begin(prefs, …),
loop(now_ms) and a small public API. No drawing, no DisplayDriver, no
screen pointers. Engines that need to show something emit an event
(AlertRequested{text, ms}, LocShareEnded, AlarmFired, …) instead of
calling showAlert() directly.
Constraints (the lite frontend runs on nRF52 with ~215 KB flash / ~65 KB RAM
free): no heap allocation, no RTTI/exceptions, no STL containers, fixed-size
buffers — the same rules ui-new follows today. Extraction must be roughly
size-neutral on WioTrackerL1_companion_solo_dual.
3. Events: Core → frontend
A small fixed-size queue of tagged events plus coarse dirty flags
(DIRTY_MESSAGES, DIRTY_CONTACTS, DIRTY_STATUS, …). The frontend drains
the queue in its loop() and redraws what is dirty. No callbacks into the
frontend from inside mesh processing — which also keeps the e-ink busy-wait
pump rule ("never re-enter UI code from the radio path") trivially true.
4. Actions: frontend → Core
Plain methods on the Core facade: sendDM, sendChannelMsg, markRead,
setLiveShare, setLocatorTarget, toggleGps, startPing, … Both frontends
call the same ones, so e.g. "marking a channel read" has one implementation.
5. Declarative settings schema
Replaces the per-item code in SettingsScreen:
struct SettingDef {
const char* id; // stable key, also used for search / CLI
const char* label;
uint8_t section; // Radio / System / Display / …
SettingType type; // Bool, Enum, Int, Text, Action
// accessors over NodePrefs; enum labels; min/max/step
int (*get)(const NodePrefs&);
void (*set)(NodePrefs&, int);
const char* (*label_for)(int v); // Enum/Int formatting
bool (*visible)(const NodePrefs&); // build- or state-dependent rows
bool (*locked)(const NodePrefs&, const char** why); // "Off while repeating"
void (*apply)(UiCore&); // side effect after set (radio, display…)
};
ui-new renders it as today's accordion list; ui-lvgl as switches, dropdowns
and sliders. Build-time gating (FEAT_*) stays in the table via #if, so a
hidden row costs no flash.
6. Platform services
Per-board implementations behind small interfaces: Sound (buzzer PWM today;
I2S ES8311 tone generator on L2), DisplayPower (auto-off, brightness), Led,
and input sources. Input reaches frontends in two forms: focus keys (the
existing KEY_* codes — joystick, buttons, CardKB) and pointer events
(down/move/up with coordinates — touch). LVGL consumes both natively; ui-new
consumes keys only.
ui-lvgl specifics
- LVGL 9 (pinned), memory in a PSRAM pool; partial-render buffers in internal RAM, flush over QSPI via LovyanGFX.
- Theme tokens (colours, spacing, typography) in one place — the first chance to realise the "Amber Trace" direction in colour.
- Fonts with full Latin/Polish/Cyrillic coverage.
- Runs in the WASM sim at 320×240 with mouse as touch, so most UI work needs no flashing.
- PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted.
Migration plan
Each step keeps WioTrackerL1_companion_solo_dual behaviour identical and is
checked in the sim plus on L1 hardware before the next one.
- Listener boundary ✅ (upstream
MyMesh::Listenerported;MyMeshhas no UI calls left). - Skeleton ✅.
examples/companion_radio/ui-core/withUiCore.h(facade),MessageHistory.h,DmUnreadTable.h. Header-only for now, reached fromui-new/UITask.cppby relative include, so none of the 66 variantplatformio.inifiles that buildui-newchange.UITaskheap-allocates oneUiCoreinbegin()(before the screens, asMessagesScreenused to own the history on the heap);MessagesScreenbinds tocore().historyby reference. When the Core grows real.cppfiles, compile them through a unity.cppinside each frontend directory. - Engines, one per commit. Clock tools ✅ (also introduced
ui-core/UiEvents.h, the Core → frontend event queue;UITask::tickCore()runsUiCore::loop()and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator ✅ → trail ✅ → notifications ✅ (with step 3).UITaskshrinks to screen management + drawing. - Flip the interface ✅.
UiCoreisMyMesh::Listener;ui-new'sUITaskderivesUITaskBase+UiCoreHostand is fed by events, drained at the start (mesh-originated) and end (engines) of itsloop(). Mesh callbacks no longer touch the display or buzzer directly. - Settings schema. Convert
SettingsScreensection by section. ui-lvglskeleton ✅ on L2 hardware (envWio_Tracker_L2_companion_solo_lvgl, LVGL 9.2.2): status bar, home, conversation list, contact picker, conversation with keyboard, toasts, display sleep/wake. The Core gained the first actions (sendDirectText,sendChannelText) and runs DM resends itself. Still to do: sim target at 320×240, a font with Polish/Cyrillic coverage, then screens by priority.- Contacts/Nearby, Admin, Bot logic extraction as the LVGL screens for them are built.
Decisions
- Upstream merge cost accepted (2026-09-24). Moving code out of
ui-newwill conflict with upstream edits to the same files; this fork'sui-newis already heavily diverged, and upstream is itself working toward a larger UI abstraction — revisit alignment when that lands. - Scope is UI only (2026-09-24). The settings schema and event queue serve the on-device frontends; CLI and companion-app settings/push paths are untouched for now.