-
Notifications
You must be signed in to change notification settings - Fork 0
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/).
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);
};| # | 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:

Overlay elements registered outside the rotation: AlarmElement,
QuickAccessElement, WifiSetupElement, IpQrElement, and the six games
(launched from the menu).
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]
advance() walks candidates in direction order and:
- skips
manualOnlyelements 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 whilefetching(), - 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.
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.
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
|
| 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.
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").
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 |
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.)
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.

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.
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.
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) |
|---|
![]() |
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.

QR-based provisioning flow — see Connectivity.
-
LCD_W=100, LCD_H=48, CHAR_W=6, CHAR_H=8; globallcdpointer. -
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).
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) |




















