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 wantf000ffc0-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 0x00with 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 returns0x06 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
0in between. - It only reads while the train is moving. When the train is standing still, it reports nothing new, whatever is under the sensor.
0means 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 sends0xa001as its first value, and100 + motor speedas 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 ID0xa001; whether other event IDs exist is unknown.VERS(mode 0) returned04 00 01 10 04 00 01 10 04 00 01 10 31 00 31 00. Read as four 32-bit numbers, that's0x10010004three times, then0x00310031. 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 then0x31(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.