Skip to content

feat: Add FTMS BLE protocol support for KingSmith KS-HD-* treadmills#120

Open
mcdax wants to merge 21 commits into
madmatah:mainfrom
mcdax:main
Open

feat: Add FTMS BLE protocol support for KingSmith KS-HD-* treadmills#120
mcdax wants to merge 21 commits into
madmatah:mainfrom
mcdax:main

Conversation

@mcdax
Copy link
Copy Markdown

@mcdax mcdax commented Mar 19, 2026

Summary

Add support for KingSmith treadmills that use the standard Bluetooth FTMS (Fitness Machine Service) protocol instead of the legacy WiLink protocol. This enables newer KingSmith devices (e.g. KS-Z1D, A1PRO, P1E) that advertise BLE names starting with KS-HD-* to work with this integration.

The integration remains fully backward-compatible with existing WiLink devices.

All protocol logic (FTMS + WiLink) lives in the standalone walkingpad-controller library on PyPI, following HA's recommended architecture for separating device communication from the integration.

What's Included (8 commits)

Core FTMS Support

  • walkingpad.py — Thin wrapper around walkingpad-controller's WalkingPadController. Auto-detects protocol from BLE device name (KS-HD-* → FTMS) or service UUIDs on first connection, delegates to the library
  • const.py — Re-exports BeltState and ProtocolType from the library, added session_calories field to WalkingPadStatus

Sensors & Entities

  • Steps sensor — Available for both protocols (FTMS uses pressure-sensor based step counter from KingSmith extension bytes; WiLink uses existing step count)
  • Calories sensor — FTMS-only, from FTMS Expended Energy field
  • Speed number entity — Uses dynamic min/max/step from FTMS device capabilities
  • Stay Connected switch — Per-device toggle to control persistent BLE connection (see below)

Stay Connected Switch (new feature)

BLE is exclusive — only one client can connect at a time. The new "Stay connected" switch lets users toggle whether HA maintains a persistent connection:

  • ON (default): Normal behavior — polls every 5s, sensors update live
  • OFF: HA disconnects immediately, freeing BLE for the smartphone app (KS Fit). Commands still work via connect→send→disconnect. Sensors go stale.
  • State persists across HA restarts via RestoreEntity

