Turning my Metroboard into a smart light
- home-assistant
- homelab
- hardware
- mqtt
- micropython
I got a Chicago Metroboard as a gift. It’s a map of the L with 310 LEDs that show where the trains are in real time.
I run Home Assistant at home, and all my other lights are on it with routines and automations. The Metroboard wasn’t. You change its settings with four buttons on the back, and since I mounted it on the wall, I can’t really get to them.
So I added a bridge to its firmware that talks MQTT, the messaging protocol a lot of smart home devices use. Home Assistant picks it up automatically and adds it as a device, with no config on the Home Assistant side.
It shows up as a regular smart light. I can turn it on and off, change the brightness, and switch between line colors and white.
The other button settings are there too, solid or pulsing trains and how often it updates, along with night mode, which you can normally only set during Wi-Fi setup. Since it’s a normal light now, it works in the same routines and automations as everything else.
All of this stays on my network. The board still gets its train data from Design Rules’ servers like it did before.
Doing it yourself#
The code is on GitHub at alexwohlbruck/metroboard-hass.
What you need#
- A Metroboard. I’ve only tried this on a Chicago board running app v0.8.0 and MicroPython 1.25. Other cities should work the same way, and the installer stops if it doesn’t recognize your board’s app.
- Home Assistant with the MQTT integration set up, and an MQTT broker like the Mosquitto add-on. The board has to be able to reach the broker over your Wi-Fi, and it has to be the same broker Home Assistant is connected to.
- A USB-C cable that carries data, not just power.
- A computer with Python 3. The installer finds the board on its own on macOS and Linux. On Windows, pass the port with
--port, like--port COM3.
1. Get the code#
git clone https://github.com/alexwohlbruck/metroboard-hass
cd metroboard-hass
python3 -m venv .venv
source .venv/bin/activate
pip install esptool littlefs-python "mpy-cross==1.25.0.post2"mpy-cross has to be the 1.25 version so the compiled files match the board.
2. Plug in the board#
Unplug the board from its power adapter and plug it into your computer. It runs off USB power while it’s connected.
3. Back it up#
python3 tools/mb_flash.py backupThis saves the board’s entire flash to a file like metroboard-20261005-120000.bin. Keep it somewhere safe. It’s how you get the board back to exactly how it was, and you’ll need it again after some vendor updates. It also has your Wi-Fi password and the board’s secret key in it, so don’t share it.
4. Set your broker#
cp mb_hass.example.json mb_hass.jsonOpen mb_hass.json and set broker to your MQTT broker’s IP address. If you use the Mosquitto add-on, that’s the IP of the machine running Home Assistant. If your broker needs a login, fill in user and password. The Mosquitto add-on accepts Home Assistant user accounts, so you can make a user just for the board.
5. Install#
python3 tools/mb_flash.py installThe board resets when it’s done.
6. Find it in Home Assistant#
Unplug the board from your computer and put it back on its power adapter. Within about a minute it shows up under Settings → Devices & services → MQTT as Metroboard (Chicago), or whichever city yours is. You don’t need to use “Add MQTT device”.
Add its Display light to a dashboard (I renamed mine to Metro Board). The tile turns it on and off and sets the brightness. Line colors and white are under Effect when you open the light. You can use it in automations and scenes like any other light.
If it doesn’t show up#
Check that
brokeris the one Home Assistant’s MQTT integration uses, and that devices on your Wi-Fi can reach it on port 1883.To see what the board is doing, set
"debug": trueinmb_hass.json, runinstallagain, and leave the board plugged into your computer. Its logs print on the USB serial port:python3 -m serial.tools.miniterm --dtr 0 --rts 0 /dev/cu.usbmodem* 115200On Linux the port is usually
/dev/ttyACM0.
After a vendor update#
When Design Rules releases a new version, the board switches to it. The new version doesn’t have the bridge’s hook, so Home Assistant control stops. Plug the board in and run the installer again with your backup:
python3 tools/mb_flash.py install --app-from metroboard-20261005-120000.binUndoing it#
python3 tools/mb_flash.py uninstallThat removes the bridge and the hook, and switches the board back to the app it was running before. To put the board back exactly how it was before you started, write your backup to it:
python3 -m esptool write-flash 0 metroboard-20261005-120000.binHow it works#
The board#
It’s an ESP32-C3 with 4 MB of flash, running MicroPython 1.25. Flash encryption and secure boot are off, and the USB-C port carries data, so the whole flash can be read and written over the cable. The app is MicroPython code on a littlefs filesystem.
Out of the box it’s a cloud client. It connects to Wi-Fi and polls Design Rules’ realtime API every 5, 10 or 30 seconds. For each line, the response lists which LEDs have a train stopped at a station and which have one in transit, and the board lights them up. It doesn’t run any local server, so there’s nothing on the network to talk to.
Design Rules leaves a README on the board saying local changes are fine, as long as you don’t poll their servers more often, change what the board reports to them, or share the API key or URLs in the firmware. The bridge only adds local control, its fastest update setting is their 5 second minimum, and the repo doesn’t include any of their code.
The hook#
The app runs on asyncio. The installer adds a few lines to its main(), right after it sets up the buttons, that start the bridge as one more task:
import mb_hass
asyncio.create_task(mb_hass.run(ctx))They’re wrapped in a try, so if the bridge fails to start, the app carries on without it. ctx is the app’s state, so the bridge changes settings on the same objects the buttons use. It saves them to /config.json too, so they survive a reboot and stay in sync with the buttons.
The installer writes all this with the board in bootloader mode: it reads the filesystem partition with esptool, edits it with littlefs-python, and writes it back. The bridge’s files go at the root of the filesystem, which vendor updates don’t touch.
The board keeps two copies of the app. base_app is plain Python, and updated_app holds the latest update from Design Rules, compiled to bytecode. The hook can only go in the plain Python copy, so the installer switches the board to base_app and tells it to skip the pending update. On my board, that meant going from app v1.0.0 back to v0.8.0. Train data comes from Design Rules’ servers either way, so the map works the same.
A newer update will still install, and the board will switch to it, leaving the hook behind. That’s why the installer can restore base_app from your backup.
The MQTT client#
The bridge uses Peter Hinch’s mqtt_as, which does all its socket I/O asynchronously, so a slow or unreachable broker can’t stall the display. By default it manages Wi-Fi itself, which would conflict with the app, so the bridge subclasses it to wait for the app’s connection instead.
Both the bridge and mqtt_as are installed precompiled as .mpy files. If the board had to compile mqtt_as itself, next to the running app, it would run out of memory.
Home Assistant#
The bridge uses MQTT discovery. When it connects, it publishes a config message for each entity, and Home Assistant creates the device from those. The main entity is a light that takes JSON commands:
mosquitto_pub -h <broker> -t metroboard/<device-id>/set/light \
-m '{"state": "ON", "brightness": 170, "effect": "White"}'The board only has three brightness levels, so the light’s brightness snaps to 85, 170 or 255. Color is an effect rather than a color picker, because the board can only show the real line colors or all white. The Brightness and Color Mode dropdowns on the device page control the same settings and stay in sync with the light.