Skip to content

data broker

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

Data Broker (Blackboard)

lib/DATA_BROKER/data_broker.h implements the one shared-state hub of the firmware: a singleton DataBroker holding a Blackboard struct behind a mutex, plus a per-topic dirty-flag word.

DataBroker::instance().update(Topic::LED, [](Blackboard& b) { b.led_mode = LedMode::MANUAL; });
Blackboard snap = DataBroker::instance().snapshot();     // full copy
LedMode mode = DataBroker::instance().read([](const Blackboard& b){ return b.led_mode; });
if (DataBroker::instance().consume(Topic::AUDIO)) { /* dirty — act on it */ }
API Semantics
update(topic, fn) Run fn(board) under the lock, mark topic dirty
updateSilent(topic, fn) Run fn(board) under the lock, without marking topic dirty — for mirroring authoritative driver state back into the board (e.g. re-publishing the actual volume/playing/station after handling a command) rather than signaling a new command. Using update() here would re-dirty the topic every write, and a consumer that itself calls update() on the same topic while handling its own dirty flag (e.g. main.cpp's AudioCmd dispatch) would perpetually re-trigger its own consume() every loop iteration — starving genuine concurrent writers of the quiet window they need to not be clobbered
snapshot() Copy of the whole board (lock held only for the copy)
read(fn) Evaluate fn(const board&) under the lock, return its result
consume(topic) Atomically test-and-clear the dirty bit — the "was there a new command?" primitive
isDirty(topic) Non-destructive dirty check

Topics

ACCEL, BATTERY, LDR, RTC_TIME, DANCE, LED, AUDIO, WIFI, POWER, GEO, EAR_FLASH

Topics serve two different styles of communication:

  1. Telemetry (ACCEL, BATTERY, LDR, RTC_TIME, WIFI, GEO): published periodically by loop()/WebFetcher; consumers just snapshot() whenever they render. Dirty bits are mostly ignored.
  2. Commands (LED, AUDIO, POWER, DANCE, EAR_FLASH): a producer (menu, quick access, radio screen, dashboard, carousel timers, game element) writes a command field and the dirty bit; loop() consume()s it and drives the hardware, then writes the resulting state back.

Command/handshake pattern (AUDIO example)

sequenceDiagram
    participant UI as Any UI (menu / radio / dashboard / quick access)
    participant BB as DataBroker
    participant L as loop() (core 1)
    participant AM as AudioManager ("audio_mgr" task, core 0)

    UI->>BB: update(AUDIO) { audio_cmd = PLAY, station_index = 3 }
    L->>BB: consume(AUDIO) == true
    L->>AM: playRadio(3) — blocking call, serialized via AudioManager::_guard
    L->>BB: updateSilent(AUDIO) { audio_cmd = NONE, mirror index/name/volume/playing }
    AM->>AM: "audio_mgr" task pumps the stream on its own cycle
Loading

The mirror-back write uses updateSilent, not update — a plain update() there would re-dirty Topic::AUDIO and make this same dispatch block re-trigger itself every subsequent loop() iteration (see main.cpp:314, 731, 1137).

audio_cmd values: NONE, PLAY, STOP, NEXT, PREV, RELOAD, VOLUME, PLAY_CLIP, PLAY_CLIP_FORCE. power_cmd: NONE, SHUT_DOWN.

Field reference (grouped)

Group Fields Producer → Consumer Persistence
Accelerometer accel_x/y/z, accel_temp loop 5 Hz → mood, dashboard RAM
Battery battery_voltage (mV), battery_raw_adc, battery_charging, battery_standby, battery_adc_2s/1min/5min loop 5 Hz → status bar, mood, diagnostics RAM
Light ldr_ohms (EMA), ambient_dark (hysteresis 20 kΩ/30 kΩ) loop 5 Hz → mood RAM
Time/alarm time, ntp_synced, ntp_last_sync_ago, alarm_enabled/hour/minute, alarm_ringing, alarm_led_effect (default 11=Matrix), alarm_dance_move (default 5=Twist), alarm_audio_enabled (default true), alarm_station_index, alarm_backup_sound (default 2=Calling) loop 1 Hz → time screen, carousel (alarm overlay); alarm-fire block plays effect/move/audio alarm_enabled/hour/minute live in the PCF8523; alarm_led_effect/dance_move/audio_enabled/station_index/backup_sound in NVS; alarm_ringing RAM-only
Motors motor_enabled (master gate), motor_pwm_multiplier (0.7–1.3 calibration scale, note the PWM output is inverted so <1.0 runs stronger), dance_move (0–14), motor_manual_control (dashboard teleop gate) menu/quick access/dashboard → loop motor_enabled and motor_pwm_multiplier in NVS; dance_move and motor_manual_control deliberately RAM-only (safety: nothing self-drives after a power cycle)
LEDs led_mode (LedMode::OFF/AUTO/MANUAL), led_effect (0 none, 1–21 animated, 22–26 saved slots), led_r/g/b, led_brightness, led_effect_brightness (30–100%, animated/saved-slot brightness only), led_eye_dim (0–100, extra knockdown for eye pixels during animated effects) menu/quick access/mood/dashboard → loop led_mode, led_effect_brightness, led_eye_dim in NVS; led_effect/led_r/g/b/led_brightness RAM-only
Audio audio_cmd, audio_station_index/name, audio_playing, audio_volume (0.4–1.0 driver scale), audio_track_title (ICY metadata), audio_clip_sound (AudioManager::Sound id for PLAY_CLIP/PLAY_CLIP_FORCE), mood_sounds_enabled (master gate for mood-triggered clips) UIs → loop → driver; driver state mirrored back via updateSilent station/volume/playing debounced into NVS; mood_sounds_enabled in NVS
WiFi wifi_connected, wifi_ip, wifi_ssid, wifi_rssi loop 5 Hz RAM
Geo geo_valid, geo_lat/lon, city/country/cc/region/zip/timezone/offset WebFetcher (once per boot) → weather, time weather cache blob in NVS
Power power_cmd, accel_wake_enabled, backlight_always_on, power_off_min + power_off_start menu/carousel → loop accel wake, backlight in NVS; power-off countdown RAM-only
Ear flash ear_flash_r/g/b, ear_flash_ms GameElement (win/lose feedback) → loop/LedDriver, consumed once via db.consume() RAM (one-shot request, not persistent state)

LED effect encoding

led_effect shares one integer namespace between animated effects and saved color slots so every selector UI (menu, quick access, dashboard) can cycle a single range:

  • 0 — none (static led_r/g/b color shows instead)
  • 1–21 — animated effects (see LedDriver, GUI & Carousel)
  • LED_EFFECT_SLOT_BASE (22) … 26 — saved color slots 1–5 (per-LED colors from NVS)

Locking rules (critical)

The broker mutex is held only inside update/updateSilent/read/snapshot/consume. Two hard rules keep the system deadlock-free:

  1. Never call a potentially blocking driver function from inside a broker lambda. The canonical incident (originally against the now-dead AudioWebDriver, same rule applies to today's AudioManager): a driver accessor took the audio _guard, whose owner (the audio task) calls the metadata callback, which takes the broker lock. Calling such an accessor inside db.update(...) produced an ABBA deadlock that froze the GUI, LEDs and shutdown. Hence:
  2. AudioManager state accessors are lock-free atomics on purpose. Fetch driver state before entering db.update(...) (see the comment block in main.cpp's AUDIO consumer).

Other synchronization in the system, for context:

Lock Protects Shared between
DataBroker::_mtx blackboard all tasks
DriverBase::i2c_operations (one global) every I2C transaction all tasks
NvsStore::_mtx (recursive) NVS access loop, httpd, carousel
TimeManager::_mtx (recursive) time/NTP/TZ state loop, httpd
LedDriver::_stripMutex WS2812 buffer writes via set() loop, httpd (live pixel edit)
AudioManager::_guard (FreeRTOS sem) the shared radio/clip audio pipeline loop()'s AUDIO dispatch and the "audio_mgr" task

Clone this wiki locally