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.
The four families
| Family | Service | Notify | Write | Status |
|---|---|---|---|---|
| Classic "WalkingPad" | 0000fe00 | fe01 | fe02 | Implemented |
| FTMS (Bluetooth SIG standard) | 00001826 | 2acd, 2ada | 2ad9 | Implemented |
| FitShow | 0000fff0 | fff1 | fff2 | Detect only |
KingSmith 0x1234 | 00001234 | fed8 | fed7 | Implemented |
None of these UUIDs sit on the W3C Web Bluetooth blocklist, which is what makes a browser a legal client for all of them.
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
| Action | Frame |
|---|---|
| Ask stats | F7 A2 00 00 A2 FD |
| Set speed | F7 A2 01 <0.1 km/h> <crc> FD — param 0 stops the belt |
| Set mode | F7 A2 02 <0 auto / 1 manual / 2 standby> <crc> FD |
| Start belt | F7 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:
| Action | Bytes |
|---|---|
| Request Control — must be first | 00 |
| Reset | 01 |
| Set Target Speed | 02 <uint16 lo> <hi>, 0.01 km/h |
| Start / Resume | 07 |
| Stop | 08 01 |
| Pause | 08 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 |
|---|---|
shake | shake 00 |
time_posix <unix seconds> | time_posix 0 |
version | version 0014 |
servers getProp 1 3 7 8 9 16 17 18 19 21 22 23 24 13 15 | servers 0, then a config dump |
props user_id <id> | — |
get_pk | get_pk 3 |
props ControlMode 1 | props ControlMode 1 |
servers getProp 1 9 15 2 10 11 12 13 14 | servers 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
| Action | Message |
|---|---|
| Start | props ControlMode 1 → props runState 1 |
| Stop | props runState 0 |
| Pause | props runState 0 — the same bytes as stop |
| Set speed | props 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:
- The arm64 build of the app disassembles where the 32-bit one would not, and the send
path converts its argument to exactly
0or1before storing it underrunState. It is structurally incapable of emitting a2. - A capture of the app doing reconnect → play → pause → play → pause on a real KS-C2
shows exactly four control writes:
runState 1,0,1,0. Nothing else.
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
partial — props 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.
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:
- docs/protocols.md — the protocol reference in full
- src/lib/drivers.js — the drivers themselves, deliberately plain JavaScript
- github.com/morsela/beltcontrol — the whole project, Apache-2.0
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.