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

Experimenting

The train makes experiments hard. It sleeps about a minute after you wake it, powers off after about 30 s standing still, needs a button press to come back, and takes over its motor whenever it reads an action tile. Experiments have to be planned around that.

The limits

Limit What it means for you
Sleeps about 1 min after waking Start the command first, so it is already waiting when you press the button
Powers off after about 30 s standing still No experiment can include a long pause
Sending commands does not keep it awake Only movement does
Needs a button press to wake Every failed attempt means walking over to the train
The bond goes stale every time it sleeps Reconnect code must fix this by itself, or you waste button presses
Tiles take over the motor Re-send your speed 20 times a second (20 Hz), or the train is not under your control

A bond is the saved pairing between two Bluetooth devices; see Connecting.

What worked

Wait for the train instead of scanning for it. With --timeout 0, any command waits until the train starts advertising (broadcasting that it is there). Start the command first, press the button second. This beats scanning, failing and retrying.

Subscribe first, ask questions later. Get sensor data flowing in the first second, then query ports while values are already arriving. Querying every port up front uses up your time.

Cache anything that never changes. The list of ports and modes is the same for every hub of the same type. The tool saves it in .duplo-cache.json, so later runs skip about 160 requests and start listening right away.

Use one self-contained command, not an interactive session. ./duplo lap and ./duplo next connect, run one experiment, report and exit. This was much more reliable than driving an interactive shell.

Use delta 0 to read the current value, delta 1 to see changes. With delta 1, a train standing still looks broken, because nothing changes so nothing is reported. With delta 0 you get the current value at once, followed by a flood of repeats.

Filter the log with a script, not by eye. At delta 0 a single port sends thousands of samples. Collapsing runs of identical values (awk '$1!=p{print; p=$1}') turned 2389 samples into one line.

Use timing as evidence. Tiles one track piece apart give a gap of about 2.2 s at 20% speed. A gap twice as long means a tile was missed, and where the long gap falls tells you which one. This is how I caught two tiles that had been swapped. Timing is geometry: it doesn't care what you believe the layout is.

An interactive session over a FIFO

Worth knowing, though it proved fragile. A FIFO (named pipe) keeps one connection open while you send it commands from separate shell commands:

mkfifo /tmp/in.fifo
( sleep 7200 > /tmp/in.fifo ) &        # holds the pipe open so the reader never sees EOF
./duplo shell --timeout 0 < /tmp/in.fifo 2>&1 &
echo "sub 51 0 0" > /tmp/in.fifo       # then send commands whenever

The sleep line matters. Without a writer that keeps the pipe open, the first echo closes it, the shell sees end of input (EOF) and exits.

This saved many button presses while I was exploring. But the log grew to 85 000 lines, and sometimes the session had quietly reconnected and lost its subscriptions. For anything you want to repeat, use a one-shot command.

Mistakes to avoid

pkill -f killed its own shell, twice. A command that contains the pattern text kills itself, even through a heredoc that only mentions the filename. My patch was never applied and the next run quietly used old code. Split the string ("dup""lo.py"), match on a longer path, or find the PID with pgrep first and kill that.

Stopping a background task left an orphan holding the connection. Stopping the wrapper shell does not stop the Python process inside it. The leftover process kept the train connected, while the new session sat there reporting InProgress and "not connecting". I caused it myself, but it looked exactly like a hardware fault. Always run pgrep -af duplo before blaming the train.

Redirecting output into a missing folder stopped the task before it started. The error looked like a connection failure. Check that a background task really started.

Driving blind ran the train off the track, more than once, because a 2–3 s burst at 40% goes further than the straight section. With a closed loop (the program steering by sensor feedback) this stops being a problem. Without one, keep bursts short, or push the train by hand.

A 30 s pause powered the train off mid-test. The hub switched itself off, and the next twelve "steps" went nowhere. They came back as "no change", which looked like a sensor result. Before trusting a run full of nothing, look for hub action 0x30 / 0x31 in the log (the hub's warnings that it is switching off).

Moving in 1 s steps barely moved the train (2–5% measured speed), so my careful step-by-step approach produced no data. Check the speedometer to confirm the train is really moving before you read meaning into silence.

Before you trust a null result

Silence from the train can mean many things. Before concluding "the sensor does not report X", check:

  1. Is something else holding the connection? (pgrep -af duplo)
  2. Did the hub send 0x30/0x31 (switching off) during the run?
  3. Is the train really moving? (signed SPEED on port 54)
  4. Was the subscription accepted, or did it reply with a 0x05 generic error?
  5. Is delta set so that an unchanged value would never be shown?
  6. Has the track layout changed since the last run? (Mine had.)