← 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.

The 2025 hub

Everything on this page comes from measuring one real train, not from documentation. None of the community projects describe how this hub is laid out.

The hub

Advertising name DUPLO Train
System type 0x21 (33), DUPLO Train Base, Hub No. 16
Firmware 1.4.16.0000
Hardware 0.4.00.0000
Battery reported as 100%, ~5.4 V
Advertising flags 0x06: LE general discoverable, BR/EDR not supported (Bluetooth Low Energy only, no classic Bluetooth)

It offers two vendor-specific services (groups of Bluetooth features, each with its own ID):

  • 00001623-1212-efde-1623-785feabcd123: LWP3, the one you want
  • f000ffc0-0451-4000-b000-000000000000: Texas Instruments OAD (over-the-air firmware download)

The second ID belongs to Texas Instruments, so the chip is from TI's CC26xx family, not whatever the 2018 hub used. The hub is a fresh design, not the old one with new numbers, which is why the published port map doesn't apply. Leave the OAD service alone: it's how the firmware gets updated.

Port map

This is the hub's own description of its ports. A port is one device on the hub (motor, sensor and so on), and each port has one or more modes (different kinds of reading or command).

Port Device type Modes Declared masks
50 (0x32) 0x29 motor 0 POWER int8 −100..100 PCT · 1 SPEED int8 −100..100 PCT caps 0x00, in 0x0000, out 0x0000
51 (0x33) 0x5b undocumented 0 TAG int16 0..65535 IDX caps 0x01, in 0x0000, out 0x0001
52 (0x34) 0x5a undocumented 0 VERS 4×int32 0..255 · 1 EVENTS 2×int16 0..65535 · 2 DEBUG 4×int32 0..65535 caps 0x01, in 0x0000, out 0x0007
53 (0x35) 0x14 voltage 0 VLT L int16 0..6000 mV · 1 VLT S int16 0..6000 mV caps 0x02, in 0x0003, out 0x0000
54 (0x36) 0x2c speedometer 0 SPEED int8 −128..127 PCT · 1 COUNT int32 −8..8 CNT · 2 VELO int16 −400..400 RPM caps 0x02, in 0x0007, out 0x0000

Device types 0x5a and 0x5b appear in no published table. The old speaker (0x2a), RGB light (0x17) and colour sensor (0x2b) types are missing entirely.

Don't trust the masks

The masks are flags where each port says which modes are inputs (it sends you readings) and which are outputs (it accepts commands). Two ports contradict their own flags:

  • Port 50 (motor) declares caps 0x00 with both masks zero, meaning neither input nor output. Yet it drives the motor and reports values back.
  • Port 51 (TAG) declares itself output-only, but writing to it returns 0x06 invalid use, while subscribing to it works and streams values. It's really an input.

So the only safe approach is to try each port and see what happens. duplo.py subscribes to every port by default instead of filtering on the masks, and treats a 0x06 reply as a sign that a mode is read-only.

What each port does

Port 50: motor

POWER (mode 0) takes a signed byte from −100 to 100. Writing it works: the wheels turn. It also reports values back. Commanding 30% gave readings of 45, 48 and 52 while the train was speeding up, so the reported number isn't just the command echoed back. Most likely it's the power actually being applied while the motor ramps up. Mode 1 SPEED was never tested on its own.

Port 51: TAG, the tile sensor

This is the coloured-tile reader, and the most interesting port on the hub.

  • It's an input. Writes are rejected with 0x06.
  • It reports a 16-bit code for each tile and holds it: the value stays until it reads another tile, instead of dropping back to 0 in between.
  • It only reads while the train is moving. When the train is standing still, it reports nothing new, whatever is under the sensor.
  • 0 means nothing has been read yet (for example, right after switching on).
  • Tiles of the same colour give the same code, so it identifies the colour, not the individual tile. It reads them optically (working tiles can be made from coloured paper), and it is not the LWP3 colour index (those run from 0 to 10).

Codes seen on a six-colour loop: 1, 21, 37, 119, 191, 402.

Colour mapping: mostly unsolved

