System Architecture
This page is the map for the whole masterclass. Read it once for the shape of the system; every box on it has its own deep-dive chapter.
The platform
Safi runs on an ESP32-S3: two Xtensa LX7 cores at 240 MHz, about 400 KB of usable internal SRAM, 16 MB of flash, and 8 MB of external PSRAM. There is no operating system in the desktop sense; the firmware ships its own scheduler (FreeRTOS, bundled with Espressif's ESP-IDF framework) and every byte of the system, from the TCP stack to the Arabic text shaper, is part of one statically linked program.
Two numbers define the engineering style of everything that follows:
- Internal RAM is scarce. After WiFi, TLS, LVGL, and the audio pipeline claim their working sets, tens of kilobytes remain. Chapters keep returning to the question "which RAM does this live in?" because on this chip that question decides whether a feature exists.
- PSRAM is plentiful but second class. 8 MB, but slower, unreachable by most DMA engines, and forbidden for anything a FreeRTOS primitive or an interrupt touches. The rules are formalized in tasks and memory.
The component layout
The firmware is split into eleven ESP-IDF components (self-contained modules with a public header and a private implementation) plus the main application glue:
| Component | Responsibility | Deep dive |
|---|---|---|
event_bus | Publish/subscribe backbone between all components | Event bus |
audio_player | Decode pipeline, I2S output, seek, resume | Audio engine |
stream_client | HTTP(S) radio streams, buffering, reconnection | Streaming |
display_driver | ST7735S or ST7789 panel, LVGL 9, screens, Arabic shaping | Display stack |
sd_manager | Card mount, library scan, path resolution | SD and the shared bus |
state_manager | NVS settings, favorites, scheduler, SNTP, boot report | State |
web_server | HTTPS server, REST API, WebSocket, dashboard files | HTTPS, REST |
rfid_manager | PN532 NFC scanning and the card database | NFC |
input_handler | Rotary encoder and touch | Input |
power_manager | Battery monitoring and safe shutdown | Power |
rtc_ds3231 | DS3231 real-time clock on the shared I2C bus | RTC |
main/ owns boot order (system_init.c), WiFi management (wifi_manager.c), the mapping from input events to player commands (event_handlers.c), and the USB serial console.
Why event-driven
The central design decision is that components never call each other's functions to announce things. When the NFC reader sees a card, it does not call the audio player; it publishes EVENT_NFC_CARD_DETECTED on the event bus, and whoever cares subscribes. The alternatives were considered and rejected:
- Direct calls create a dependency web: the NFC component would need to know about audio, display, and the WebSocket forwarder, and every new consumer means editing the producer. With the bus, adding a subscriber touches one file.
- A central dispatcher (one god-module routing everything) concentrates the coupling instead of removing it, and grows a switch statement forever.
- ESP-IDF's own
esp_eventloop was the serious alternative; the reasons a purpose-built bus won anyway (typed enum, synchronous option, bounded memory, testability on target) are detailed in the event bus chapter.
The payoff shows up everywhere: the WebSocket layer mirrors device state to browsers purely by subscribing to events; the display renders whatever the system announces without any component knowing a display exists; and the entire input system can be exercised in tests by publishing synthetic events.
Task topology
Concurrency is organized as roughly a dozen FreeRTOS tasks. The ones that matter for understanding the system:
| Task | Priority | Core | Role |
|---|---|---|---|
audio_task | 10 | 0 | Decode loop; the only task with realtime deadlines |
stream_worker | 9 | any | Network reads for radio, feeds the ring buffer |
taskLVGL | 4 | 1 | LVGL rendering and flushes |
display_task | 5 | any | Event-to-UI bridge, backlight policy |
event_task | 5 | any | Delivers asynchronous bus events |
httpd | 5 | any | HTTPS server (requests and WebSocket) |
wifi, tiT (lwIP) | 23, 18 | 0 | Radio driver and TCP/IP, owned by ESP-IDF |
battery_mon | 3 | any | Slow ADC sampling loop |
Two placement decisions carry most of the weight: audio decoding is pinned to core 0 with a priority above every application task because a missed audio deadline is audible while a late screen repaint is not; LVGL is pinned to core 1 so a long render never contends with the decoder. The full reasoning, including the memory placement rules and a hard-won lesson about stack sizing, is in tasks and memory.
Boot sequence
Boot is deliberately failure-tolerant: every peripheral initialization returns an error instead of aborting, failures land in the boot report, and the system continues with whatever hardware works. A Quran player with a dead NFC module should still play Quran.
Data flow example: one tap on an NFC card
To see all the layers cooperate, trace a single card tap:
rfid_manager's scan loop reads a UID over I2C and publishesEVENT_NFC_CARD_DETECTED.main/event_handlers.creceives it, looks the card up in the database, and callsaudio_player_play_file()(a command, not an event; the distinction is deliberate: state changes are commanded by one owner, facts are broadcast).audio_playerqueues the command toaudio_task, which opens the file viasd_manager, runs the decode loop, and publishesEVENT_AUDIO_PLAY_START.display_taskcatches the event and switches the screen to Now Playing;websocket_handlercatches it too and pushes a JSON message to every connected browser.- Thirty seconds later,
audio_playerquietly persists the playback position throughstate_managerso a power cut resumes correctly.
Five components cooperated; none of them knows more than one neighbor.
Where to go next
Read getting started to build the firmware, then tasks and memory and the event bus: those two are assumed background for every other chapter. After that, chapters are independent; follow whatever subsystem interests you.