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_completion0x11means execute immediately (0x10) and send feedback (0x01). The feedback comes back as a0x82message.0x51is 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.