TAG code Colour Confidence
0 nothing read yet certain
1 yellow confirmed: read while sitting on a known yellow tile
37 white likely: the very next tile, reported as white
21 ? unknown
119 ? unknown
191 ? unknown
402 ? unknown

I first guessed the last four from each code's place in the repeating cycle, matched against the colour order around the loop. That guess fell apart: two tiles had been physically swapped partway through my testing, so the cycle I recorded before the swap and the colour order I noted after it describe different tracks.

The timing showed it. The tiles were one track piece apart, about 2.2 s between reads at 20% speed, and a skipped tile shows up as a gap twice as long. Running in reverse, the double gap fell between two tiles that the colour order said were neighbours, which is impossible on a fixed layout. See Open questions for how to finish the mapping properly.

Missed reads are real

Sometimes a tile just isn't read. I checked the obvious explanations, and none of them account for it:

  • Not speed. Tiles were missed at 18, 20, 25 and 30%.
  • Not direction. Tiles were missed going both forward and in reverse.
  • Not the tile routine. One idea was that the sensor stops reading while the hub plays a tile's sound-and-movement routine. If so, approaching the same tile from the other side (so after a different neighbour) should fix it. It didn't.

The same tile read fine on some runs and was skipped on others, so detection is intermittent. The cause is unknown.

Port 52: diagnostics, not tiles

  • EVENTS (mode 1) always sends 0xa001 as its first value, and 100 + motor speed as its second: 145 while driving at +45, 100 when stopped, 55 at −45. This held across many speed changes, so it simply echoes the motor speed. I only ever saw the ID 0xa001; whether other event IDs exist is unknown.
  • VERS (mode 0) returned 04 00 01 10 04 00 01 10 04 00 01 10 31 00 31 00. Read as four 32-bit numbers, that's 0x10010004 three times, then 0x00310031. Probably version numbers, but not decoded.
  • DEBUG (mode 2) was never read.

Port 53: battery

VLT L reports millivolts directly (unit mV, range 0..6000), with no scaling needed, unlike the 2018 hub. It's a real live reading: it dropped from 5414 mV to 4848 mV under motor load and recovered afterwards. I didn't look into how VLT L differs from VLT S (perhaps long and short averaging).

Port 54: speedometer

  • SPEED (mode 0) is a signed percentage, so its sign tells you the direction of travel. This is how you tell which way the train is going.
  • VELO (mode 2) is signed RPM, ±400.
  • COUNT (mode 1) is a 32-bit number but with a declared range of only −8..8, which is odd for a counter. Not investigated.

A port can only stream one mode at a time, so you have to pick one of these three.

Tiles take over the motor

When the train crosses a tile, the hub runs its own sound-and-movement routine and writes to the motor itself, overriding what you just told it. That's why the train sometimes seems to ignore you. In the data it shows up as brief negative SPEED readings in the middle of a forward run.

I found no way to switch this off. Nothing in the hub properties, port modes or hub actions looked like a "manual mode" switch, and TAG, the one port that might have triggered behaviour, rejects writes.

Sending the speed more often works. Re-sending the speed you want on a timer beats the hub's own writes:

Re-send rate Result
~7 Hz (150 ms) brief reversals still slip through
20 Hz (50 ms) tile actions fully suppressed: zero reverse readings across 20 s and six tiles

./duplo lap, ./duplo next and the shell's hold command all do this.

It sleeps a lot

This is what makes experiments hard.

  • It switches itself off after about 30 s without moving. Just before, it sends hub action 0x30 (will switch off) and then 0x31 (will disconnect).
  • Writing to it doesn't keep it awake. It switched off while I was writing to it 20 times a second. Only actual movement seems to count.
  • After waking, it stays connectable for about a minute before sleeping again.
  • Once the link drops, you have to press the button on the train to bring it back.

Two things follow. Any experiment that needs a long pause with the train standing still needs someone next to the train. And anything about the hub's fixed setup should be saved rather than asked for again. That's why duplo.py caches the port descriptions in .duplo-cache.json.

Short bursts don't move it

Driving for 1 s at 18% gives a measured speed of only 2 to 5%: the motor's slow start uses up the whole burst. So creeping forward in small steps doesn't work, and you need continuous driving to cover ground. At 20% speed, one track piece takes about 2.2 s.