Device Linking (fixes #116)

All entities now provide DeviceInfo, so HA groups them under a single device with proper device-linked naming.

BLE Discovery

Added KS-HD-* to the bluetooth local_name patterns in manifest.json for auto-discovery.

Technical Details

FTMS Protocol (reverse-engineered from KS Fit app)

  • Service UUID: 0x1826
  • Control Point: Request Control → Start/Resume → Set Target Speed → Stop
  • Treadmill Data (0x2ACD): 17-byte packets with flags 0x2484, including KingSmith proprietary extension (3 bytes: uint16 LE step count + 1 zero byte)
  • Cold start requires START_OR_RESUME before SET_TARGET_SPEED; speed commands are silently ignored for several seconds after cold start (retry logic in the library handles this)

Architecture

Protocol communication is handled by walkingpad-controller, a standalone Python library on PyPI that supports both FTMS (via bleak) and WiLink (via ph4-walkingpad) behind a unified API. The integration adds only HA-specific concerns: entities, coordinator, config flow, and the Stay Connected toggle.

Tested On

  • KingSmith KS-Z1D (BLE name: KS-HD-Z1D)
  • Speed range: 1.0–6.0 km/h, increment 0.1 km/h

Files Changed

File Change
walkingpad.py Rewritten as thin wrapper around walkingpad-controller library
coordinator.py Stay connected guard in polling + toggle method
switch.py Stay Connected switch + DeviceInfo on belt switches
sensor.py Steps in common sensors, calories for FTMS, DeviceInfo
number.py Dynamic speed limits from FTMS capabilities, DeviceInfo
const.py Re-exports from library, session_calories
translations/en.json Calories + Stay connected labels
manifest.json KS-HD-* pattern, walkingpad-controller dependency, version bump
README.md Updated documentation

Net diff vs upstream: +443 / -165 lines across 9 files

Closes #116

@madmatah
Copy link
Copy Markdown
Owner

Hi mcdax,

First of all, thank you so much for this PR. The reverse engineering work you’ve done here is truly impressive.

However, I have to address a structural point regarding Home Assistant’s best practices. According to the official developer guidelines, logic for device communication (the "driver" or protocol layer) should be decoupled from the integration itself and hosted as a standalone Python library (usually on PyPI).
You can find more details on this requirement in the official documentation here:

The "Stay connected switch" is a very good idea. There are plenty of interesting things in this PR. But it would have been convenient to have a separate PR for each topic.

I'll take some time to test your fixes soon, and if that's okay with you, I'll cherry-pick them.

On a more personal note, I am unfortunately no longer able to dedicate the time necessary to maintain this project or oversee a major architectural shift like moving your code to an external library.
Since your work is valuable for the community, I strongly encourage you to keep your fork alive. I would be more than happy to add a link in my README.md to redirect users with these specific devices toward your repository. This way, your hard work gets the visibility it deserves, and users get a working solution.

Thank you again for your contribution and for understanding.

Best regards,
Matthieu

@madmatah
Copy link
Copy Markdown
Owner

Wow, you set up the external library before I even had time to write my message 😄 👏

@mcdax
Copy link
Copy Markdown
Author

mcdax commented Mar 19, 2026

Hi Matthieu,

Thanks for the thoughtful and kind response. Really appreciate you taking the time!

I should be upfront: the vast majority of this work (the reverse engineering, the code, and the library extraction) was done with the help of an AI coding agent. I'm happy to keep the fork going, and a link in your README would be amazing for discoverability. And yes, absolutely feel free to cherry-pick whatever fixes are useful to you.

Thanks again for building this integration in the first place. Without it there would have been nothing to build on!

Best,
Philipp

@mcdax mcdax force-pushed the main branch 5 times, most recently from eccedb7 to 2f08a9b Compare March 20, 2026 09:12
…tion

Fork of madmatah/hass-walkingpad with major enhancements:

- FTMS BLE protocol support via walkingpad-controller library
- Legacy WiLink protocol still supported
- Stay Connected switch for persistent BLE connection control
- Auto-toggle stay_connected on belt start/stop (HA-initiated only)
- Deferred disconnect — waits for belt to fully stop before disconnecting
- BLE operation mutex to prevent coordinator poll races
- MAC address normalization in config flow
- Device entity linking via DeviceInfo
- Step count sensor for FTMS devices via KingSmith extension bytes
- Updated README and manifest for mcdax/hass-walkingpad fork
- Version 0.4.0, requires walkingpad-controller>=0.4.0
mcdax and others added 19 commits May 4, 2026 20:18
Pulls in walkingpad-controller 0.4.1, which writes the FTMS vendor
pre-amble required by KingSmith MC-21 firmware before each Control
Point command and tolerates REQUEST_CONTROL rejection. Fixes the
speed slider on MC-21.

See mcdax/walkingpad-controller#1.
Belt switch async_turn_off used to call async_schedule_deferred_disconnect
unconditionally. The coordinator then watched for belt_state == STOPPED
and called async_set_stay_connected(False), which both disconnects BLE
and flips the user-facing "Stay connected" toggle off — even when the
user had explicitly turned that toggle on.

Track whether stay_connected was the user's persistent preference at
async_turn_on time. Only schedule the deferred disconnect on stop when
we observed it as False (i.e. the belt switch flipped it on just for
the walk). When it was already True, leave the toggle alone and keep
the connection open.

Reported on walkingpad-controller#1.
Bumps walkingpad-controller to 0.4.3 and uses its new firmware_version
property to populate DeviceInfo.sw_version on every entity. Also fills
DeviceInfo.model with the BLE-advertised name so the device card shows
both clearly (e.g. "KingSmith KS-MC21-D06BFD, FW V0.0.6").

walkingpad-controller 0.4.3 also brings:
  - eager FTMS detection for KS-MC21-*, KS-SMC21C-*, ZP-ZEALR1-*
  - staggered CCCD subscriptions matching KS Fit's connect timing
  - Fitness Machine Status (2ADA) event-based command acks on the
    vendor pre-amble path (MC-21)
  - Training Status (2AD3) subscription
Surfaces two new diagnostic sensors for FTMS devices, populated from
the 0x2AD3 (Training Status) and 0x2ADA (Fitness Machine Status)
notifications the library subscribes to:

- Training status: Bluetooth SIG FTMS standard enum (idle, warming_up,
  high_intensity_interval, cool_down, manual_mode, pre_workout, etc.).
- Last FTMS event: the opcode of the most recent state-change event
  (started_or_resumed, target_speed_changed, etc.).

Both sensors are EntityCategory.DIAGNOSTIC so they don't clutter the
main device card. Translations added for en + de.

Requires walkingpad-controller 0.4.4, which records these on
TreadmillStatus and fires the status callback when they change.
Pressing the Belt switch (manual or auto mode) no longer flips
stay_connected on/off as a side effect of starting or stopping the
belt. The toggle now reflects exactly what the user set.

Removes the entire deferred-disconnect machinery (82 lines from
coordinator.py): scheduling, timeout safety, cancellation paths,
and the corresponding _stay_connected_was_true bookkeeping in
switch.py.

If the user has stay_connected=False and presses Belt → ON, the
library still connects just long enough to send the start command,
then disconnects (existing disconnect_after_command behavior). The
belt keeps running; live sensors stay stale until the user
re-enables stay_connected.
Replaces the immediate disconnect-after-command with a debounced
5 second idle timer. Each new command cancels the pending timer
and (post-command) schedules a fresh one, so a burst of actions
holds the BLE link for the whole burst.

Behavior:
- stay_connected ON  → no idle timer; link held permanently
- stay_connected OFF → after each command, schedule disconnect in
  IDLE_DISCONNECT_TIMEOUT_SECONDS (5s); subsequent commands reset
  the timer.

The timer fires under the BLE lock and re-checks stay_connected and
connectedness before disconnecting, so it can't race a command that
started during the sleep. Toggling stay_connected (either direction)
cancels the timer; an explicit connect() also cancels.

Verified with a unit-style test using a 0.3s timeout: single command
disconnects ~300ms after; burst of 3 actions produces 1 disconnect
~300ms after the LAST action; flipping stay_connected back on cancels
the timer cleanly.
- Connected binary sensor (binary_sensor.walkingpad_connected, device
  class connectivity, diagnostic) — surfaces the BLE link state.
- Calorie rate (kcal/h, FTMS) sensor — value already on TreadmillStatus,
  was just unsurfaced.
- Heart rate (bpm, FTMS) sensor — unavailable when 0 (no HR strap
  paired); the treadmill reports 0 in that case which would otherwise
  show a misleading "0 bpm".
- Protocol sensor (ftms / wilink / unknown).
- Min speed / Max speed / Speed increment sensors — exposing device-
  capability values that already drive the speed slider, now available
  in templates and automations.
- Firmware version sensor (FTMS-only) — populated from Software
  Revision String, in addition to the existing DeviceInfo.sw_version.

Wires up the library's BLE-disconnect callback through to the
coordinator's _async_handle_disconnect (previously dead code), so
entities react to mid-session link drops promptly.

Refactors WalkingPadSensorEntityDescription.value_fn to receive the
coordinator rather than the raw status dict, with a new static=True
flag for device-info sensors that stay available even when BLE is down.
…ty_ids

Multiple bugs surfaced by interactive UI testing:

madmatah#9: speed slider was unavailable when BLE was down, so dragging it
silently no-op'd. The slider now stays available; set_native_value
relies on the underlying WalkingPad wrapper to connect-and-issue.
This is FTMS-only behavior — the WiLink belt-state guard remains.

madmatah#11: belt switch now goes unavailable when disconnected, matching
the sensor behavior so it doesn't silently no-op on toggle.

madmatah#2: manual/auto belt switch translation_keys collapse to a single
"walkingpad_belt". The mode is internal config; the entity name no
longer carries a misleading suffix.

madmatah#5 (in walkingpad-controller 0.4.5): KingSmith firmware replays the
current FM Status on CCCD subscribe. The first 2ADA event after
subscribe is now skipped, so `last_fm_event` doesn't flip to
"stopped_or_paused" on every reconnect.

#1, madmatah#3: New installs now pin entity_id to a stable English slug via
_attr_suggested_object_id, instead of letting HA derive it from the
localized friendly_name. Existing installs are migrated on integration
reload — sensor.state -> sensor.walkingpad_state,
switch.stay_connected -> switch.walkingpad_stay_connected,
number.speed -> number.walkingpad_speed, German-suffixed sensors to
their English canonical form, and so on. Action required for
automations.

madmatah#4, madmatah#8: enum sensor states are now translated. State, Mode,
Training status, Last FTMS event, and Protocol all show
user-readable values in the UI's locale instead of snake_case.

Documented as known limitations:
  - madmatah#6: KS-HD-Z1D firmware doesn't emit target_speed_changed 2ADA
    events; Last FTMS event only captures start/stop.
  - madmatah#10: rapid switch toggles within ~1s may lose the second
    command (work in progress).
The belt switch wasn't subscribed to coordinator updates and was relying
on HA's default ~30s polling. After a speed-slider drag the underlying
belt_state would update within seconds (the "Zustand" sensor reflected
this immediately), but the Belt switch toggle stayed in the wrong
position until the next poll.

WalkingPadBeltSwitchBase now sets `_attr_should_poll = False` and
subscribes to `coordinator.async_add_listener(self.async_write_ha_state)`
in `async_added_to_hass`. Same pattern the binary_sensor and
stay-connected switch already used.

Reverts a brief flirtation with an "optimistic STARTING status" patch
in walkingpad.py — that was a workaround for the missing subscription;
the proper fix is at the entity level.
Two related issues found via Playwright UX testing:

1) Belt switch was unavailable while the BLE link was down (per the
   earlier madmatah#11 fix). UX-wise this meant the user couldn't tap the belt
   toggle to start a walk while disconnected — they had to first enable
   Stay-connected, or use the slider. Reversed: belt switch is now
   always-available, mirroring the slider's madmatah#9 behavior. Tapping it
   triggers the WalkingPad wrapper's connect-and-issue sequence.

2) When KingSmith firmware drops the BLE link mid-START_OR_RESUME, the
   library does its own internal reconnect retry. The coordinator's
   single +1s reconnect attempt was racing this — it would fire while
   status was CONNECTING, bail silently, and no further reconnect
   would be triggered until polling timed out (30+ seconds, often
   minutes when the device's BLE went into a "won't accept connections"
   cooldown). Now the disconnect callback schedules a series of
   reconnect attempts at 3 s, 10 s, 30 s, and 60 s after the drop,
   covering the whole window the firmware needs to recover.

