Walking Pad, Controlled by Code: BLE, FTMS, and a Phone Button That Tracks Every Step

I have a Mobvoi Home walking pad under my standing desk. BLE, FTMS, Python, and a Home Assistant button: one tap to start, one to stop, every session logged.

I have a Mobvoi Home walking pad under my standing desk. I've been standing at my desk for ten years — standing is the baseline. Walking while working is the upgrade I added this January, and I immediately wanted to know: how far am I actually going? The obvious fix: one button to start the thing, one button to stop it, and every session automatically logged to Home Assistant. No phone, no app, no remote. Here's what that took.


The remote that can't be cloned

The walking pad ships with a physical remote. START/STOP and PAUSE work fine. Speed up and speed down use rolling codes — each button press generates a new code, Tuya-style — so you can clone the 433 MHz signal for power on/off with something like an eMylo S11, but not for anything useful. I confirmed it: start works, speed doesn't. Dead end.


BLE, and why this was easier than expected

The walking pad advertises over Bluetooth as "Mobvoi TM Fit" and speaks FTMS — the Fitness Machine Service, a proper Bluetooth SIG standard. No proprietary protocol to reverse-engineer.

One GATT characteristic controls everything — the Control Point, UUID 0x2AD9, handle 0x0027:

OpcodeMeaning
`01`Request Control (always first)
`07`Start / Resume
`08 01`Stop
`08 02`Pause
`03 [lo][hi]`Set Speed (0.01 km/h, little-endian)

The treadmill also streams data back on UUID 0x2ACD — current speed, total distance, elapsed time — as BLE notifications. That's where session distance comes from.


Finding the MAC address without a working BLE scan

The device only advertises for a minute or two after power-on. By the time you open a scanner, it's gone.

I tried bluetoothctl scan, hcitool lescan, and Python's Bleak library. bluetoothctl and hcitool came up empty every time I tried. Bleak was worse: BleakScanner.discover() hung forever — a D-Bus conflict between the library and BlueZ on this Ubuntu machine that I never fully diagnosed.

The MAC was hiding in the Android app logs. I enabled HCI snoop logging on the Pixel 8a, connected from the TicSports app, then pulled the log file:

adb pull /sdcard/Android/data/com.mobvoi.aitreadmill/files/tic_sport/20260623_log.txt

dumpsys bluetooth_manager masks everything as XX:XX:XX:XX:F0:09 (Android randomises BLE MACs in its output). The TicSports log doesn't. Real address: 29:BF:3D:F0:F0:09. Once you have it, you can connect directly without scanning.


Why `--char-write-req` fails — and how interactive mode saves everything

First attempt: gatttool --char-write-req -b MAC -a 0x0027 -n 0107. Error: Attribute can't be written.

The Control Point property is 0x24 — write-without-response plus indicate. --char-write-req waits for a write acknowledgement; the characteristic doesn't send one. The flag to use is --char-write, no -req. One flag, completely different result.

Second problem: two consecutive gatttool calls fail with Device or resource busy. BlueZ holds onto the connection after the first one finishes. The fix: do everything in a single interactive gatttool session, driven by a Python subprocess managing stdin/stdout with a reader thread draining output into a queue.

proc = subprocess.Popen(
    ['gatttool', '-b', '29:BF:3D:F0:F0:09', '-I'],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT, text=True
)

send('connect')
wait_for('Connection successful')          # polls the queue, 12s timeout
send('char-write-cmd 0x0027 01')          # Request Control
send('char-write-cmd 0x0027 07')          # Start
send('char-write-cmd 0x0027 036400')      # Set speed: 1.0 km/h

Connect once, send all commands, read notifications, quit. One connection per operation. The Device or resource busy problem disappears.


The start sequence that actually moves the belt

Sending 0x07 (start) alone wakes the display — it lights up and shows 0000 — but the belt doesn't move. The treadmill needs a target speed.

The sequence that reliably starts the belt:

03 64 00   ← Set speed: 1.0 km/h (100 = 0x64)
07         ← Start
03 64 00   ← Set speed again to confirm

