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

Connecting

This is where almost all my time went. The protocol was the easy part.

My setup: Arch Linux, BlueZ 5.87 (the Linux Bluetooth stack), bleak 3.0.2 (a Python Bluetooth library), Python 3.14 and an Intel AX211 adapter. Most of this page is specific to BlueZ.

No bond without an agent

A bond is the saved pairing between two Bluetooth devices. The 10427 ignores every command until the link is bonded. The ble2mqtt project already documents that.

What isn't documented, and what actually blocked me: BlueZ won't finish a bond unless some program has registered an org.bluez.Agent1, a pairing agent that answers BlueZ's pairing requests. bleak doesn't provide one. Without an agent you get a connection that looks perfectly healthy and a train that does nothing. That's the exact symptom people report and then blame on the protocol.

The train has no keypad and no screen, so it can only use "Just Works" pairing (no PIN to type or confirm). The fix is to register a NoInputNoOutput agent that accepts every request. duplo.py does this over D-Bus (Linux's system message bus) using dbus-fast, which bleak already depends on under Linux. It registers the agent as the default, pairs, and removes the agent on exit.

If you can't register an agent yourself, bluetoothctl pair <address> works as a manual fallback, because bluetoothctl registers its own agent while it runs.

Stale bonds fail silently

This is the most important thing on this page.

The train forgets its side of the bond whenever it sleeps or is reset, but BlueZ keeps its side. Encryption then fails, and the connection attempt just hangs: no error, no rejection, only a bare TimeoutError once the timeout runs out.

The sign to look for:

The connection times out while the train is advertising normally.

Advertising means the train is broadcasting that it's available. If you can see fresh advertisements and the connection still times out, suspect a stale bond first. I lost a lot of time here, because "it's advertising, so it must be reachable" sounds so reasonable.

The fix is to delete BlueZ's pairing record and bond again. duplo.py spots this case (a timeout, while the train is advertising, with a bond on record), clears the bond and pairs again automatically. --forget clears it up front.

Forget the bond before scanning

Removing a device deletes everything BlueZ knows about it. If you do it after a scan, two things break:

  1. You delete the very device you were about to connect to, and bleak then fails with device 'dev_XX_XX_...' not found.
  2. Without the bond, BlueZ can no longer link the train's changing address back to the train (see below), so your next scan sees a stranger.

I ran into both. duplo.py now removes old bonds before it starts scanning.

Scans start with cached devices

When a scan starts, BlueZ immediately reports the devices it already has in its cache (as InterfacesAdded / PropertiesChanged events). bleak passes these on as if they were just found, so a train that's switched off still shows up as "found". The connection then times out, because there's nothing there.

That's confusing, because the log says found <address> followed by a timeout, which looks exactly like the stale bond above.

The fix is to ignore anything seen in the first second or so of a scan. Only a sighting after that proves the train is really advertising. duplo.py waits 1.2 s, and when it skips a cached entry it prints seen ... in BlueZ's cache; waiting for a live advertisement, so you can tell the two problems apart in the log.

The address keeps changing

For privacy, the train advertises a random private address that changes every minute or two. Three different addresses I saw within a few minutes all belonged to the same train, and none was its fixed address (its identity address).

What this means for you:

  • Never hardcode an address from a scan. It will be out of date within minutes.
  • Once bonded, BlueZ uses a key it got during pairing (the IRK) to translate the changing address into the fixed one, so scans become stable. If you delete the bond, you're back to changing addresses.
  • Pass bleak the BLEDevice object you just found, rather than a plain address string.
  • When retrying, search for the train again instead of reusing the address that failed, unless the user gave one explicitly.

bleak and BlueZ gotchas

Don't pass services=[...] to BleakClient. If BlueZ hasn't cached the train's list of services yet, the connection fails with an empty error message. That empty error cost me a lot of debugging, and removing the filter fixed it. Always print type(exc).__name__ next to str(exc), because several of these errors print as nothing at all.

org.bluez.Error.InProgress on connect is temporary. BlueZ is still finishing the scan. Wait about 1.5 s and retry the same device; don't scan again.

org.bluez.Error.Failed: No discovery started on scanner.stop(). This happens when something else has already ended the scan. The results you collected are still good, so catch the error and carry on. Otherwise it breaks a scan that actually worked.

br-connection-canceled appeared once. It looks like an error from classic Bluetooth, but the train only supports Bluetooth Low Energy (it advertises BR/EDR not supported). So it's BlueZ getting confused, not a real problem. It didn't happen again.

Writes fail after a disconnect. Writing to a dead connection raises BleakError: Service Discovery has not been performed yet, not an obvious "disconnected" error. Catch it, treat it as a disconnect, and reconnect.

Handle Ctrl-C as a signal, not as KeyboardInterrupt. A KeyboardInterrupt cuts through asyncio.run before scanner.stop() can finish, which leaves BlueZ scanning in the background. Instead, install a SIGINT handler that sets an asyncio.Event, and let the normal shutdown code run.

Useful checks

bluetoothctl info <address>        # Paired / Bonded / Connected / AddressType / UUIDs
bluetoothctl devices Paired        # what bonds exist
bluetoothctl devices Connected     # is something holding the train?
./duplo scan --all                 # is the adapter working at all?

./duplo scan --all tells "the train is asleep" apart from "the adapter is broken". If it lists other Bluetooth devices, your setup is fine.