Verified end-to-end via Playwright:
  - Belt switch tap from fully disconnected state: 6.3 s to belt
    running at 1 km/h, switch flipped on within 1 ms of state change.
  - 5 s idle disconnect after action (with stay_connected=OFF) still
    works correctly.
  - Slider cold-start path on a clean device BLE: completes in 4-7 s
    without firmware drop.

Manifest version unchanged (still 0.4.9 on disk) — these patches are
deployed via SSH to the running HA instance for live iteration; no
release tagged.
Promotes the SSH-deployed coordinator + switch fixes to a versioned
release. See CHANGELOG for the full set:
  - belt toggle stays available while disconnected (auto-connects
    on tap, mirroring the slider)
  - reconnect backoff retries (+3 s / +10 s / +30 s / +60 s) covering
    the KingSmith firmware's post-disconnect cooldown window
  - Stay-connected genuinely keeps the link alive even when the user
    navigates to a different HA page
walkingpad-controller 0.4.6 stops subscribing to Training Status
(0x2AD3) on connect. That CCCD write contributed to the connect-time
disconnect rate on marginal-signal hardware (KS-HD-Z1D at -83 dBm RSSI:
0/5 reps held a 20s idle window before; 4/5 hold it now).

Since the lib no longer populates training_status, the corresponding
HA sensor would always read 'other'. Remove the sensor entity. The
field is still threaded through coord.data so a later opt-in re-subscribe
can revive the sensor without further coord-level changes.
User-visible: pressing the belt switch off no longer zeros the session
counters. The device decelerates to a halt, but time / distance /
calories / steps survive across the pause-then-resume cycle. Pressing
the switch back on continues the session from where it left off, which
is what the phone app and the physical remote already do.