Speed first puts the machine in a known state. Start fires the belt. The second speed command makes it stick — without it, about half the time the belt stays still even after the start opcode is accepted.

If the treadmill was in any previous state — paused, mid-session from a prior connection — prepending 08 01 (stop) before the sequence resets it cleanly.


Session tracking: JSONL and a Home Assistant webhook

The controller script is a single Python file at ~/.local/bin/treadmill. It exposes a small CLI:

treadmill start     # begins a session, starts the belt
treadmill stop      # stops the belt, logs the session, pushes to HA
treadmill pause     # pauses the belt, keeps session open
treadmill resume    # restarts from pause
treadmill speed 4.5 # change speed mid-session
treadmill stats [today|week|year|all]

On stop, it reads the last Treadmill Data notification (handle 0x0017) for the session distance, appends a line to ~/.local/share/treadmill/sessions.jsonl:

{"start": "2026-06-24T10:20:19", "end": "2026-06-24T10:31:05",
 "distance_m": 440, "distance_km": 0.44,
 "duration_s": 646, "duration_min": 10.8}

...then POSTs to a Home Assistant webhook with today's totals and year-to-date figures. Four input_number helpers update instantly.

A toggle wrapper at ~/.local/bin/treadmill-toggle handles the three states: no session → start, session active → stop, session paused → resume. A lockfile prevents the same command from firing twice if you press the button before the BLE handshake finishes — and it will: the handshake takes 5–6 seconds with no way to speed it up.


The Stream Deck that ghosted me — and why it doesn't matter

I originally wired everything to a Stream Deck Mini. Getting StreamController's EasyCommand plugin to actually execute shell commands took a full morning: the plugin wraps commands in multiprocessing.Process, which on Python 3.12 triggers a silent TypeError: 'bool' object is not callable in _after_fork — nothing runs, nothing logs, no error. Fix: patch EasyCommand to use subprocess.Popen directly. Then the double-wrapped flatpak-spawn --host, the TTY-less sudo, the setsid dance. Got it all working.

Then I discovered that the Stream Deck Mini has a known hardware defect: the membrane under key 1 (top-center) fires ghost DOWN+UP events whenever any other key is pressed or released. Confirmed by HID telemetry at the lowest level of StreamController's input stack. Every treadmill start was triggering a ghost pause. Software fixes made it worse — a broad ghost filter broke the start button entirely. The right fix is opening the case and cleaning the membrane contacts. For now, I moved the control interface entirely.


Home Assistant

Four input_number helpers:

  • treadmill_distance_today (0–50 km)
  • treadmill_duration_today (0–600 min)
  • treadmill_distance_year (0–5000 km)
  • treadmill_sessions_year (0–1000)

One webhook automation fires on POST to /api/webhook/treadmill_session_logged. The NUC sends the payload after every stop; the automation sets all four values in a single service call.

A dashboard shows the four tiles — today and year-to-date — updated the moment the session ends.


Home Assistant as the control panel

The replacement turned out cleaner than the Stream Deck ever was. Two input_button helpers in Home Assistant — Tapis Roulant START and Tapis Roulant STOP — live on the existing Tapirulan dashboard alongside the stats tiles.

On the Linux machine, a small systemd service (treadmill-ha-watcher) polls the HA REST API every two seconds and compares the last_changed timestamp of each button. When it detects a change, it calls treadmill-toggle or treadmill stop. No external libraries — just Python's built-in urllib. The token lives in ~/.local/share/treadmill/ha_token (mode 600), not in the script.

The result: open Home Assistant on the phone, tap START, the belt starts within two seconds. Tap STOP — belt stops, session logged, HA stats updated. The phone is always in reach when I'm walking. The Stream Deck is not.


End result

Tap START on the Home Assistant app: treadmill starts. Tap STOP: belt stops, session logged, Home Assistant updated. The hardware side — BLE, FTMS, the Python controller — took one evening. The Flatpak and Stream Deck debugging took most of the next morning. Moving to HA control took twenty minutes and worked first try.

The data has been rolling in since January, and for the first time I actually know how far I walk in a day. The number is humbling, which is exactly the point.

The full script is at github.com/hamen/tapirulan-home-assistant.