Recently I saw a cool project someone shared on the ESPHome Discord server that inspired me and I’ve decided that I’ll attempted to replicate it and build something similar. It was a LED matrix display based departure board connected to an Arduino that was displaying publicly available information about upcoming departures of the nearest public transport stop.
Since I live in Prague where we happen to have a publicly available data where you can fetch live information about location of trams/buses/metro and their delays I thought it should be pretty much doable. The public transport APIs are available at Golemio API, you must register to get your API Key and then you’re good to go. The default rate-limit policies are pretty generous and completely sufficient for this home project.
Hardware
- 2x Waveshare RGB LED matrix display 64x32 with 2.5mm pitch. I’ve bought these over rpishop.cz. There is some documentation along with some examples on the Waveshare Wiki.
- ESP32 development board. I’ve this over rpishop.cz as well, but I suspect you can use almost any ESP32 board you might be having laying around.
- 5V/4A power supply with a 5mm barrel plug for the LCD Display is recommended. I’m using one which is rated for 3A and it is able to provide enough current for both of the panels but I suspect it’s because I run it at half brightness and also most of the pixels are off.
- 5V power supply with micro USB plug for the ESP32.
The Waveshare LED display comes with all necessary wires plus also contains the cable which allows you to chain multiple of these displays together. In the end I’ve chained two of them so in the code you can program it like it would be one 128x32 large panel.
Home Assistant Code
I’ve decided to use Home Assistant’s RESTful integration to communicate with the Golemio API endpoint and process the raw departure data for three distinct bus stops that happen to be near me. I’m using the /v2/pid/departureboards
endpoint and the following rest.yaml
platform config:
- scan_interval: 30
resource: "https://api.golemio.cz/v2/pid/departureboards?ids[]=Uxxx&ids[]=Uyyy&ids[]=Uzzz&minutesBefore=0&minutesAfter=360&includeMetroTrains=false&airCondition=true&skip=canceled&limit=6"
headers:
X-Access-Token: !secret golemio_api_key
sensor:
- name: Bus Departures Raw
value_template: "OK"
json_attributes:
- departures
This fetches the live positions and the predicted departure times for the 6 upcoming bus lines (including a real delay of each bus) that happen to stop at any of those 3 stop IDs (set in the query string in the URL resource). It extracts all objects from the departures
array from the JSON string and stores it to the sensor attribute. Originally I’ve had three different sensors for three different bus stops but I’ve opted for this approach for multiple reasons but mostly because it reduces the level of duplication in HA.
The raw JSON response looks like this:
{
"stops": [
{ ... }
],
"departures": [
{
"arrival_timestamp": { ... },
"delay": { ... },
"departure_timestamp": { ... },
"last_stop": { ... },
"route": { ... },
"stop": { ... },
"trip": { ... }
},
{ ... }
],
"infotexts": []
}
Second piece to the puzzle is to parse this information and pick only the data I’m interested in displaying on the LCD: route.short_name
, trip.headsign
, delay.seconds
and departure_timestamp.predicted
. I want to display the 3 upcoming departures only (since you can’t reasonably fix more text to the display without pagination), so I’ve created a template sensor which parses the raw sensor data stored in the seteps above and save the nearest 3 departures – each in one attribute to simplify the parsing later in the ESPHome code:
- name: "Upcoming Bus Departures"
state: "OK"
attributes:
line1: >
{% set buses = state_attr('sensor.bus_departures_raw', 'departures') %}
{% if buses %}
{%- set bus = buses[0] %}
{%- set route = bus['route']['short_name'] %}
{%- set sign = bus['trip']['headsign'] or "?" %}
{%- set delay = bus['delay']['seconds'] | default(0) %}
{%- set minutes = (
(as_timestamp(bus['departure_timestamp']['predicted']) - as_timestamp(now()))
/ 60) | round(0, 'floor') %}
{%- set line = "{}|{}|{}|{}".format(route, sign, delay, minutes) %}
{{ line }}
{% endif %}
line2: >
...
{%- set bus = buses[1] %}
...
line3: >
...
{%- set bus = buses[2] %}
...
This results in a sensor having three attributes (line1
, line2
and line3
) where each is having those 4 pieces of information separated by a pipe:
line1: 120|Na Knížecí|0|17
line2: 120|Nádraží Radotín|124|19
line3: 104|Na Hvězdárně|7|35
Now we can expose this sensor to ESPHome and try to display this text on our display.
ESPHome Configuration
The display is actually not supported out of the box in ESPHome and I’ve had to use this external component. By far the biggest chunk of the code is the lambda for the display
component, but after I figured out all the X and Y positions for my elements on the screen the rest was fairly straightforward. What actually ended up being the biggest challenge was picking the best font! Even with this pitch there is really only about ~10 pixels for each row and some fonts are really unusable when rendered in this size. I’ve ended up using Doto Bold.
esphome:
name: esp32-nodemcu
friendly_name: esp32-nodemcu
esp32:
board: esp32dev
framework:
type: arduino
external_components:
- source: github://TillFleisch/ESPHome-HUB75-MatrixDisplayWrapper@main
font:
- file: "gfonts://Doto@Bold"
id: mono
size: 10
glyphsets:
- GF_Latin_Core
display:
- platform: hub75_matrix_display
id: matrix
width: 64
height: 32
brightness: 64
chain_length: 2
G2_pin: GPIO33
C_pin: GPIO18
OE_pin: GPIO32
pages:
- id: page1
lambda: |-
int destPosX = 22;
int delayPosX = 95;
int timePosX = 127;
auto red = Color(255, 0, 0);
auto yellow = Color(255, 0, 255);
auto orange = Color(255, 30, 150);
auto green = Color(0, 50, 255);
auto white = Color(255, 255, 255);
auto grey = Color(150, 150, 150);
std::string line1 = id(deps_line1).state;
std::string line2 = id(deps_line2).state;
std::string line3 = id(deps_line3).state;
std::array<std::string, 3> lines = { line1, line2, line3 };
for (int i = 0; i < lines.size(); ++i) {
int y = i * 10;
const auto &line = lines[i];
std::string line_number, head_sign;
float delay;
int minutes;
size_t start = 0;
size_t end = line.find('|');
if (end != std::string::npos) {
line_number = line.substr(start, end - start);
start = end + 1;
}
end = line.find('|', start);
if (end != std::string::npos) {
head_sign = line.substr(start, end - start);
start = end + 1;
}
end = line.find('|', start);
if (end != std::string::npos) {
delay = parse_number<int>(line.substr(start, end - start)).value() / 60.0;
start = end + 1;
}
minutes = parse_number<int>(line.substr(start)).value(); // last part
Color color = green; // default fallback
if (delay >= 9) {
color = red;
} else if (delay >= 6) {
color = orange;
} else if (delay >= 3) {
color = yellow;
}
it.printf(1, y, id(mono), white, COLOR_OFF, TextAlign::TOP_LEFT, "%s", line_number.c_str());
it.printf(destPosX, y, id(mono), grey, COLOR_OFF, TextAlign::TOP_LEFT, "%s", head_sign.substr(0, 16).c_str());
it.printf(timePosX, y, id(mono), color, COLOR_OFF, TextAlign::TOP_RIGHT, "%d", minutes);
}
number:
- platform: hub75_matrix_display
matrix_id: matrix
id: matrix_brightness
name: "Display Brightness"
switch:
- platform: hub75_matrix_display
matrix_id: matrix
name: "Display Power"
restore_mode: ALWAYS_ON
id: power
text_sensor:
- platform: homeassistant
id: deps_line1
entity_id: sensor.upcoming_bus_departures
attribute: line1
- platform: homeassistant
id: deps_line2
entity_id: sensor.upcoming_bus_departures
attribute: line2
- platform: homeassistant
id: deps_line3
entity_id: sensor.upcoming_bus_departures
attribute: line3
Final Result
As you can see in the picture it doesn’t look too bad. However I’ve decided to trim the text of the final destination for each bus because for certain names it would start to overlap with the last number - the minutes until departure. This is a bit hacky and I’ll think of improving this later.