The Event Bus
The event bus is the smallest component in the tree and the one every other chapter leans on. This page explains the pattern from zero, walks the implementation, and defends the design choices.
The concept, from zero
A publish/subscribe bus decouples the component that knows something happened from the components that care. Producers call:
event_bus_publish(EVENT_NFC_CARD_DETECTED, &uid, sizeof(uid));
Consumers register interest once:
event_bus_subscribe(EVENT_NFC_CARD_DETECTED, on_card, NULL);
The bus keeps a table of subscribers per event type and invokes each callback with the event and its payload. Producers compile without knowing a single consumer exists; consumers appear and disappear without touching producers. In a system where a card tap must reach the audio player, the display, and every open browser tab, this is the difference between three hardwired calls in the NFC driver and zero.
The API surface
esp_err_t event_bus_init(void);
esp_err_t event_bus_subscribe(safi_event_type_t type, event_callback_t cb, void *user_data);
esp_err_t event_bus_unsubscribe(safi_event_type_t type, event_callback_t cb);
esp_err_t event_bus_publish(safi_event_type_t type, const void *data, size_t len);
esp_err_t event_bus_publish_async(safi_event_type_t type, const void *data, size_t len);
const char *event_bus_get_event_name(safi_event_type_t type);
uint32_t event_bus_get_dropped_count(void);
Thirty-one event types cover the system, from EVENT_AUDIO_PLAY_START to EVENT_TIME_SYNCED; the events reference tables all of them with payloads and typical publishers.
Sync versus async, the load-bearing decision
The bus offers two delivery modes, and choosing correctly at each call site is most of the skill in using it:
event_bus_publish | event_bus_publish_async | |
|---|---|---|
| Callbacks run | Immediately, on the caller's task and stack | Later, on the dedicated event_task |
| Payload | Borrowed for the duration of the call | Copied to the heap, freed after delivery |
| Caller blocks | Until every subscriber returns | Only to enqueue |
| Ordering | Strict with respect to the caller | Strict among async events |
| Use when | The publisher must know the world saw it before proceeding (shutdown) | The publisher has realtime work to return to (almost everything) |
The audio task announcing EVENT_AUDIO_PLAY_START uses async: it must get back to feeding the DAC, and it cannot know how long a subscriber (say, the WebSocket layer serializing JSON for three browsers) will take. The battery monitor announcing EVENT_SYSTEM_SHUTDOWN uses sync: the whole point is that subscribers finish saving state before the power goes.
The async payload copy is not an implementation detail, it is the contract that makes async safe: the publisher's buffer may be a stack variable that is gone microseconds later. The copy is heap-allocated per event and freed by event_task after the last subscriber returns.
Implementation walkthrough
The whole bus is one array and one lock:
- A fixed table:
MAX_SUBSCRIBERS_PER_EVENT(12) slots for each of the 31 event types. No dynamic allocation on the subscribe path, so subscription cannot fail at 2 a.m. from heap fragmentation, and the memory cost is known at link time. - A recursive mutex guards the table. Recursive, because a subscriber callback (running under the lock during synchronous delivery) may legitimately publish another event; with a plain mutex that inner publish would deadlock against the outer one. This is the classic reentrancy trap of synchronous buses, solved structurally rather than by documentation.
- Async delivery is a small queue plus
event_task. If the queue is full, the publish is counted and dropped, never blocked:event_bus_get_dropped_count()exposes the counter, and a nonzero value in the field is a diagnosis (some subscriber is slow), not a mystery. Blocking the publisher was rejected because the publisher might be the audio task, and trading a lost telemetry event for an audio glitch is a bad deal. - Subscribing past 12 slots returns
ESP_ERR_NO_MEMloudly and is asserted in tests. The number is a policy: if an event needs a thirteenth subscriber, that is a design review, not a config bump.
Why not esp_event
ESP-IDF ships a perfectly good event loop (esp_event), and Safi still uses it where the SDK demands it (WiFi and IP stack events). For the application bus, a bespoke ~200 line component won on four counts:
- A single closed enum.
esp_eventkeys events by (base, id) pairs registered at runtime; typos compile. Safi'ssafi_event_type_tis one enum, so the compiler enforces the catalog and a static assert pins the name table to it. - A synchronous mode.
esp_eventis post-to-a-loop only. The shutdown path genuinely needs run-to-completion semantics, and simulating them on top of an async loop (post, then wait on a semaphore each subscriber must remember to give) is strictly worse than having a sync call. - Bounded, visible memory. The static table plus one queue is auditable at a glance;
esp_eventallocates handler nodes on the heap. - Trivially testable. The bus runs on the target in the unit test app with zero mocking; the overflow behavior and the async copy semantics are asserted directly (see testing).
The cost is owning the code, which is small enough to read in one sitting. That trade goes the other way for big frameworks; it is the right trade here precisely because the bus is tiny.
Rules of use
- Events are facts, not commands.
EVENT_AUDIO_PLAY_STARTmeans "playback started", published by the player after the fact. Telling the player to start is a function call (audio_player_play_file), because commands need an owner, an error path, and exactly-once semantics that broadcast cannot give. - Callbacks must be quick and must not block on the publisher's resources. A synchronous callback runs on the publisher's stack; a slow one stalls the publisher, and one that takes a lock the publisher holds deadlocks. Long work belongs behind a queue in the subscriber's own task (this is exactly how
display_taskconsumes events). - Payloads are value types. Publish copies of data, never pointers to live structures; async delivery outlives the publisher's stack frame by design.