Skip to main content

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:

Safi system architecture
ComponentResponsibilityDeep dive
event_busPublish/subscribe backbone between all componentsEvent bus
audio_playerDecode pipeline, I2S output, seek, resumeAudio engine
stream_clientHTTP(S) radio streams, buffering, reconnectionStreaming
display_driverST7735S or ST7789 panel, LVGL 9, screens, Arabic shapingDisplay stack
sd_managerCard mount, library scan, path resolutionSD and the shared bus
state_managerNVS settings, favorites, scheduler, SNTP, boot reportState
web_serverHTTPS server, REST API, WebSocket, dashboard filesHTTPS, REST
rfid_managerPN532 NFC scanning and the card databaseNFC
input_handlerRotary encoder and touchInput
power_managerBattery monitoring and safe shutdownPower
rtc_ds3231DS3231 real-time clock on the shared I2C busRTC

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_event loop 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:

TaskPriorityCoreRole
audio_task100Decode loop; the only task with realtime deadlines
stream_worker9anyNetwork reads for radio, feeds the ring buffer
taskLVGL41LVGL rendering and flushes
display_task5anyEvent-to-UI bridge, backlight policy
event_task5anyDelivers asynchronous bus events
httpd5anyHTTPS server (requests and WebSocket)
wifi, tiT (lwIP)23, 180Radio driver and TCP/IP, owned by ESP-IDF
battery_mon3anySlow 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:

  1. rfid_manager's scan loop reads a UID over I2C and publishes EVENT_NFC_CARD_DETECTED.
  2. main/event_handlers.c receives it, looks the card up in the database, and calls audio_player_play_file() (a command, not an event; the distinction is deliberate: state changes are commanded by one owner, facts are broadcast).
  3. audio_player queues the command to audio_task, which opens the file via sd_manager, runs the decode loop, and publishes EVENT_AUDIO_PLAY_START.
  4. display_task catches the event and switches the screen to Now Playing; websocket_handler catches it too and pushes a JSON message to every connected browser.
  5. Thirty seconds later, audio_player quietly persists the playback position through state_manager so 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.