Belt Control — treadmill control in the browser

How WalkingPad treadmills speak Bluetooth

Four protocol families, three of them controllable, one of them — the KingSmith 0x1234 family behind the KS-C2 — not documented anywhere before this work. Everything here was recovered from BLE traffic and a publicly distributed binary, and every frame is exercised by a test suite.

This one is for developers. It is the wiring diagram — byte-level notes for anyone writing software that drives these treadmills. If you just want to use your pad, will it work with my treadmill and when it won't connect are the pages you want.

The four families

FamilyServiceNotifyWriteStatus
Classic "WalkingPad"0000fe00fe01fe02Implemented
FTMS (Bluetooth SIG standard)000018262acd, 2ada2ad9Implemented
FitShow0000fff0fff1fff2Detect only
KingSmith 0x123400001234fed8fed7Implemented

None of these UUIDs sit on the W3C Web Bluetooth blocklist, which is what makes a browser a legal client for all of them.

The official app selects a protocol from a chip field in a catalogue of 83 products, matched against advertised name prefixes. Do not copy that approach — the mapping is not what it looks like, and the 0x1234 family was initially missed here precisely because of an assumption about it. Detect from the GATT table instead. It is both more robust and shorter.

Classic WalkingPad — fe00

Commands are six-byte frames written to fe02:

F7 A2 <cmd> <param> <crc> FD        crc = (0xA2 + cmd + param) & 0xFF
ActionFrame
Ask statsF7 A2 00 00 A2 FD
Set speedF7 A2 01 <0.1 km/h> <crc> FD — param 0 stops the belt
Set modeF7 A2 02 <0 auto / 1 manual / 2 standby> <crc> FD
Start beltF7 A2 04 01 A7 FD

Starting is a sequence, not a command: ask stats, set manual mode, start, set speed. Frames sent back to back get dropped, so they need spacing — roughly 120 ms apart, with about 400 ms after the mode byte before the start.

The leading ask stats is the part that looks like decoration and is not. Standby parks the same app-control path that has to be woken before anything works, so a start sent straight after a stop arrives at a pad that is not listening. Without it, only the first start of a session lands.

Status notifications arrive on fe01 with header F8 A2, 18 bytes, integers big-endian: belt state at [2], speed ÷10 km/h at [3], mode at [4], elapsed seconds [5..7], distance ÷100 km [8..10], steps [11..13]. The pad does not push status on its own — poll once a second.

There is no pause in this command set. Not in the vendor's app, not in any published reverse-engineering of it. Speed 0 without the standby that follows it in a stop is the obvious candidate, and whether the belt resumes from there has never been checked on real hardware, so it stays unimplemented rather than guessed.

FTMS — 1826

The Bluetooth SIG's standard Fitness Machine Service, so this half is spec-driven rather than reverse engineered. Control Point 2ad9, little-endian:

ActionBytes
Request Control — must be first00
Reset01
Set Target Speed02 <uint16 lo> <hi>, 0.01 km/h
Start / Resume07
Stop08 01
Pause08 02

Acknowledgements come back as indications 80 <reqOpCode> <result>, where 0x01 is success. Gate every command on its ack — rejection is normal in practice, not exceptional. Most units drop control permission on stop, so control has to be re-requested, including when the stop itself was rejected: a unit that refused to stop has certainly not handed control back.

Pause is real here, and only here. One op code covers start-or-resume and one covers stop-or-pause, so resuming needs no command of its own. Support cannot be discovered in advance — there is no pause bit in the Feature characteristic or anywhere else — so the spec's own answer is to send it and read the result. A unit that cannot pause replies 02 op-code-not-supported or 03 invalid-parameter, and the right response is to stop the belt and retire the button.

Treadmill Data on 2acd is a uint16 flag field followed by only the fields that are present, in spec order. Layout varies per device, so walk the flags with a cursor — fixed offsets will be wrong on somebody's pad. Note that FTMS carries no step count at all.

KingSmith 0x1234 — the undocumented one

Decoded from an iOS Bluetooth HCI capture of the official app driving a real KS-C2. As far as I can establish, this protocol had not been published anywhere before.

transport : write -> fed7 (write-without-response),  notify <- fed8
framing   : ksBase64(plaintext) + "\r", fragmented across 20-byte ATT writes
payload   : plain text, space separated — "props CurrentSpeed 1.1"

The vocabulary is not KingSmith's

Once decoded, the command set turns out to be Xiaomi's MIoT serial protocol — the vocabulary a Mi-ecosystem device's MCU speaks to its network module, tunnelled here over BLE. props, time_posix, mcu_version, version, error and the 0 success replies are all MIoT's. KingSmith is a Mi-ecosystem vendor, so this is less surprising than it first looks.

That does not decode any command the capture did not contain — the property names are still the vendor's. What it does mean is that the envelope has a published specification, which is where the error-code semantics below come from rather than from guesswork.

The encoding is base64 with a permuted alphabet

custom  SaCw4FGHIJqLhN+P9RVTU/WcY6ObDdefgEijklmnopQrsBuvMxXz1yA2t5078KZ3
std     ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

