The tool
duplo.py is a single-file command-line tool I wrote to talk to the train
over Bluetooth Low Energy (BLE). It speaks LWP3, the LEGO protocol described on
the Protocol page, using bleak
and no LEGO libraries.
Run it through the ./duplo wrapper. On first run the wrapper creates a
Python virtual environment and installs bleak, so there is nothing to set up
or activate. It works from any directory.
Commands
./duplo scan # find hubs and identify the generation
./duplo scan --all # every BLE device — is the adapter alive?
./duplo info # hub properties + attached ports
./duplo probe # make the hub describe every port and mode
./duplo watch # subscribe to everything, print what arrives
./duplo lap --speed 25 # drive at a held speed, naming each tile read
./duplo next # drive until ONE new tile is read, then stop
./duplo shell # interactive
./duplo drive --speed 40 --seconds 5
./duplo tag 9 # write the TAG port (rejected on the 2025 hub)
./duplo sound horn # 2018 train only
./duplo led green # 2018 train only
Every command that connects to the train accepts an optional Bluetooth address. Without one, it uses the first DUPLO train it finds. It's best to leave the address out, because the train keeps changing the address it advertises.
Shared options
| Option | Meaning |
|---|---|
--timeout N |
seconds to wait for the train to appear; 0 waits until Ctrl-C |
--forget |
drop BlueZ's pairing record before connecting (normally automatic) |
--pair / --no-pair |
force bonding on or off; default is to bond for hub type 0x21 |
BlueZ is Linux's Bluetooth stack. Bonding (saving a pairing) is required by
the 2025 train, hub type 0x21.
--timeout 0 is the one to remember: start the command first, then press the
train's button.
Per-command options
scan: --seconds N (default: until Ctrl-C), --all
watch: --seconds N, --all-frames (don't dedupe unchanged values),
--mode PORT=MODE (repeatable; one mode per port is a protocol limit),
--delta N (0 = every sample), --probe / --no-probe,
--reconnect / --no-reconnect, --poke-motor (brief motor run on connect, to
prove output commands work), --verbose
lap: --speed N (negative = reverse), --seconds N, --period N
(how often to re-send the speed, default 0.05 = 20 times a second)
next: --speed N, --seconds N, --period N
drive: --speed N, --seconds N
tag: --sweep LO HI, --gap N, --port N
Interactive shell
./duplo shell keeps a connection open and lets you type commands:
<number> motor speed, -100..100
s, stop motor off (also cancels hold)
hold N keep re-asserting speed N, overriding tile actions
tag N write index N to the TAG port
sweep LO HI write every tag index in a range, 2s apart
watch subscribe to every port
sub P M [D] subscribe port P to mode M (delta D; 0 = every sample)
unsub P stop notifications on port P
ports print the port/mode map
probe re-interrogate the hub
quiet / loud stop or resume printing incoming messages
raw 08 00 81.. send arbitrary LWP3 bytes
off shut the hub down
q, quit disconnect
raw is the escape hatch: it lets you send any protocol bytes by hand.
What it handles for you
- Pairing: it registers a BlueZ pairing agent and bonds with hubs that need it.
- Stale bonds: if connecting times out while the train is advertising, it clears the out-of-date bond and tries again.
- Old scan results: it ignores the cached devices BlueZ replays when a scan starts, so "found" means the train is really there.
- Both train generations: it finds ports by device type, not port number, so it works with the 2018 and 2025 hubs.
- Port cache: it saves the probed port map to
.duplo-cache.json, per hub type, so later runs start faster. - Reconnecting: it reconnects when the train sleeps or drops the connection.
- Lost connections: writing to a dead link prints a warning instead of crashing.
- Ctrl-C: it stops the scanner cleanly when you press Ctrl-C.