Pulls in walkingpad-controller 0.4.7 which exposes pause() on the
unified controller; the FTMS-side path was already there but only
reachable through the private _ftms attribute.

Hard stop with counter reset is still available via the WalkingPad
wrapper's stop_belt() — kept as the explicit session-reset escape
hatch since most users want pause semantics on a "stop" button.

Refs mcdax/walkingpad-controller#2.
Two changes that pair with walkingpad-controller 0.4.8:

1. Translations expose a "paused" state on the walkingpad_state sensor.
   The library now emits BeltState.PAUSED when the firmware is in the
   paused-but-resumable state (FM Status STOPPED_OR_PAUSED + PAUSE
   param), so dashboards can show "Paused" instead of "Stopped" while
   the session is still alive.

2. New `button.walkingpad_stop_session` entity (icon mdi:stop). Sends
   the hard-stop variant — full session end with counter reset — for
   when the user actually wants to start over rather than resume. The
   default belt-switch toggle (since 0.4.12) sends PAUSE so this is the
   explicit path for "end this workout for real". Available only when
   the BLE link is up; gated by remote_control_enabled like the belt
   switch. Reloaded on options change so it appears / disappears with
   the rest of the remote-control entity set.

Refs mcdax/walkingpad-controller#2.
…mware

async_unload_entry now calls coordinator.async_shutdown() before
popping hass.data[DOMAIN][entry.entry_id]. Without this, the reconnect
task and the library disconnect callback registered on the old
WalkingPad/coordinator stayed alive across integration reloads, so
each subsequent reload added another listener — bringing back the
duplicate-event firing we worked hard to eliminate.