The literal lives in the app binary. One slot is inferred — index 62 never appears in the captured traffic — and every other position is confirmed against real bytes. The stored literal has two characters transposed relative to the working alphabet: solving from known plaintext gives 100% printable output only with them swapped, against 96.6% as stored.

The handshake is why passive sniffing fails

A real KS-C2 drops the link 1.8 to 3.6 seconds after connecting unless a handshake is written to it. This is the single fact that kept the protocol undocumented: a client that connects and listens is hung up on before the pad says anything worth reading. There is no secret in it — the sequence is fixed and anyone can replay it — but you cannot observe your way to it.

Sent →← Reply
shakeshake 00
time_posix <unix seconds>time_posix 0
versionversion 0014
servers getProp 1 3 7 8 9 16 17 18 19 21 22 23 24 13 15servers 0, then a config dump
props user_id <id>
get_pkget_pk 3
props ControlMode 1props ControlMode 1
servers getProp 1 9 15 2 10 11 12 13 14servers 0 — subscribes to telemetry

user_id is not an identity the protocol checks: the pad takes any integer and the app never reads it back. The value visible in a capture is whoever's account was being recorded, so the right thing to send is a random per-install number connected to no real account.

Control, and why a start gets refused

ActionMessage
Startprops ControlMode 1props runState 1
Stopprops runState 0
Pauseprops runState 0 — the same bytes as stop
Set speedprops CurrentSpeed 1.1, km/h, one decimal

Stopping hands control back to the pad's own panel, and in panel mode runState 1 is accepted and ignored — the belt simply does not move. ControlMode 1 therefore has to be re-asserted on every start, not once at handshake.

Re-asserting it is not always enough, and on a KS-C2 a start is refused often enough to be worth handling. From a real log, with the waits between attempts removed:

--> props ControlMode 1 / props runState 1
<-- props ControlMode 2 ChildLockSwitch 0 runState 0 ...   ← panel kept control
<-- props Error ErrorCode -5000                            ← refused
--> props ControlMode 1 / props runState 1
<-- props Error ErrorCode -5000                            ← refused again
--> props ControlMode 1 / props runState 1
<-- props ControlMode 1                                    ← taken
<-- props runState 1 CurrentSpeed 0.0                      ← and moving

Nothing in the capture explains what is different about the third attempt. What is usable is that the pad answers within the same second, every time — a vendor error code, or ControlMode 2 meaning the panel still holds control, or the ControlMode 1 echo that means the command was taken.

-5000 is not an arbitrary number. MIoT fixes -4001 to -4007 for its own conditions and then reserves -9999 to -5000 inclusive for codes a device vendor defines itself. So -5000 is KingSmith firmware's own, documented nowhere, and its meaning here is only what the log shows: this start will not be honoured. Treat the whole vendor band as a refusal — a sibling model may well answer -5003 — and treat none of the -400x codes that way, because none of them mean refused.

Pause is stop, and the pad is what makes it a pause

This was open for a long time. The official app visibly carries a pause alongside start and stop, and warns that speed cannot be adjusted while paused, but it builds its command templates at runtime and early captures only ever exercised runState 1. runState 2 was the obvious guess, and a guess is not something to aim at a treadmill.

Two independent things settled it:

What makes it a pause lives on the pad: the session counters survive it. In the same capture the running-time counter read 3 before the first pause and resumed at 5 → 6 after, holding still through the ten-second gap rather than resetting, and the next runState 1 picked the walk back up. The derived calorie figure did not survive it, and distance and steps were both still zero on a walk that short, so they remain unconfirmed either way.

Telemetry, and a binary sidecar nobody expected

Telemetry arrives on fed8 as props lines, often partialprops RunningSteps 3 on its own is normal, so merge updates rather than replacing state. Distance counts metres and calories count gram-calories; both are thousandths.

Interleaved with that text stream, the pad occasionally pushes short raw binary frames — no base64, no carriage return, control bytes a text line can never contain. One full exchange in the capture carries a little-endian POSIX timestamp matching the capture's own clock, which reads as a time-sync request. The answer went to a third characteristic whose UUID the capture cannot supply, because the phone had discovery cached from an earlier bond.

This matters to anyone writing a driver for these pads, because the obvious handling is wrong: those bytes used to land in the line buffer, glue themselves onto the next text line, and make the decoder reject the lot — silently eating a real speed reading. Recognise a sidecar frame by its bytes, log it, drop it.

All of this is implemented and running. Belt Control drives all four families from a browser tab, over Web Bluetooth, with no vendor app in the loop.

Open Belt Control

The full reference

This page is the tour. The complete reference — every field of every frame, the capture method with the tshark filters, the scaling arithmetic with its ground truth, the GATT dumps, and a table of the approaches that failed before the capture worked — is in the repository:

Nothing from the vendor's app is redistributed. The reference explains how to obtain the APK yourself and reproduce the analysis from it.

If you have a pad that lands on FitShow, or on nothing at all, the app's connection log is the capture that would let it be decoded properly — and it is one button to send.