Article Summary

Turn a LEGO Powered Up train layout into an automated railway. RFID readers under the track report which train passed, an MQTT broker carries the messages, an ESP32 in each train drives the hub with the Legoino library, and a small Python script decides when trains stop and go. All code here connects, reconnects and handles an emergency stop.

A LEGO Powered Up train layout can run itself: trains stop at stations, wait, and leave again with no one holding a remote. The recipe is RFID readers under the track to report which train passed, an MQTT broker to carry messages, an ESP32 in each train to drive its hub over Bluetooth, and a short script to make the decisions. Every piece of code here connects to the broker, reconnects after a drop, and obeys an emergency stop.

Quick answer: put an RC522 reader under the track at each station and a tag under each train, run Mosquitto on a Raspberry Pi or any always-on computer, put an ESP32 running Legoino in each train, and let a Python script publish STOP and SPEED commands when a tag arrives.

System architecture

[Tag under train]  passes over  [RC522 reader under track]
                                        |
                                [Trackside ESP32]
                                        |  Wi-Fi, MQTT
                                        v
                       [Broker + control script on a Pi]
                                        |  Wi-Fi, MQTT
                                        v
                             [ESP32 riding in train 1]
                                        |  Bluetooth
                                        v
                            [Train's Powered Up hub]
  • Trackside: an RC522 reader and an ESP32 at each detection point.
  • Trains: an ESP32 in each locomotive, connected to the train's Powered Up hub over Bluetooth.
  • Server: the Mosquitto broker and the control script, on a Raspberry Pi or any computer that stays on.

Trains that work with this

Any train with a Powered Up hub. Current LEGO sets that include one:

LEGO Police Train Heist
Police Train Heist
$199.99

Police Train Heist, a Powered Up locomotive with three flatcars.

View on

LEGO Explorer Train
Explorer Train
$209.99

Explorers' Arctic Polar Express Train, a remote-controlled Powered Up locomotive.

View on

Some current trains ship as push trains. LEGO's description of this steam train says a separately sold kit motorizes it for control from the Powered Up app or remote:

LEGO Vintage Steam Train
Vintage Steam Train
$89.99

Vintage Steam Train, a push train with a coal tender and passenger car that a separately sold kit motorizes.

View on

A station gives the schedule somewhere to stop:

LEGO Central Train Station
Central Train Station

Central Train Station, three levels of platforms and shops for a stopping point.

View on

RFID position tracking

The RC522 boards sold for Arduino projects use NXP's MFRC522 chip, which reads 13.56 MHz tags such as MIFARE cards and stickers over SPI. NXP's datasheet gives a typical read and write distance of up to 50 mm, depending on the antenna size and tuning. That is enough to read through track and a plate or two, but test your own stack with a tag before building the reader in.

  • Mounting: the reader goes under the track at a station or junction, the tag goes on the underside of the train, as low as possible.
  • Reading a moving train: keep the reader loop free of delays, use the largest tag that fits under the train, and place two tags in a row on long trains so one read is enough.
  • Wiring: RC522 boards run at 3.3 V, so power them from the ESP32's 3.3 V pin. Use the ESP32's default SPI pins (SCK 18, MISO 19, MOSI 23) and any free pins for the reader's SDA and reset lines.

Parts: RC522 RFID modules on Amazon and ESP32 development boards on Amazon.

Trackside ESP32 code

It uses the MFRC522 Arduino library and PubSubClient. The tag ID is printed with leading zeros, so the same tag always gives the same string.

#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <PubSubClient.h>

#define RST_PIN 22
#define SS_PIN  21

MFRC522 rfid(SS_PIN, RST_PIN);
WiFiClient espClient;
PubSubClient mqtt(espClient);

const char* LOCATION = "STATION_A";

void ensureMqtt() {
  while (!mqtt.connected()) {
    String clientId = String("track-") + LOCATION;
    if (!mqtt.connect(clientId.c_str(), "lego", "your-password")) {
      delay(1000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  SPI.begin();
  rfid.PCD_Init();

  WiFi.begin("YourSSID", "YourPassword");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  mqtt.setServer("192.168.1.100", 1883);
}

void loop() {
  ensureMqtt();
  mqtt.loop();

  if (rfid.PICC_IsNewCardPresent() && rfid.PICC_ReadCardSerial()) {
    char tag[21] = {0};
    for (byte i = 0; i < rfid.uid.size && i < 10; i++) {
      sprintf(tag + i * 2, "%02x", rfid.uid.uidByte[i]);
    }
    String topic = String("track/location/") + LOCATION;
    mqtt.publish(topic.c_str(), tag);
    rfid.PICC_HaltA();  // the same tag is not reported again until it leaves and returns
  }
}

MQTT topics

track/location/{location}  tag ID seen at a reader
train/{id}/location        a train's last known location
train/{id}/speed           a train's current speed
train/{id}/command         commands to one train: STOP or SPEED:n
system/emergency           any message stops every train

Message flow

  1. The reader at STATION_A sees tag a1b2c3d4 and publishes it to track/location/STATION_A.
  2. The control script looks the tag up, finds train 1, and publishes train/1/location.
  3. STATION_A is a stop, so the script publishes STOP to train/1/command.
  4. Train 1 brakes and publishes its new speed.
  5. After the dwell time the script publishes SPEED:50 and the train leaves.

The Mosquitto broker

Since Mosquitto 2.0, a broker with no listener configured only accepts connections from its own machine, and anonymous clients are refused. To accept the ESP32 boards on your network, add a listener and a password file:

sudo apt install mosquitto mosquitto-clients
sudo mosquitto_passwd -c /etc/mosquitto/passwd lego

Then create /etc/mosquitto/conf.d/lego.conf with:

listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd

Restart the broker and watch the train topics:

sudo systemctl restart mosquitto
mosquitto_sub -u lego -P your-password -t "train/#" -v

Train-side ESP32 code

It uses Legoino for the hub. Legoino's last release is 1.1.0 and it does not compile against NimBLE-Arduino 2.x; users in the project's issues report that the esp32 board package 2.0.17 with NimBLE-Arduino 1.4.3 works. The ESP32 runs Wi-Fi and Bluetooth at the same time, so one board handles both MQTT and the hub.

#include <WiFi.h>
#include <PubSubClient.h>
#include "Lpf2Hub.h"

Lpf2Hub trainHub;
WiFiClient espClient;
PubSubClient mqtt(espClient);

const char* TRAIN_ID = "1";
const byte MOTOR_PORT = (byte)PoweredUpHubPort::A;

void setSpeed(int speed) {
  if (trainHub.isConnected()) {
    trainHub.setBasicMotorSpeed(MOTOR_PORT, speed);  // -100..100, 0 brakes
  }
  String topic = String("train/") + TRAIN_ID + "/speed";
  mqtt.publish(topic.c_str(), String(speed).c_str());
}

void onMessage(char* topic, byte* payload, unsigned int length) {
  String message;
  for (unsigned int i = 0; i < length; i++) message += (char)payload[i];

  if (String(topic) == "system/emergency" || message == "STOP") {
    setSpeed(0);
  } else if (message.startsWith("SPEED:")) {
    setSpeed(message.substring(6).toInt());
  }
}

void ensureMqtt() {
  while (!mqtt.connected()) {
    String clientId = String("train-") + TRAIN_ID;
    if (mqtt.connect(clientId.c_str(), "lego", "your-password")) {
      mqtt.subscribe((String("train/") + TRAIN_ID + "/command").c_str());
      mqtt.subscribe("system/emergency");
    } else {
      delay(1000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.begin("YourSSID", "YourPassword");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  mqtt.setServer("192.168.1.100", 1883);
  mqtt.setCallback(onMessage);
}

void loop() {
  ensureMqtt();
  mqtt.loop();

  if (!trainHub.isConnected() && !trainHub.isConnecting()) {
    trainHub.init();  // scan again after a drop
  }
  if (trainHub.isConnecting()) {
    trainHub.connectHub();
  }
}

The subscriptions are made after every successful connection, so a train that loses Wi-Fi rejoins its topics on its own. For more on Legoino itself, see ESP32 and LEGO Powered Up with Legoino.

The control script (Python)

It uses paho-mqtt 2.x, which needs the callback API version when the client is created. Departures run on a timer, so the script keeps receiving messages while a train waits at a station.

import threading
import paho.mqtt.client as mqtt

STOPS = {"STATION_A": 30, "STATION_B": 20}          # dwell time in seconds
TAG_TO_TRAIN = {"a1b2c3d4": "1", "e5f6a7b8": "2"}  # your tags here

def depart(client, train_id):
    client.publish(f"train/{train_id}/command", "SPEED:50")

def on_connect(client, userdata, flags, reason_code, properties):
    client.subscribe("track/location/#")

def on_message(client, userdata, msg):
    location = msg.topic.split("/")[-1]
    train_id = TAG_TO_TRAIN.get(msg.payload.decode().lower())
    if train_id is None:
        return
    client.publish(f"train/{train_id}/location", location)
    dwell = STOPS.get(location)
    if dwell:
        client.publish(f"train/{train_id}/command", "STOP")
        threading.Timer(dwell, depart, args=(client, train_id)).start()

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set("lego", "your-password")
client.on_connect = on_connect
client.on_message = on_message
client.connect("localhost", 1883)
client.loop_forever()

Block signalling builds on the same idea: keep a record of which block each train is in, and hold a train with STOP until the block ahead is clear.

Home Assistant

Home Assistant's MQTT integration can show each train and send it commands. In configuration.yaml:

mqtt:
  sensor:
    - name: "Train 1 Location"
      state_topic: "train/1/location"
    - name: "Train 1 Speed"
      state_topic: "train/1/speed"
      unit_of_measurement: "%"
  button:
    - name: "Start Train 1"
      command_topic: "train/1/command"
      payload_press: "SPEED:50"
    - name: "Stop Train 1"
      command_topic: "train/1/command"
      payload_press: "STOP"
    - name: "Emergency Stop All"
      command_topic: "system/emergency"
      payload_press: "STOP"

Start the trains in the morning

- alias: "Start trains at 9"
  triggers:
    - trigger: time
      at: "09:00:00"
  actions:
    - action: mqtt.publish
      data:
        topic: "train/1/command"
        payload: "SPEED:40"
    - delay: "00:00:05"
    - action: mqtt.publish
      data:
        topic: "train/2/command"
        payload: "SPEED:40"

Older Home Assistant installs write trigger, platform and service instead of the plural keys and action. To control the layout by voice, expose scripts or buttons that publish these commands to Alexa or Google Assistant through Home Assistant. For a single train without RFID, LEGO Home Automation: ESPHome and Home Assistant is a simpler start.

Parts list

Part How many
ESP32 board One per train and one per detection point
RC522 RFID reader One per detection point
13.56 MHz MIFARE tags or stickers One or two per train
Raspberry Pi or other always-on computer One, for the broker and the script
Small battery or USB power bank One per train, for its ESP32
LEGO Powered Up train Each train brings its own hub