Architecture & Data Flow
Technical documentation for developers and contributors.
Dual-Core Architecture
SpojBoard uses both cores of the ESP32-S3 with FreeRTOS tasks for optimal performance:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 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 management โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Thread Safety
Two mutexes protect shared data with short lock durations (~1ms):
- displayMutex โ Protects display update requests (display task โ loop)
- apiDataMutex โ Protects departures array and weather data (API task โ loop)
Data is copied under mutex, then processed without locks. Rendering and HTTP calls never hold a mutex.
Configuration Constants
| Constant | Value | Purpose |
|---|---|---|
MAX_DEPARTURES | 24 | Per-stop result buffer + published cache cap |
DEPS_PER_STOP | 12 | Departures requested per stop (Golemio) + accumulator sizing |
BVG_MAX_RESULTS | 10 | BVG request cap (its payload is heavier; 12 overflows the read buffer) |
config.numDepartures | 1-7 | Display rows (user setting; max depends on panel size) |
numDepartures only controls how many rows to show on the LED matrix (up to 3 on 128ร32, up to 7 on either 128ร64 arrangement), not API fetch size. The board fetches more than it displays for better caching and sorting โ Golemio 12 per stop, BVG 10 (its response is heavier).
Complete Data Pipeline
1. User Configuration
City selection, stop IDs, display rows (up to 7 on 128ร64)
2. Per-Stop Fetch
One stop at a time โ merged into a running accumulator
Failed stop keeps its previous rows; 1-second delay between stops
3. Sort by ETA
All departures sorted by time across all stops
4. Publish Snapshot
Top 24 soonest stored with timestamps; partial board shown during initial fill
5. ETA Recalculation
Every 10 seconds: recalculate ETAs, filter stale entries
6. Display Rendering
Show configured rows (up to 7) on LED matrix
State Machine
The device operates in multiple modes with priority-based evaluation:
Operating Modes
AP Mode
Creates WiFi network for setup. Display shows credentials. API calls disabled.
STA Mode
Connects to configured WiFi. Fetches departures, recalculates ETAs, serves web dashboard.
Demo Mode
Pauses API polling. Shows user-configurable sample departures. Available in both AP and STA modes.
Rest Mode
Display cleared, brightness 0. API polling continues. Triggered manually or by schedule โ the schedule is edge-triggered, so it acts only when its answer changes and a manual choice holds until then.
Display Priority (highest to lowest)
- Demo mode โ custom sample departures
- Rest mode โ display off
- Ticker mode โ candlestick price chart (easter egg)
- AP mode โ WiFi setup credentials
- WiFi connecting โ connection status
- Setup required โ web UI address
- API error โ error message
- No departures โ info message
- Normal operation โ real departures
Memory Allocation
The firmware favors static allocation in the main loop for stability. Departures are collected into a temporary buffer, sorted, and cached for display, while per-API JSON buffers live on the heap only for the duration of a fetch.
Typical: ~200KB free heap. Firmware fits within the 2MB app partition (dual OTA partitions of 2MB each).
For the exact per-structure budget and current buffer sizes, see the architecture reference on GitHub.
Module Architecture
Layered design with zero circular dependencies:
Layer 6: Application
main.cpp (orchestrates all modules, runtime API selection)
Layer 5: Business Logic
TransitAPI (abstract), GolemioAPI, BvgAPI, MqttAPI,
WeatherAPI, TickerAPI, GitHubOTA
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) Key Patterns
- Zero Circular Dependencies: Lower layers never depend on higher layers
- Callback Pattern: Modules communicate upward via callbacks
- Pure Data Structures: Config passed as parameter, not stored in modules
- Static Allocation: No dynamic allocation in main loop for stability
Multi-Stop Behavior
When multiple stop IDs are configured (comma-separated, max 12 stops):
- Query each stop individually (Golemio 12, BVG 10 departures per stop)
- Apply 1-second delay between API calls (rate limiting)
- Merge into a running accumulator โ a failed stop keeps its last-known rows
- Sort by ETA across all stops
- Publish top 24 soonest departures (partial board shown during initial fill)
- Display configured rows on LED matrix
This ensures you always see the soonest departures across all stops, while a transient failure of one stop never blanks it.
Performance
| Operation | Timing |
|---|---|
| Single stop API call | ~1-2 seconds |
| 12 stops full query | ~12-24 seconds |
| ETA recalculation | <1ms |
| Display render | ~10-20ms |
Debugging
When config.debugMode = true:
- Detailed logging on the serial console โ everything goes to serial and nowhere else. The telnet mirror on port 23 existed until r9 and was removed with its dependency
- Memory logging at key checkpoints (api_start, api_complete, display_update)
- API response logging with timestamps
Serial output always available at 115200 baud for boot sequence, WiFi status, and errors.