← Talking to a DUPLO train

Talking to a DUPLO train

experiment

How I drive the 2025 LEGO DUPLO train (10427) from a Linux laptop over Bluetooth, and what I learned on the way.

Protocol

The DUPLO train's locomotive is a Bluetooth Low Energy (BLE) device that speaks the LEGO Wireless Protocol 3.0 (LWP3). It's the same protocol used by the other Powered Up hubs (BOOST Move Hub, Technic Hub, Powered Up remote). LEGO publishes the specification, so the message formats on this page are not guesswork: https://lego.github.io/lego-ble-wireless-protocol-docs/

What I had to measure myself, everything specific to the 2025 hub, is on The 2025 hub page.

Identifying a train

Set Hub System type Bonding
10874 Steam, 10875 Cargo, 10877/10882 (2018–2024) DUPLO Train Base, Hub No. 5 0x20 not required
10427 (2025) DUPLO Train Base, Hub No. 16 0x21 required

Bonding means saving a pairing between two Bluetooth devices. The system type is byte 1 of the LEGO-specific data in the train's Bluetooth advertisement (the signal it broadcasts so it can be found), so you can tell the generations apart before connecting. ./duplo scan prints it.

The Bluetooth service

BLE devices expose data through services and characteristics (GATT). This train uses one of each:

Service 00001623-1212-efde-1623-785feabcd123
Characteristic 00001624-1212-efde-1623-785feabcd123
Bootloader service / characteristic ...1625... / ...1626...
Advertising manufacturer ID 0x0397 (LEGO System A/S)

All traffic goes through that one characteristic: commands out, sensor values in. You write to it without asking for a response, and subscribe to it for notifications.

The LEGO part of the advertisement is six bytes:

[button, system_type, capabilities, last_network_id, status, option]

A 2025 train at rest advertises 00 21 02 ff 01 00. Watch the indexing: libraries such as node-poweredup count the two-byte company ID as part of the data. That's why their code checks manufacturerData[3] for the hub type, while in bleak the same byte is manufacturer_data[0x0397][1].

The train only advertises while it is switched on and not already connected. Close the LEGO app first, and press the button to wake the train.

Message format

Every message, in both directions, looks like this:

[length, hub_id (0x00), message_type, ...payload]

length includes itself. Messages longer than 127 bytes use a two-byte length, but nothing on this train is that long.

The message types used here:

Type Direction Meaning
0x01 both Hub Properties
0x02 both Hub Actions
0x03 in Hub Alerts
0x04 in Hub Attached I/O
0x05 in Generic Error
0x21 out Port Information Request
0x22 out Port Mode Information Request
0x41 out Port Input Format Setup (Single)
0x43 in Port Information
0x44 in Port Mode Information
0x45 in Port Value (Single)
0x47 in Port Input Format (Single)
0x81 out Port Output Command
0x82 in Port Output Command Feedback

Discovering what a hub has

As soon as you subscribe to notifications, the hub sends one Hub Attached I/O (0x04) message per built-in device:

[len, 0x00, 0x04, port, event, type_lo, type_hi, hw_rev×4, sw_rev×4]

event is 0x00 detached, 0x01 attached, 0x02 attached virtual. The 16-bit device type tells you what is on the port, for example a motor or a colour sensor. Always look ports up by device type, never by port number, because the port numbers changed between train generations.

Then ask the firmware to describe each port, which beats any hand-written table:

Port Information Request        [0x05, 0x00, 0x21, port, 0x01]
Port Mode Information Request   [0x06, 0x00, 0x22, port, mode, info_type]

Info type 0x01 returns MODE INFO:

[len, 0x00, 0x43, port, 0x01, capabilities, mode_count, input_modes×2, output_modes×2]

capabilities bits: 0x01 output, 0x02 input, 0x04 logically combinable, 0x08 logically synchronizable. input_modes and output_modes are 16-bit masks with one bit per mode. On the 2025 train these are unreliable; see The 2025 hub.

Each mode information reply (0x44) carries one info_type:

info_type Payload
0x00 NAME, up to 11 chars
0x01 RAW range, two floats (min, max)
0x02 PCT range, two floats
0x03 SI range, two floats
0x04 SYMBOL (unit), up to 5 chars
0x80 VALUE FORMAT: [datasets, type, figures, decimals]

In VALUE FORMAT, type is 0 int8, 1 int16, 2 int32, 3 float. Together with datasets (how many values are in each reading), that is everything you need to decode a port value. So a client can read any device without hardcoding anything. ./duplo probe requests all of this and caches it.

