Skip to content

gui carousel

Wouter Van de Wiele edited this page Aug 6, 2026 · 2 revisions

GUI & Carousel

The LCD UI is a carousel of screens ("GUI elements") that auto-advance on timers, plus overlay screens (menu sub-screens, games, alarm, quick access) that temporarily take over input and rendering. Everything runs in one dedicated FreeRTOS task owned by CarouselManager (singleton, lib/CAROUSEL_MANAGER/).

GuiElement interface

class GuiElement {
    virtual const char* name() const = 0;
    virtual bool ready() const;       // false → carousel skips / triggers fetch
    virtual bool fetching() const;    // true → show as "loading" for 2 s
    virtual bool manualOnly() const;  // true → never auto-advanced *to* (menu, games)
    virtual void fetch(unsigned long now);
    virtual void init(unsigned long now) = 0;   // (re)draw from scratch
    virtual bool tick(unsigned long now) = 0;   // one frame
    virtual void onButton(uint8_t button);
    virtual void onGesture(uint8_t gesture);
};

Registered carousel (from main.cpp)

# Element Dwell Notes
0 TimeElement 10 s Clock face (GuiTime)
1 WeatherElement 20 s Open-Meteo data, fetch-on-demand, animated rain
2 EyeElement 20 s Animated eye; forwards pet gestures to MoodManager
3 RadioElement 20 s Radio UI (STREAM) or "Disabled by webserver" (WEB)
4 JokesElement 120 s (self-paced) One joke per visit (setup → 5 s hold → punchline), then advances the carousel itself — see below
5 MenuElement 60 s idle fallback manualOnly — reached by pressing ◀/▶ past the edge, never by timer

NewsElement exists but is currently commented out of the carousel (src/main.cpp:608).

Screenshots (pixel-accurate renderings — see tools/lcd_render for how these are generated from the real draw-call sequences):

Time Weather (splash, no WiFi yet) Weather (screen 1/2) Weather (screen 2/2) Jokes

Eye — all 12 moods:

Neutral Happy Sad Excited Evil Angry Sleepy Surprised Confused Nervous Love Bored

Overlay elements registered outside the rotation: AlarmElement, QuickAccessElement, WifiSetupElement, IpQrElement, and the six games (launched from the menu).

Carousel task main loop

flowchart TD
    A[every ~1 ms tick] --> B[update status bar markers, 4 Hz]
    B --> C[backlight always-on override]
    C --> D[stream-sleep + power-off timers]
    D --> E{overridden?}
    E -- yes --> A
    E -- no --> F[poll ADC keys + touch]
    F --> G{alarm_ringing?}
    G -- yes --> H[activate AlarmElement overlay]
    G -- no --> I{game/overlay active?}
    I -- yes --> J[route tick+input to overlay only]
    I -- no --> K["current element tick()"]
    K --> L{"dwell expired & !manualOnly?"}
    L -- yes --> M["advance to next ready element"]
    M --> N{wrapped to element 0?}
    N -- yes --> O[idle-lap bookkeeping]
Loading

Advancing & readiness

advance() walks candidates in direction order and:

  • skips manualOnly elements unless the move was user-initiated,
  • calls fetch() on a not-ready element when WiFi is up, shows it as a 2 s "loading" screen while fetching(),
  • re-inits the chosen element (init() = full redraw) and resets its dwell.

override()/release() let non-carousel code (e.g. QR screens during boot flows) freeze the carousel and use the LCD directly; released time is credited back to the interrupted element's dwell.

Overlays ("games")

activateGame(el) routes all tick/input to the overlay until clearGame() (typically bound to CANCEL). The carousel keeps its own position and re-inits the underlying element on return. Used for: games, alarm screen, quick-access panel, WiFi setup, IP QR.

Input

Physical → logical keys (AdcKeys)

Five analog channels, two keys per channel (resistor ladder; 3-sample majority filter; hold auto-repeat):

ADC pin Key pair (low / high level)
GPIO 34 GEM_KEY_DOWN / GEM_KEY_UP
GPIO 35 GEM_KEY_LEFT / GEM_KEY_RIGHT
GPIO 36 GEM_KEY_OK / GEM_KEY_CANCEL
GPIO 37 GEM_KEY_FUNC_1_DOWN / GEM_KEY_FUNC_2_UP
GPIO 38 GEM_KEY_FUNC_3_LEFT / GEM_KEY_FUNC_4_RIGHT

