feat: Add FTMS BLE protocol support for KingSmith KS-HD-* treadmills#120
feat: Add FTMS BLE protocol support for KingSmith KS-HD-* treadmills#120mcdax wants to merge 21 commits into
Conversation
|
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).
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. Thank you again for your contribution and for understanding. Best regards, |
|
Wow, you set up the external library before I even had time to write my message 😄 👏 |
|
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, |
eccedb7 to
2f08a9b
Compare
…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
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>
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 aroundwalkingpad-controller'sWalkingPadController. Auto-detects protocol from BLE device name (KS-HD-*→ FTMS) or service UUIDs on first connection, delegates to the libraryconst.py— Re-exportsBeltStateandProtocolTypefrom the library, addedsession_caloriesfield toWalkingPadStatusSensors & Entities
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:
RestoreEntityDevice 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 thebluetoothlocal_name patterns inmanifest.jsonfor auto-discovery.Technical Details
FTMS Protocol (reverse-engineered from KS Fit app)
0x18260x2484, including KingSmith proprietary extension (3 bytes: uint16 LE step count + 1 zero byte)Architecture
Protocol communication is handled by walkingpad-controller, a standalone Python library on PyPI that supports both FTMS (via
bleak) and WiLink (viaph4-walkingpad) behind a unified API. The integration adds only HA-specific concerns: entities, coordinator, config flow, and the Stay Connected toggle.Tested On
KS-HD-Z1D)Files Changed
walkingpad.pywalkingpad-controllerlibrarycoordinator.pyswitch.pysensor.pynumber.pyconst.pytranslations/en.jsonmanifest.jsonwalkingpad-controllerdependency, version bumpREADME.mdNet diff vs upstream: +443 / -165 lines across 9 files
Closes #116