An ESP32-based LED matrix display showing real-time transit departures. Supports multiple data sources: direct API integration (Prague Golemio, Berlin BVG) or MQTT for home automation integration.
SPOJ = Smart Panel for Onward Journeys (also "spoj" = connection/service in Czech) Standalone operation with web-based configuration, no external server required for direct API mode.
- Wiring Guide - HUB75 pin mapping for all supported hardware variants
- MQTT Integration Guide - Home Assistant integration, custom data sources, and dual filtering setup
- Architecture & Data Flow - Technical details, pipeline flow, and design rationale
- Font System - Czech character support and custom font creation guide
- Troubleshooting - Common issues and solutions
- Multi-Source Support: Direct API (Prague Golemio, Berlin BVG) or MQTT for home automation integration (Home Assistant, custom sources)
- Direct API Access: Fetches departure data every 10-300 seconds (configurable)
- MQTT Integration: Request/response pattern with configurable JSON field mappings, dual ETA modes (timestamp/pre-calculated), and server-side aggregation - see MQTT Guide
- Local ETA Updates: Recalculates ETAs every 10 seconds from cached timestamps without additional API calls - reduces server load while keeping display fresh
- Multi-Stop Support: Query up to 12 stops simultaneously (144 departure buffer), automatically sorted by departure time with 1s rate limiting
- WiFi Setup Portal: Automatic AP mode with captive portal when WiFi fails - no code editing needed
- Dual ETA Display: Optionally show next two departure times per line - see departure frequency at a glance (disabled by default)
- Dual-Core Architecture: FreeRTOS multi-task design - Core 0 handles WiFi interrupts, Core 1 runs display rendering, API fetching, and web server concurrently for responsive operation
- Web Configuration: Easy setup via tabbed web interface with city selector and per-tab save
- Persistent Settings: Configuration stored in ESP32 flash memory, survives reboots
- Extended Character Support: Custom 8-bit ISO-8859-2 fonts with automatic UTF-8 conversion (ž, š, č, ř, ň, ť, ď, ß, etc.)
- Adaptive Font Rendering: Automatically switches to condensed font for long destination names (>16 chars), fitting up to 23 characters on screen
- Optional Scrolling: Enable scrolling for long destination names that exceed display width (disabled by default)
- Headsign Shortening: Automatic shortening of long Czech words (e.g., "Nádraží" → "Nádr.", "nádraží" → "nádr.") to maximize display space
- Trip Filtering: Configurable minimum departure time (default 3 minutes) to hide departures that are too soon to catch
- AC Indicator: Shows asterisk (*) for air-conditioned vehicles (Prague only)
- Custom Line Colors: Configure custom colors for specific lines via web interface with exact and pattern matching
- Demo Mode: Preview custom departure data on the display before API configuration - available in both setup and normal modes
- Firmware Updates: GitHub-based OTA updates with user confirmation - check for new releases directly from the web interface
- Optional Telnet Logging: Remote debugging via telnet (port 23) when debug mode is enabled - mirrors all logs over WiFi
- Weather Display: Optional weather info in status bar showing temperature and condition icon from Open-Meteo API (free, no API key required)
- Rest Mode: Scheduled display power saving - configure time periods when the display turns off automatically
SpojBoard supports two hardware variants. The firmware automatically detects and configures the correct pin mapping - just flash the right firmware for your board.
A note on logic levels: HUB75 panels are 5V logic devices, but the ESP32-S3 outputs 3.3V. Many panels work at 3.3V by accident (the input threshold is close enough), but this is out of spec and not guaranteed across all panel batches. Level shifters (3.3V → 5V) ensure reliable operation with any panel.
| Variant | Board | Firmware File | Wiring | Level Shifting |
|---|---|---|---|---|
| MatrixPortal S3 | Adafruit MatrixPortal ESP32-S3 | spojboard-matrixportal_s3-r*.bin |
Plug & play | Built-in (74AHCT245) |
| ESP32-S3 N8R2 | Generic ESP32-S3 DevKitC (8MB Flash, 2MB PSRAM) | spojboard-esp32_s3_n8r2-r*.bin |
Manual wiring | Not included |
- Board: Adafruit MatrixPortal ESP32-S3
- Display: 2× HUB75 64×32 LED Matrix panels (128×32 total)
- Power: 5V 2A minimum (3A recommended) via USB-C
- Wiring: Built-in HUB75 connector - just plug in your panels
- Level shifting: Includes 2× 74AHCT245 level shifters, works reliably with all HUB75 panels
- Board: ESP32-S3-DevKitC-1 or similar (8MB flash minimum)
- Display: 2× HUB75 64×32 LED Matrix panels (128×32 total)
- Power: 5V 2A minimum (3A recommended) via screw terminals on panels
- Wiring: Standard HUB75 rainbow cable to GPIO pins
Warning: Direct ESP32-S3 → HUB75 wiring runs at 3.3V, which is below the panels' 5V logic spec. It works with many panels but is not guaranteed across all manufacturers/batches. For reliable operation, add a 74AHCT245 level shifter between the ESP32 and panel. See ESP32-HUB75-MatrixPanel-DMA #134 for details.
→ See Wiring Guide for complete pinout and connection instructions.
✅ No code changes required - the firmware automatically handles pin mapping differences between boards. Just use standard HUB75 cables.
HUB75 LED matrix panels require external 5V power.
Measured power consumption (real-world testing):
- Normal operation (brightness 90, default): ~0.3A average
- Maximum brightness (brightness 255): ~0.65A average, 0.7A peak
- Transient spikes: Up to 1.6A during WiFi connection, OTA updates, or boot
Recommended power supply:
- 5V 2A minimum (provides 2.8× headroom over normal peaks, adequate for all conditions)
- 5V 3A recommended (extra safety margin for simultaneous WiFi/OTA/display load)
Compatible supplies:
- USB-C: Any quality 5V 2A+ phone charger or USB PD adapter (10W+)
- Screw terminals: 5V 2-3A regulated DC power supply (for generic ESP32 boards)
Note: Published HUB75 specifications often cite 5-6A at full brightness with all LEDs white, but SpojBoard's text-based display with typical content uses significantly less power in practice.
-
Build & Flash the Firmware
pio run -t upload
-
Connect to Setup AP
- The display will show WiFi credentials:
WiFi Setup Mode SSID: SpojBoard-XXXX Pass: xxxxxxxx Go to: 192.168.4.1 - Connect your phone/computer to this WiFi network
- A captive portal should open automatically (or go to http://192.168.4.1)
- The display will show WiFi credentials:
-
Configure via Web UI
- Enter your home WiFi credentials (required)
- Select your data source:
- Prague: Enter Golemio API key (get one at api.golemio.cz/api-keys)
- Berlin: No API key required
- MQTT: Configure broker and topics - see MQTT Guide
- Enter your stop ID(s) - comma-separated for multiple stops (not used for MQTT)
- Try the demo mode to preview the display before API setup
- Click "Save & Connect to WiFi"
-
Device Connects
- The device will restart and connect to your WiFi
- Find its new IP address on the display or your router
- Departures will start showing automatically
If the device can't connect to WiFi (wrong password, network down, etc.), it will automatically:
- Stop trying after 20 attempts (~10 seconds)
- Create a new AP with a random password
- Display the credentials on the LED matrix
- Wait for you to configure new WiFi settings
The built-in web server provides a tabbed configuration interface with per-tab save:
- Connection Tab (📡): WiFi credentials, data source selector (Prague, Berlin, or MQTT)
- Transit Data Tab (🚇): API-specific configuration (dynamically shown based on selected city)
- Prague: API key, stop IDs
- Berlin: Stop IDs
- MQTT: Broker, topics, field mappings - see MQTT Guide
- Refresh interval, minimum departure time, number of departures
- Display Tab (🖥): Brightness, custom line colors with wildcard patterns, dual ETA toggle, scrolling
- Optional Tab (⭐): Weather display, rest mode periods, debug mode (STA mode only)
- System Tab (⚙): System info, firmware updates, actions (STA mode only)
- Action Bar: Force refresh, demo mode, rest mode toggle (context-aware per mode)
- Status Banners: AP mode, demo mode, rest mode (manual/scheduled), API errors
| Feature | AP Mode | Normal Mode |
|---|---|---|
| WiFi | Creates its own network | Connects to your network |
| API Calls | Disabled | Every 30-300s (configurable) |
| ETA Updates | N/A | Every 10s from cache |
| Display | Shows AP credentials | Shows departures |
| Web UI | Setup focused | Full dashboard |
| Demo Mode | Available | Available |
- Visit data.pid.cz/stops/json/stops.json
- Search for your stop name (Ctrl+F)
- Find the
gtfsIdsvalue (e.g.,U693Z2P) - You can enter multiple IDs separated by commas
- Visit v6.bvg.transport.rest
- Use the
/locationsendpoint to search for your stop - Find the stop ID (numeric, e.g.,
900013102) - You can enter multiple IDs separated by commas
No stop IDs needed - configure your MQTT broker to aggregate and filter departures server-side. See MQTT Integration Guide for complete setup instructions.
┌────────────────────────────────────┐
│ [31] Sídliště Řepy 12' │ Row 1
│ [7 ] Radlická 5' │ Row 2
│ [A ] Depo Hostivař 2' │ Row 3
│ 08.02. Donnerstag ☀ 15° 14:23 │ Row 4 (Date/Day/Weather/Time)
└────────────────────────────────────┘
When "Show multiple departure times" is enabled, each row shows the next two departures for the same line and destination:
┌────────────────────────────────────┐
│ [31] Nádraží 5' 32' │ Row 1
│ [A ] Letenské 2' 18' │ Row 2
│ [S9] Masarykovo <1' 24' │ Row 3
│ 15.02. Saturday ☀ 18° 14:35 │ Row 4
└────────────────────────────────────┘
Both ETAs are independently color-coded (red < 2min, yellow 2-5min, white > 5min). If no matching second departure exists, only the first ETA is shown.
Weather in Status Bar (when enabled):
- Weather icon and temperature displayed between day name and time
- Temperature color-coded: cyan (cold), white (mild), yellow (warm), red (hot)
- Icon color indicates condition: yellow (sunny), white (cloudy), cyan (rain), blue (snow)
Visual Design:
- Line numbers displayed in their route color on uniform 18-pixel black background boxes (fits 1-3 characters)
- Route numbers are horizontally centered within their boxes for consistent alignment
- All destinations start at the same X position regardless of route length
- Adaptive font rendering: automatically switches to condensed font for destinations longer than 16 characters
- Destination text dynamically truncated based on ETA display width and font choice to prevent overlap
- Status bar: numeric date (DD.MM.) + full localized day name (English/Czech/German) + optional weather + time
┌────────────────────────────────────┐
│ WiFi Setup Mode │
│ SSID: SpojBoard-A1B2 │
│ Pass: k7m3p9x2 │
│ Go to: 192.168.4.1 │
└────────────────────────────────────┘
These colors are used by default and can be customized via the web interface:
- Green: Metro A
- Yellow: Metro B, Default fallback for all unmapped lines
- Red: Metro C
- White: Trams 1-29 (e.g., 2, 5, 7, 10, 12, 15, 17, 22)
- Purple: 2-digit T-buses 50-59 & 3-digit buses 100-299 (e.g., 59, 100, 102, 200)
- Blue: S-trains (S1, S2, S3, etc.)
- Cyan: Night lines 91-99, 900-999
Customizing Line Colors:
- Configure specific colors for individual lines (e.g., "A=RED")
- Use position-based wildcard patterns with asterisks as position placeholders:
- "9*" matches 2-digit lines (91-99)
- "95*" matches 3-digit lines (950-959)
- "4**" matches 3-digit lines (400-499)
- "C***" matches 4-digit lines (C000-C999)
- Exact matches take priority over patterns
- Invalid patterns (leading asterisks "**", non-trailing asterisks "91") are ignored
- Unmapped lines use the hardcoded defaults above
- Available colors: RED, GREEN, BLUE, YELLOW, ORANGE, PURPLE, CYAN, WHITE
- White: > 5 minutes
- Yellow: 2-5 minutes
- Red: < 2 minutes
- Orange: Delayed
When dual ETA mode is enabled, both times are independently color-coded and use condensed font.
SpojBoard can optionally display current weather in the status bar using the free Open-Meteo API (no API key required).
- Open the web interface at
http://[device-ip]/ - Enable "Weather Display" in the configuration form
- Enter your location's GPS coordinates (latitude and longitude)
- Optionally adjust the refresh interval (default: 15 minutes)
- Save configuration
- Use Google Maps: Right-click any location → coordinates are shown
- Common cities: Prague (50.0755, 14.4378), Berlin (52.5200, 13.4050)
| Condition | Icon | Color |
|---|---|---|
| Clear/Sunny | ☀ | Yellow |
| Cloudy | ☁ | White |
| Fog | 🌫 | Purple |
| Rain/Drizzle | 🌧 | Cyan |
| Snow | ❄ | Blue |
| Thunderstorm | ⛈ | Red |
- Blue: Below 8°C (cold)
- White: 8-16°C (mild)
- Yellow: 17-25°C (warm)
- Red: Above 25°C (hot)
Rest mode automatically turns off the display during configured time periods to save power and reduce light pollution at night. It can also be controlled manually via the web interface or REST API.
- Open the web interface at
http://[device-ip]/ - Enter time periods in the "Rest Mode" field
- Format:
HH:MM-HH:MM(24-hour format) - Multiple periods: separate with commas
| Configuration | Behavior |
|---|---|
23:00-07:00 |
Off from 11 PM to 7 AM |
00:00-06:00 |
Off from midnight to 6 AM |
22:00-06:00,12:00-13:00 |
Off overnight and during lunch |
| (empty) | Rest mode disabled (default) |
You can also enable/disable rest mode manually from the web interface:
- Open the web interface at
http://[device-ip]/ - Click "Enable Rest Mode" button in Actions section
- Display turns off immediately (regardless of scheduled periods)
- Click "Disable Rest Mode" to resume normal operation
Manual vs Scheduled:
- Manual activation: Takes priority, overrides schedule, indicated as "Rest Mode Active (Manual)" in status
- Scheduled activation: Follows configured time periods, indicated as "Rest Mode Active (Scheduled)" in status
- Status indicator: Shows whether rest mode is "Manual" or "Scheduled" in the dashboard
Use cases:
- Quick display shutoff without editing time periods
- Integration with home automation (REST API endpoint:
POST /rest-mode) - Testing rest mode before configuring schedule
Notes:
- Cross-midnight periods work correctly (e.g., "22:00-06:00")
- API polling continues during rest mode (data stays fresh)
- Display resumes automatically when rest period ends
Having issues? See the Troubleshooting Guide for solutions to common problems including WiFi connection issues, API errors, display problems, and firmware update issues.
SpojBoard supports two methods for updating firmware:
Check for and install new releases directly from GitHub:
- Open the web interface at
http://[device-ip]/ - Click "Check for Updates" in the Actions section
- Review the update (version, release notes, file size)
- Click "Download & Install" if update is available
- Wait for download - progress shown on LED matrix
- Device reboots automatically with new firmware
Features:
- ✅ Automatic version checking
- ✅ Displays release notes before installing
- ✅ Secure HTTPS download with MD5 validation
- ✅ Progress display on LED matrix
- ✅ Safe - failed update doesn't brick the device
Requirements:
- Device must be connected to WiFi (not in AP mode)
- Internet access to github.com
Upload a firmware .bin file manually:
- Get the firmware file for your hardware variant:
- MatrixPortal S3:
spojboard-matrixportal_s3-r2-abc12345.bin - ESP32-S3 N8R2:
spojboard-esp32_s3_n8r2-r2-abc12345.bin
- MatrixPortal S3:
- Open web interface and click "Update Firmware"
- Select .bin file and click "Upload Firmware"
- Wait for upload - progress shown on screen
- Device reboots after successful upload
Use cases:
- Installing custom firmware builds
- Offline updates
- Development testing
All firmware updates (both methods):
- ❌ Disabled in AP mode (prevents unauthorized access)
- ✅ MD5 validation (corrupted firmware rejected)
- ✅ HTTPS only for GitHub downloads
- ✅ Separate OTA partition (failed update doesn't affect running firmware)
- ✅ User confirmation required (no automatic updates)
For detailed information about the dual-core architecture, data flow pipeline, memory allocation, and design decisions, see Architecture & Data Flow.
- SSID:
SpojBoard-XXXX(XXXX = last 4 chars of MAC address) - Password: 8 random alphanumeric characters (regenerated each time)
- IP:
192.168.4.1
The device implements captive portal detection for:
- Android (
/generate_204,/gen_204) - iOS/macOS (
/hotspot-detect.html) - Windows (
/ncsi.txt,/connecttest.txt) - Firefox (
/success.txt)
- JSON buffer: 8KB for API responses
- Configuration stored in NVS flash
- Typical free heap: ~200KB
- RAM usage: 21.4% (70KB used of 327KB)
- Flash usage: 94.7% (1.24MB used of 1.31MB)
When multiple stop IDs are configured (comma-separated, max 12 stops), the system:
- Queries each stop individually via separate API calls (always fetches 12 departures per stop)
- Applies 1-second delay between API calls to reduce server load and avoid rate limiting
- Collects all departures in temporary buffer (capacity: 144 total = 12 stops × 12 departures)
- Sorts by ETA (earliest departures first across all stops)
- Caches the top 12 soonest departures with timestamps for ETA recalculation
- Displays the configured number of rows (1-3) on the LED matrix
- Recalculates ETAs every 10 seconds from cached timestamps without additional API calls
This ensures you always see the soonest departures across all your stops, regardless of which stop they come from. The API always fetches 12 departures per stop for caching and sorting - the user only controls how many rows (1-3) to display on the LED matrix. The 10-second ETA recalculation keeps the display fresh without hammering the API, allowing longer refresh intervals (up to 300s) during peak times.
To maximize display space on the 128×32 LED matrix, long Czech destination names are automatically shortened before display (Prague only):
- "Nádraží" → "Nádr." (uppercase)
- "nádraží" → "nádr." (lowercase)
- "Sídliště" → "Sídl."
- "Nemocnice" → "Nem."
Combined with adaptive font rendering (condensed font for destinations >16 chars), even very long station names fit comfortably on the display. Shortening is applied before UTF-8 to ISO-8859-2 conversion, ensuring Czech diacritics are preserved correctly. Berlin station names are displayed as-is from the API.
SpojBoard uses custom 8-bit ISO-8859-2 fonts with automatic UTF-8 conversion to display special characters (Czech: ž, š, č, ř, ň, ť, ď, ú, ů, á, é, í, ó, ý; German: ß, ẞ, ä, ö, ü, Ä, Ö, Ü) from transit APIs.
For complete details on the font system, UTF-8 conversion, and creating custom fonts, see Font System Documentation.
This project is licensed under the GNU General Public License v3.0 (GPL-3.0).
This means you are free to:
- ✅ Use this code for personal or commercial purposes
- ✅ Modify and distribute the code
- ✅ Create derivative works
However, you must:
- 📝 Disclose the source code of any derivative works
- 📝 License derivative works under GPL-3.0
- 📝 Include copyright and license notices
See the LICENSE file for full details.
- Golemio API - Prague open data platform
- ESP32-HUB75-MatrixPanel-DMA - Display driver
- Adafruit GFX Library - Graphics primitives and font rendering
- Adafruit GFX Font Customiser by tchapi
- Michel Deslierres - Original UTF-8 to ISO-8859-2 conversion code
- Petr Brouzda - fontconvert8-iso8859-2 - Font conversion tool and UTF-8 conversion implementation
- Claude Code - AI-assisted development and code refinement
- DepartureMono font family - Custom 8-bit GFXfonts with ISO-8859-2 character support