Sending commands

Every write used here is a Port Output Command with the WriteDirectModeData subcommand:

[len, 0x00, 0x81, port, startup_completion, 0x51, mode, ...value]
  • startup_completion 0x11 means execute immediately (0x10) and send feedback (0x01). The feedback comes back as a 0x82 message.
  • 0x51 is the WriteDirectModeData subcommand.

So an 8-byte motor write is 08 00 81 <port> 11 51 <mode> <value>.

Reading sensors

Sensors send nothing until you ask. Turn on notifications one port at a time with Port Input Format Setup (Single):

[0x0A, 0x00, 0x41, port, mode, delta_u32_le, notify(0|1)]

delta is how much a value must change before the hub reports it. delta = 0 reports every sample: handy for seeing a stored value at once, but a flood otherwise. delta = 1 reports changes only.

One mode per port. Selecting a mode replaces whatever mode was active on that port. To read several modes of one port at once you need Combined Mode (0x42), and only ports that advertise the combinable capability support it. In practice you have to choose, for example between the speedometer's SPEED, COUNT and VELO.

Values then arrive as Port Value (Single):

[len, 0x00, 0x45, port, ...data]

Decode data using the VALUE FORMAT of the active mode. The message doesn't say which mode it is, so a client has to remember what it subscribed to.

In theory a long message can be split across BLE notifications, so a robust client rejoins them using the leading length byte. Nothing this train sends needs it.

Hub properties

Hub properties are facts about the hub itself, such as name or battery level:

Request   [0x05, 0x00, 0x01, property, 0x05]
Reply     [len, 0x00, 0x01, property, 0x06, ...value]

Operations: 0x01 set, 0x02 enable updates, 0x03 disable updates, 0x04 reset, 0x05 request update, 0x06 update.

Property Meaning
0x01 advertising name
0x02 button state
0x03 firmware version
0x04 hardware version
0x05 RSSI
0x06 battery percentage
0x07 battery type
0x0B system type ID
0x0D primary MAC address

RSSI is the received signal strength. Version numbers are packed into 32 bits, mostly as BCD (each decimal digit stored in 4 bits): major version in bits 31–28 (top bit reserved), minor in 27–24, bugfix as two BCD digits in 23–16, and build as four BCD digits in 15–0.

Hub actions

[0x04, 0x00, 0x02, action]
Code Meaning
0x01 switch off
0x02 disconnect
0x03 / 0x04 VCC port control on / off
0x05 / 0x06 activate / reset busy indication
0x2F shutdown without pre-warning
0x30 hub will switch off (sent by the hub)
0x31 hub will disconnect (sent by the hub)
0x32 hub will go into boot mode

The 2025 train sends 0x30 and then 0x31 before it powers itself off. That is a useful warning; see the power management notes on The 2025 hub.

Generic errors

[0x05, 0x00, 0x05, offending_command, error_code]
Code Meaning
0x01 ACK
0x02 MACK
0x03 buffer overflow
0x04 timeout
0x05 command not recognised
0x06 invalid use (parameter error)
0x07 overcurrent
0x08 internal error

A 0x06 reply to a 0x81 command is how I found out that a port is read-only: the hub understood the command but didn't accept the parameters.

The 2018 train

I haven't tested the 2018 train. This section comes from community sources.

Port Device type Device
0x00 0x29 motor
0x01 0x2A speaker
0x11 (17) 0x17 RGB light
0x12 (18) 0x2B colour sensor
0x13 (19) 0x2C speedometer
0x14 (20) 0x14 battery voltage
motor   08 00 81 00 11 51 00 <speed>     signed, -100..100
sound   08 00 81 01 11 51 01 <sound>     3 brake, 5 depart, 7 water, 9 horn, 10 steam
LED     08 00 81 11 11 51 00 <colour>    LWP3 colour index

LWP3 colour numbers, used by the LED and the old colour sensor: 0 black, 1 pink/magenta, 2 purple, 3 blue, 4 light blue, 5 cyan, 6 green, 7 yellow, 8 orange, 9 red, 10 white, 0xFF none.

On that hub, voltage is a raw uint16; roughly raw × 6.4 / 3047 gives volts. The 2025 hub reports millivolts directly, so don't apply that scaling there.

The tool keeps these values as fallbacks, but finds ports by device type at runtime, so it works on either generation.