Global bindings (handled by CarouselManager::handleButton)

Key Action Scope
FUNC_3_LEFT previous screen carousel only (swallowed by an active overlay/game)
FUNC_4_RIGHT next screen carousel only
FUNC_2_UP open Quick Access overlay carousel only
FUNC_1_DOWN panic stop — motors off + dance cleared, LEDs off, audio STOP works everywhere, even mid-game
everything else forwarded to the active element/overlay (onButton) resets the auto-advance timer

Any button also wakes the backlight and counts as activity for idle tracking.

Touch gestures

TouchDriver::tick() recognizes strokes across the interleaved pad strip (≥3 pad transitions, ≤5 s) as PetGesture::UP/DOWN and delivers them to the current element's onGesture(). Only EyeElement consumes them — it feeds MoodManager::notePetGesture() ("petting").

Status bar (LCD icon markers)

Refreshed at 4 Hz directly from the blackboard, drawn into the SED1530's separate icon RAM so they never fight the active element's framebuffer:

Marker Meaning
GLCD_MARKER_BATTERY blinks at 1 Hz while charging; solid when battery low (see the unit-mismatch caveat)
GLCD_MARKER_STAR charger connected (charging or charge-complete/standby)
GLCD_MARKER_ARROWS_CROSS WiFi connected

Backlight & idle-sleep escalation

CarouselManager::_updateIdleSleep() (called every task tick) tracks a single wall-clock _lastActivity timestamp — not carousel laps — reset by any button/touch input and by charger attach/detach (itself counted as activity, so plugging back in escapes an already-idle state without needing a button press too). backlight_always_on (menu: Settings → Backlight) forces the backlight on and keeps refreshing _lastActivity every tick, so none of the thresholds below ever fire while it's set.