_cancel_reconnect_task() now cancels AND awaits the loop instead of
just calling cancel(). The previous code raced the in-flight
walkingpad.connect() against the immediate _async_disconnect() that
followed, both touching _connection_status. Used by both
async_set_stay_connected(False) and async_shutdown().

Push firmware version to the device registry on first status update
where it's known. Entities snapshot firmware_version into DeviceInfo
at __init__ time, but the library only reads it on connect, so the
device card otherwise stayed blank.

Defensive entity_id migration (issue madmatah#2): wrap
entity_registry.async_update_entity in try/except ValueError so an
orphan from a previous registration that occupies the canonical
entity_id no longer kills setup — we log a warning and continue.

Button entity: _handle_state_change now decorated @callback to match
the other entities and silence HA's thread-safety warnings.

ConfigFlow: drop deprecated CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
(deprecated since HA 2021.6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps walkingpad-controller dep to >=0.4.10 to pull in WiLink
disconnect propagation and the disconnect()-vs-cache fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iggers

Adds workflow_dispatch with a tag input so a release zip can be
(re)built on demand if the release event didn't fire (which has
happened when releases are created via the gh CLI).

Also fires on `release: created` in addition to `published`, so a
single API call that creates+publishes in one step can't slip
through.

Strips the leading "v" from the tag name before writing manifest.json
— previously the workflow would have set ".version" = "v0.4.14"
which HACS/HA expect to be a clean semver string.

Excludes __pycache__ / .pyc when building the zip for reproducibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stop button is a primary user-facing control (the explicit
session-end-and-reset action), not a diagnostic/config entity, so
it belongs alongside the belt switch and speed slider. Drop the
entity_category=CONFIG that was burying it under Konfiguration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps walkingpad-controller dep to >=0.4.11, which switches FTMS
connect to bleak_retry_connector.establish_connection() — fixes the
ESP_GATT_CONN_FAIL_ESTABLISH flood on ESPHome BT-proxy setups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Link Entities to Device & Update Entity Names

2 participants