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

ConstantValuePurpose
MAX_DEPARTURES24Per-stop result buffer + published cache cap
DEPS_PER_STOP12Departures requested per stop (Golemio) + accumulator sizing
BVG_MAX_RESULTS10BVG request cap (its payload is heavier; 12 overflows the read buffer)
config.numDepartures1-7Display rows (user setting; max depends on panel size)
Important: 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)

  1. Demo mode โ€” custom sample departures
  2. Rest mode โ€” display off
  3. Ticker mode โ€” candlestick price chart (easter egg)
  4. AP mode โ€” WiFi setup credentials
  5. WiFi connecting โ€” connection status
  6. Setup required โ€” web UI address
  7. API error โ€” error message
  8. No departures โ€” info message
  9. 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):

  1. Query each stop individually (Golemio 12, BVG 10 departures per stop)
  2. Apply 1-second delay between API calls (rate limiting)
  3. Merge into a running accumulator โ€” a failed stop keeps its last-known rows
  4. Sort by ETA across all stops
  5. Publish top 24 soonest departures (partial board shown during initial fill)
  6. 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

OperationTiming
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.