Technical documentation for developers and contributors.
- Dual-Core Architecture
- Configuration Constants
- Complete Pipeline Flow
- Design Rationale
- Memory Allocation
- State Machine
- Module Dependencies
SpojBoard utilizes both cores of the ESP32-S3 for optimal performance using FreeRTOS tasks.
┌─────────────────────────────────────────────────┐
│ CORE 0 (WiFi Network Stack) │
├─────────────────────────────────────────────────┤
│ WiFi interrupt handlers (sub-ms response) │
│ LwIP TCP/IP stack │
│ NO application tasks │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ CORE 1 (Application Tasks) │
├─────────────────────────────────────────────────┤
│ displayRenderTask() [Priority 2] │
│ Waits for notification, copies data via │
│ mutex, renders to HUB75 (~100ms) │
│ │
│ apiFetchTask() [Priority 1] │
│ Handles blocking HTTP calls (200-2000ms) │
│ Updates departures via mutex, sleeps 100ms │
│ │
│ Arduino loop() [Priority 1] │
│ Web server, ETA recalculation, state mgmt │
└─────────────────────────────────────────────────┘
Two mutexes protect shared data with short lock durations (~1ms):
displayMutex- ProtectsDisplayUpdateRequeststruct (display task <-> loop)apiDataMutex- Protectsdepartures[]array and weather data (API task <-> loop)
Data snapshot pattern: Data is copied under mutex, then processed without locks. Rendering and HTTP calls never hold a mutex.
Before (single-threaded): API calls (1-2s) blocked the web server and display updates.
After (multi-task):
- Web server stays responsive during API calls
- Display updates queued in <1ms via
signalDisplayUpdate() - WiFi interrupts on Core 0 never blocked by application code
- Display task (Priority 2) preempts API task (Priority 1) when needed
The DisplayController class acts as a state machine that determines what to display, delegating the how to DisplayManager:
DisplayController (decides what to show)
↓ calls appropriate method
DisplayManager (renders to LED matrix)
↓ uses
HUB75 Hardware
Priority-based state evaluation:
- Demo mode (highest) - custom sample departures
- Rest mode - display off
- AP mode - WiFi setup credentials
- WiFi connecting - connection status
- Setup required - web UI address
- API error - error message
- No departures - info message
- Normal operation (lowest) - real departures
MAX_DEPARTURES = 12(DepartureData.h:10) - Maximum cache size (hardcoded)MAX_TEMP_DEPARTURES = 144(GolemioAPI/BvgAPI) - Collection buffer size (12 stops × 12 departures)config.numDepartures- User setting for display rows (1-3 only)
Important: config.numDepartures only controls how many rows to show on the LED matrix (1-3), not API fetch size. Both transit APIs (Prague Golemio and Berlin BVG) always fetch MAX_DEPARTURES (12) per stop for better caching and sorting. This simplifies the user experience - users don't need to understand API response sizes.
┌──────────────────────────────────────────────────────────────────┐
│ 1. USER CONFIGURATION │
│ config.city = "Prague" or "Berlin" # Transit city selection │
│ config.numDepartures = 2 # Show 2 rows on display │
│ config.pragueStopIds = "A,B" # Query 2 Prague stops │
│ config.berlinStopIds = "X,Y" # Query 2 Berlin stops │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 2. API QUERIES (Always fetch MAX_DEPARTURES = 12 per stop) │
│ TransitAPI::fetchDepartures() (GolemioAPI or BvgAPI) │
│ loops through stops: │
│ - Stop A: API call → 12 departures → tempDepartures[0-11] │
│ - delay(1000) # 1-second rate limiting │
│ - Stop B: API call → 12 departures → tempDepartures[12-23] │
│ Total collected: 24 departures in temporary buffer │
│ Buffer capacity: 144 (supports up to 12 stops) │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 3. SORT BY ETA (GolemioAPI.cpp:71) │
│ qsort(tempDepartures, 24, ..., compareDepartures) │
│ All departures sorted by increasing ETA across all stops │
│ Example sorted result: │
│ [0] = Stop B, Line 7, ETA 2min │
│ [1] = Stop A, Line 31, ETA 5min │
│ [2] = Stop B, Line A, ETA 8min │
│ ... (21 more) │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 4. COPY TO CACHE (GolemioAPI.cpp:81-88) │
│ Copy top MAX_DEPARTURES (12) from sorted temp to cache: │
│ for (i = 0; i < tempCount && count < MAX_DEPARTURES; i++) │
│ result.departures[count++] = tempDepartures[i]; │
│ Result: │
│ - result.departures[12] = top 12 soonest departures │
│ - result.departureCount = 12 │
│ - Each departure includes departureTime (Unix timestamp) │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 5. MAIN LOOP STORAGE (main.cpp) │
│ Global cache in main.cpp: │
│ - Departure departures[MAX_DEPARTURES] = cached results │
│ - int departureCount = number of valid departures │
│ Cache persists between API calls for ETA recalculation │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 6. REAL-TIME ETA UPDATES (Every 10 seconds, main.cpp) │
│ recalculateETAs(): │
│ - For each departure in cache: │
│ eta = calculateETA(departure.departureTime) │
│ - Remove stale departures (ETA < 0 or invalid) │
│ - No API call needed - uses cached timestamps │
│ This keeps display fresh without hammering the API! │
└──────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────┐
│ 7. DISPLAY RENDERING (DisplayManager.cpp:345-414) │
│ updateDisplay(..., departures, departureCount, numToDisplay) │
│ - rowsToDraw = min(departureCount, numToDisplay, 3) │
│ - rowsToDraw = min(12, 2, 3) = 2 │
│ - for (i = 0; i < 2; i++): drawDeparture(i, departures[i]) │
│ Only first 2 departures shown on LED matrix (user setting) │
│ Physical maximum is 3 rows (128×32 display = 4 rows total, │
│ with row 4 reserved for date/time status bar) │
└──────────────────────────────────────────────────────────────────┘
- Ensures good caching regardless of display setting
- Simplifies API logic - no user-dependent behavior
- Better sorting with more data points
- Users don't need to understand API response sizes
- Supports up to 12 stops × 12 departures = 144 total
- Prevents data loss when querying multiple stops
- Memory cost: ~7KB (acceptable on ESP32 with ~200KB free)
- Keeps "best" 12 departures after sorting
- Reasonable memory usage (~600 bytes)
- More departures than can be displayed (3) for filtering flexibility
- Maps directly to physical LED matrix rows
- Simple to understand: "How many rows to show?"
- No technical knowledge required
- Keeps display fresh without API calls
- Allows longer refresh intervals (up to 300s) to reduce load
- Filters out stale departures automatically
-
Temp Buffer:
static Departure tempDepartures[144](~7KB)- Function-local static to avoid stack overflow
- Located in
GolemioAPI::fetchDepartures()andBvgAPI::fetchDepartures() - Allocated once at compile time
-
Cache:
Departure departures[12](~600 bytes)- Global in
main.cpp - Persists between API calls
- Used for ETA recalculation
- Global in
-
Display: No departure storage
- Receives pointer to cache
- Zero memory overhead
Total: ~8KB for departure data structures
- JSON buffer: 8KB for Golemio API responses, 24KB for BVG API responses (DynamicJsonDocument)
- BVG API responses are more verbose (~1.7KB per departure vs Golemio's more compact format)
- Configuration: NVS flash storage (persistent across reboots)
- Typical free heap: ~200KB
- RAM usage: 21.4% (70KB used of 327KB)
- Flash usage: 94.7% (1.24MB used of 1.31MB)
The device operates in two modes:
- Creates WiFi network for setup
- DNS captive portal active
- Display shows credentials (SSID/password/IP)
- API calls disabled
- Web UI shows setup-focused interface
- Connects to configured WiFi
- Fetches departures every N seconds (configurable)
- ETA recalculation every 10 seconds
- Serves full web dashboard
- Demo mode available
- Pauses API polling and automatic display updates
- Shows user-configurable sample departures
- Available in both AP and STA modes
- Manually stopped via web interface or device reboot
- Display cleared and brightness set to 0
- Triggered manually (web UI / REST API) or by scheduled time periods
- API polling continues (data stays fresh for when display resumes)
- Manual activation tracked separately (
restModeManualflag) - Scheduled activation follows configured periods (e.g., "23:00-07:00")
Boot
↓
Try STA mode (20 attempts, ~10s)
↓
├─ Success → STA Mode
│ ↓
│ Connection loss?
│ ↓
│ Auto-reconnect (every 30s)
│
└─ Failure → AP Mode
↓
Config saved?
↓
Restart → Try STA mode
Layered architecture with zero circular dependencies:
┌─────────────────────────────────────────────────────────┐
│ Layer 6: Application │
│ main.cpp (orchestrates all modules, runtime API │
│ selection based on config.city) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 5: Business Logic │
│ TransitAPI (abstract), GolemioAPI, BvgAPI, MqttAPI, │
│ GitHubOTA, WeatherAPI │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 4: Network Services │
│ WiFiManager, CaptivePortal, ConfigWebServer │
│ OTAUpdateManager │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Hardware Abstraction │
│ DisplayController, DisplayManager, DisplayColors, │
│ TimeUtils, RestMode │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Data Layer │
│ AppConfig, DepartureData │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 1: Foundation │
│ Logger, UTF-8 utilities (gfxlatin2, decodeutf8) │
└─────────────────────────────────────────────────────────┘
- Zero Circular Dependencies: Lower layers never depend on higher layers
- Single Responsibility: Each module has one clear purpose
- Callback Pattern: Modules communicate upward via callbacks
- Example:
ConfigWebServer→main.cppviaonSaveConfigcallback
- Example:
- Pure Data Structures: Config passed as parameter, not stored in modules
- Static Allocation: No dynamic allocation in main loop for stability
main.cpp
├─ Creates all modules and FreeRTOS tasks
├─ Registers callbacks
├─ Owns global state (departures array, mutexes)
├─ Runs loop() on Core 1: web server, ETA recalc, state management
├─ apiFetchTask() on Core 1: blocking HTTP calls, weather fetches
└─ displayRenderTask() on Core 1: display rendering via notification
DisplayController
├─ State machine: decides what to display (8 priority levels)
├─ Delegates rendering to DisplayManager
└─ No direct hardware access
DisplayManager
├─ Pure rendering layer for LED matrix
├─ Receives data as parameters (no caching)
├─ Handles UTF-8 to ISO-8859-2 conversion at render time
└─ Accesses config pointer for color mapping and dual ETA mode
WiFiManager
├─ Manages WiFi connection
├─ Notifies main.cpp of state changes via flags
└─ Provides status query methods
GolemioAPI / BvgAPI / MqttAPI
├─ Fetches departures via HTTP or MQTT
├─ Returns APIResult struct (no state stored)
└─ Uses statusCallback for progress updates
ConfigWebServer
├─ Serves tabbed web interface (5 tabs, per-tab save)
├─ Handles demo mode and rest mode via callbacks
└─ Communicates with main.cpp via callback pattern
When multiple stop IDs are configured (comma-separated, max 12 stops):
- Query each stop individually via separate API calls (always 12 departures per stop)
- Apply 1-second delay between API calls to reduce server load and avoid rate limiting
- Collect in temp buffer (capacity: 144 = 12 stops × 12 departures)
- Sort by ETA (earliest departures first across all stops)
- Cache top 12 soonest departures with timestamps
- Display configured rows (1-3) on LED matrix
- Recalculate ETAs every 10 seconds without additional API calls
This ensures you always see the soonest departures across all stops, regardless of which stop they come from.
The 1-second delay between API calls (delay(1000) in GolemioAPI.cpp:63) prevents:
- HTTP 429 (Too Many Requests) errors
- Excessive load on Golemio API servers
- Connection timeouts from rapid requests
With 12 stops configured, a full query cycle takes ~12 seconds (plus network latency).
- Single stop: ~1-2 seconds (network latency)
- Multiple stops: ~1-2s per stop + 1s delay between stops
- 12 stops: ~12-24 seconds total
- ETA recalculation: <1ms (simple arithmetic on cached data)
- Display render: ~10-20ms (LED matrix DMA transfer)
- Total refresh cycle: ~30ms
- Stack usage: Minimal (all large arrays are static or global)
- Heap fragmentation: None (no dynamic allocation in main loop)
- Flash storage: Configuration in NVS (~1KB)
When config.debugMode = true:
- Telnet server listens on port 23
- All
debugPrintln()calls mirrored to telnet clients - Memory usage logged at key points
- API responses logged with timestamps
Always available (115200 baud):
- Boot sequence
- WiFi connection status
- API errors
- Configuration changes
Key checkpoints logged via logMemory():
api_start- Before API callapi_complete- After processing responsedisplay_update- After display render
Use telnet to monitor memory in real-time:
telnet <device-ip> 23