Behavior then splits on dock state (charging or standby/charge-complete counts as docked — see Power for why: the hardware power latch can't cut power through a charger, so a docked shutdown routes into a software "protected mode" reboot loop instead of a true power-off):

State Backlight dims/off Stream stops Full shutdown
On battery off at 5 min idle — (the device shuts down instead, see next column) 15 min idle, extended to 4 h while the radio is actively playing
Docked dimmed at 5 min idle 3 h 55 min idle (device itself stays on) 8 h idle (DOCK_SLEEP_MS)

One additional independent timer:

  • Power-off countdown (power_off_min, menu: Power → Timer): fixed deadline from the moment of selection; activity does not cancel it; RAM-only so a reboot clears it.

(There is no separate "stream sleep" timer/field — audio_sleep_min doesn't exist in the Blackboard.)

Screen elements in detail

RadioElement (STREAM mode)

Three-row UI: station selector (browse locally with ◀/▶, * marks a pending selection, OK tunes), volume bar (◀/▶ in 5 % steps; below the floor stops playback; ▶ from stopped starts it), PLAY/STOP row. Top rows show WiFi RSSI + signal bars and the scrolling ICY track title. Auto-returns to the carousel after 60 s without interaction. In WEB boot mode it's a static "Disabled by webserver" screen.

Radio, STREAM mode Radio, WEB mode

JokesElement (lib/GUI_Jokes/jokes_element.cpp)

Tells one joke per visit, entirely offline: SETUP (word-wrapped setup text, vertically scrolling if it doesn't fit 5 lines) → HOLD (5 s pause) → PUNCHLINE, then it advances the carousel itself rather than waiting out its 120 s dwell — the registered duration is just a fallback in case something goes wrong mid-joke. Jokes are read from data/jokes/jokes.bin (baked in at build time by patches/apply_patches.py from official_joke_api's dataset — no network fetch at runtime), tracked with a per-joke-told bitset so the same one doesn't repeat until the set cycles. Inverted title bar reads "JOKE" / "..." / "!" depending on state.

Jokes screen

QuickAccessElement (global overlay, FUNC_2_UP)

Five rows — Volume (OK toggles play/stop), Motor on/off, LEDs (mode: Off/Auto/Manual), dance Move selector, LED FX selector. Values are read fresh from the blackboard on every open and saved to NVS on change. Enforces the same "dance only when motors enabled" gate as the menu.

Quick Menu overlay

MenuElement

A GEM-based hierarchical menu (5 items/page, small font). Structure:

Menu
├── Settings
│   ├── Acc WK (accel tap-to-wake toggle, NVS)
│   ├── Backlight (always-on override, NVS)
│   ├── Boot in (Web Radio / Web Srv, NVS, needs restart)
│   ├── WiFi Setup (QR-guided provisioning overlay)
│   ├── Show IP (QR of dashboard URL)
│   ├── Test (internal diagnostic overlay)
│   ├── Version
│   │   └── Date / Time / Branch / Commit / Tag / Dev ID (all read-only)
│   └── Forget WiFi (erase credentials, restart portal)
├── Effects
│   ├── Mot En (motors master toggle, NVS)
│   ├── PWM x (0.70-1.30 step 0.01, NVS)
│   └── Dance (None/14 moves, RAM-only, requires Mot En)
├── Mood
│   ├── Audio (mood sounds toggle, NVS)
│   ├── LED Mode (Off/Auto/Manual, NVS)
│   ├── Effect (None/21 effects/Slots 1-5, NVS)
│   ├── Brightness (0-100, NVS)
│   └── Eye Dim (0-100, NVS)
├── Time (hour/min/sec/day/month/year + Apply)
├── Alarm
│   ├── Enable (toggle)
│   ├── LED (same effect list as Mood → Effect)
│   ├── Move (same list as Effects → Dance)
│   ├── Sound (toggle)
│   ├── Station (saved radio stations)
│   ├── Backup (local sound clip if the station can't be reached)
│   └── Set Alarm (hour/minute + Apply)
├── Power
│   ├── Timer (None/15/30/45/60/90/120 min, RAM-only)
│   ├── Restart (esp_restart)
│   └── Shut Down
└── Games
    └── Dino Runner / Snake / Breakout / Connect 4 / Invaders / Hunt Wumpus

State shown in the menu is reloaded from the blackboard on every open so changes made in Quick Access or the dashboard are always reflected. The menu auto-exits to the carousel after 60 s idle; CANCEL on the root page exits immediately.

Screenshots — every page, including both screens of any page with more than 5 items (GEM shows a scrollbar on the right edge in that case):

Menu (1/2) Menu (2/2) Settings (1/2) Settings (2/2)
Version (1/2) Version (2/2) Effects Mood (1/2)
Mood (2/2) Time (1/2) Time (2/2) Alarm (1/2)
Alarm (2/2) Set Alarm Power Games (1/2)
Games (2/2)

AlarmElement

Activated automatically by the carousel when alarm_ringing is set: a 4-frame animation (rocking bell, alternating inverted frames) with the current time. Any key clears alarm_ringing and returns to the carousel.

Alarm, normal frame Alarm, inverted frame

WifiSetupElement / IpQrElement

QR-based provisioning flow — see Connectivity.

Rendering utilities (GUI_Utils)

  • LCD_W=100, LCD_H=48, CHAR_W=6, CHAR_H=8; global lcd pointer.
  • showSplash(line1, line2) — boot/status splash.
  • ScrollText — non-blocking pixel-smooth horizontal scroller (1 px / 80 ms), used for track titles (the News/RSS feature it could also serve is currently disabled — see Connectivity).

LED effects

Dispatched every loop() iteration while led_mode != LedMode::OFF && led_effect > 0 (main.cpp:1209) — this single check covers both Auto (MoodManager picks led_effect) and Manual (user picks it from the GEM/QuickAccess/dashboard "Effect" item); Manual's raw-color case (led_effect == 0) and Off are handled by a separate branch. LedDriver::beginFrame(effectId) detects effect changes, starts a 300 ms crossfade from the previous colors and resets shared animation state (prevents one effect's step counter leaking into the next).

# Name # Name # Name
1 Breath (rainbow chase) 8 Curious 15 Prowl
2 Blink 9 Angry 16 Twinkle
3 Purr 10 Happy 17 Police
4 Alert 11 Matrix 18 Sunrise
5 Sleep 12 Fire 19 Cozy
6 Scan 13 Thunder 20 Midnight
7 Heartbeat 14 Hypno 21 Disco
22–26 Saved color slots 1–5 (static per-LED colors, redrawn 1 Hz as self-heal)

Clone this wiki locally