CityBoard - IoT Transit Display (WIP)
A custom-built smart departure board showing real-time transit from four cities. The project spans four independent codebases - embedded C++ firmware, a Node.js transit API, a React Native mobile configurator, and a Python font toolchain - built end-to-end by one developer.
GitHubInspired by coppermilk/wiener_linien_esp32_monitor - an open-source Vienna-only departure monitor on the same T-Display S3 hardware. CityBoard took that concept and expanded it into a multi-city system with a dedicated mobile configurator, a custom transit backend, and a full FreeRTOS concurrency model.
About the project
CityBoard is a physical IoT device built on the LilyGo T-Display S3 (ESP32-S3 with a built-in 320×170 px TFT display). It connects to Wi-Fi, fetches live departure data from official transit APIs, and renders city-styled departure board layouts with smooth scrolling text, real-time second-precision countdowns, and a live clock. Supported cities are Vienna, Graz, Berlin, and Bratislava.
The device is configured entirely over the air. A companion React Native app walks through a four-step wizard - BLE pairing, Wi-Fi setup, stop selection with direction filtering, and display preferences. When the phone is on the same network, the app uses HTTP for speed, it falls back to BLE GATT automatically when Wi-Fi is unavailable. All settings survive power cycles via NVS flash.
Because Bratislava's DPB transit agency publishes no public API, a dedicated Node.js service was built to parse their official GTFS static feed and expose a REST API whose response format exactly mirrors the WienerLinien Ogd API - so the firmware requires zero city-specific parsing logic for that city.
Architecture
- ESP32-S3 with PlatformIO / Arduino framework - SPI display, BLE 5.0, 802.11 Wi-Fi, LEDC PWM backlight
- FreeRTOS dual-task architecture - fetch task and render/animation task run concurrently, synchronized via recursive mutex and binary semaphore
- TFT_eSPI with sprite compositing and partial-region screen updates - eliminates flicker on a 40 ms animation loop
- BLE GATT peripheral with 14 custom characteristics: Wi-Fi credentials, stop IDs, direction filter, walk time, brightness, night mode, device ID, factory reset
- HTTP REST server (port 80) for fast LAN-based configuration with automatic BLE fallback
- NVS flash (ESP32 Preferences) - all settings survive power cycles with no external storage
- City-specific departure board layouts, RGB565 color palettes, and rendering code for Vienna, Graz, Berlin, and Bratislava
- Animated imminent-departure indicator, marquee scrolling for long destination names and service disruptions, live HH:MM:SS clock in the header
- React Native 0.81 + Expo SDK 54 - cross-platform iOS and Android
- 4-screen setup wizard: BLE scan → Wi-Fi configuration → Stop & direction selection → Live departure monitor
- BLE device scanning and GATT characteristic reads/writes (react-native-ble-plx)
- Dual-transport config sync: HTTP over Wi-Fi attempted first (3 s timeout), BLE GATT as automatic fallback, connection mode auto-detected with background polling
- 4-city stop search: WienerLinien CSV index with platform merging, ÖBB transport.rest, BVG transport.rest, self-built DPB REST API
- Fully custom PanResponder slider - drag-anchor fix eliminates position jump on grab, blocks native scroll in nested ScrollView
- Multi-device dashboard with full config persisted in AsyncStorage, duplicate-device detection by BLE ID and IP address
- Live monitor screen: 15 s auto-refresh, color-coded countdown (red / orange / yellow), inline brightness control while connected
- Node.js + Express 5 REST API serving live departure data for Bratislava DPB from the official GTFS static feed
- Full GTFS CSV parser (stops, routes, trips, stop_times, calendar, calendar_dates) into in-memory Maps - O(1) lookup, no database
- CET/CEST timezone-aware schedule computation with correct after-midnight trip wrapping
- Geospatial stop search using the haversine formula
- Response envelope mirrors WienerLinien Ogd API format - firmware needs zero city-specific parsing logic for Bratislava
- Endpoints: stop search by name and by geo-radius, departure monitor with grouped directions, line list, full trip stop sequence
- Python script converting any .ttf font into Adafruit GFX PROGMEM bitmap format for TFT_eSPI on ESP32
- Uses libfreetype6 via Python ctypes - no pip packages or C build toolchain required
- Generates glyphs for Unicode 0x0020–0x017E (Latin + Latin Extended-A): Slovak Š ľ č ž ť ď and other Central European characters
- Produces all 5 custom fonts compiled into firmware flash: Poppins Bold (20pt, 12pt), Doto Regular / Bold / Black (12pt)
Engineering challenges
No public API for Bratislava
Built a complete GTFS static-feed parser from scratch - schedule computation, after-midnight trip wrapping, and CET/CEST timezone handling.
Second-precision countdowns from minute-resolution data
Parsed ISO 8601 departure timestamps into Unix epoch via mktime(), firmware computes exact seconds-remaining live from the stored epoch.
Concurrent display access from two FreeRTOS tasks
Recursive tftMutex guards all TFT calls, a separate depsMutex protects departure data, the animation task uses trylock to stay non-blocking.
Custom slider inside a ScrollView
PanResponder drag-anchor (startXRef) eliminates the position jump caused by locationX changing coordinate origin on grant, onShouldBlockNativeResponder blocks the native scroll layer during a drag.
BLE and HTTP config coexistence
HTTP over Wi-Fi is tried first via AbortController with a 3 s timeout, BLE GATT is used as fallback. The device broadcasts its IP in a BLE characteristic so the app can reconnect automatically.
Duplicate departures from multi-platform GTFS stops
Post-sort O(n²) dedup pass comparing (line, towards, departEpoch) before filling display slots - catches platform-level duplicates that GTFS does not deduplicate.
Full-screen flicker on every clock tick'
Partial-region fillRect clears only the header time column; full redraw fires only on mode switch. A clockOnly parameter controls update scope within the 40 ms render loop.
Slovak and Czech characters in firmware fonts
Custom Python font pipeline extended glyph coverage to Latin Extended-A (U+00A0–U+017E), fixed the TFT_eSPI UTF-8 multi-byte decoder path in firmware for glyphs above U+007F.
Gallery









