walkingpad-controller 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walkingpad-controller
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Python library for controlling KingSmith WalkingPad treadmills over BLE (FTMS and legacy WiLink protocols)
5
5
  Project-URL: Homepage, https://github.com/mcdax/walkingpad-controller
6
6
  Project-URL: Repository, https://github.com/mcdax/walkingpad-controller
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "walkingpad-controller"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Python library for controlling KingSmith WalkingPad treadmills over BLE (FTMS and legacy WiLink protocols)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -132,6 +132,11 @@ class TreadmillDataFlags:
132
132
  # These devices have service 0x1826 but NOT 0xFE00.
133
133
  FTMS_NAME_PREFIXES = ("KS-HD-",)
134
134
 
135
- # Default connection parameters
136
- MAX_CONNECT_RETRIES = 3
137
- RETRY_DELAY_SECONDS = 2.0
135
+ # Default connection parameters.
136
+ # KingSmith FTMS firmware can be left in a bad state for several seconds
137
+ # after a previous abrupt disconnect — Bleak/BlueZ then accepts the next
138
+ # connect() call but the device closes the link before service discovery
139
+ # completes. A handful of retries with a few seconds between them rides
140
+ # this out reliably.
141
+ MAX_CONNECT_RETRIES = 5
142
+ RETRY_DELAY_SECONDS = 3.0
@@ -102,7 +102,16 @@ class WalkingPadController:
102
102
 
103
103
  @property
104
104
  def connected(self) -> bool:
105
- """Whether the device is currently connected."""
105
+ """Whether the device is currently connected.
106
+
107
+ Defers to the active backend so the result reflects the live BLE
108
+ state, not just a cached bool that can drift if the firmware
109
+ unilaterally drops the link before the disconnect callback fires.
110
+ """
111
+ if self._ftms is not None:
112
+ return self._ftms.connected
113
+ if self._wilink is not None:
114
+ return self._wilink.connected
106
115
  return self._connected
107
116
 
108
117
  @property
@@ -128,6 +128,12 @@ class FTMSController:
128
128
 
129
129
  Args:
130
130
  ble_device: The BLE device to connect to.
131
+
132
+ Raises:
133
+ BleakError: If the underlying BLE link drops before setup
134
+ finishes (e.g. shortly after a previous disconnect, the
135
+ firmware sometimes accepts the connection and then closes
136
+ it again before service discovery completes).
131
137
  """
132
138
  _LOGGER.info("FTMS: Connecting to %s", ble_device.address)
133
139
 
@@ -151,6 +157,18 @@ class FTMSController:
151
157
  # Request control
152
158
  await self._request_control()
153
159
 
160
+ # Sanity check: if the link dropped at any point during setup
161
+ # (Bleak's is_connected goes False, our _connected bit is flipped
162
+ # by the disconnect callback), surface that as a failed connect
163
+ # rather than silently claiming success — otherwise callers see
164
+ # `connected == False` immediately after `connect()` "succeeds"
165
+ # and have no clean signal that they should retry.
166
+ if not self.connected:
167
+ raise BleakError(
168
+ "FTMS: BLE link dropped during connection setup; treating "
169
+ "as a failed connect."
170
+ )
171
+
154
172
  async def disconnect(self) -> None:
155
173
  """Disconnect from the device."""
156
174
  if self._client and self._client.is_connected:
@@ -543,17 +561,18 @@ class FTMSController:
543
561
  if not self._has_control:
544
562
  await self._request_control()
545
563
 
546
- cold_start = await self._write_control_point(FTMSOpcode.START_OR_RESUME)
547
- if cold_start:
548
- _LOGGER.info("FTMS: START_OR_RESUME succeeded (cold start)")
549
- else:
550
- _LOGGER.debug("FTMS: START_OR_RESUME not needed (belt already running)")
564
+ if not self.connected:
565
+ _LOGGER.warning("FTMS: Not connected; cannot start")
566
+ return False
567
+
568
+ accepted = await self._write_control_point(FTMSOpcode.START_OR_RESUME)
551
569
 
552
570
  if not self.connected:
553
- _LOGGER.warning("FTMS: Connection lost after START_OR_RESUME")
571
+ _LOGGER.warning("FTMS: Connection lost during START_OR_RESUME")
554
572
  return False
555
573
 
556
- if cold_start:
574
+ if accepted:
575
+ _LOGGER.info("FTMS: START_OR_RESUME accepted (cold start)")
557
576
  belt_running = await self._wait_for_belt_moving(timeout=15.0)
558
577
  if not belt_running:
559
578
  if not self.connected:
@@ -565,8 +584,26 @@ class FTMSController:
565
584
  "FTMS: Cold start complete — belt running at %.1f km/h",
566
585
  self._status.speed,
567
586
  )
587
+ return True
588
+
589
+ # The device rejected START_OR_RESUME (non-success indication).
590
+ # That can mean either (a) belt is already running, or (b) the
591
+ # device is in a state that doesn't accept start right now —
592
+ # e.g. just transitioned through stop and isn't fully settled.
593
+ # Disambiguate by looking at the live speed.
594
+ if self._status.speed > 0:
595
+ _LOGGER.info(
596
+ "FTMS: START_OR_RESUME rejected but belt is running at "
597
+ "%.1f km/h — treating as success",
598
+ self._status.speed,
599
+ )
600
+ return True
568
601
 
569
- return True
602
+ _LOGGER.warning(
603
+ "FTMS: START_OR_RESUME rejected and belt is not moving "
604
+ "(device may need a moment after stop)"
605
+ )
606
+ return False
570
607
 
571
608
  async def _wait_for_belt_moving(self, timeout: float = 15.0) -> bool:
572
609
  """Wait for the belt to report speed > 0 after a cold start.
@@ -1,150 +0,0 @@
1
- # Issue #1 — KingSmith MC-21 speed control investigation
2
-
3
- Background: https://github.com/mcdax/walkingpad-controller/issues/1
4
-
5
- User report (`@flyzet-prog`): on a `KS-MC21-D06BFD` treadmill, start/stop work via the HA integration, but the speed slider has no effect. The HA slider only ever reflects what the physical remote sets. Reverse engineering with `nRF Connect` showed BLE writes are rejected unless KS Fit is opened first.
6
-
7
- ## TL;DR
8
-
9
- Speed control over BLE is **not possible on the MC-21**. KS Fit cannot do it either — the `setSpeed` function in KS Fit's BLE module exists only in the legacy WiLink (`0xFE00`) protocol path, and the MC-21 doesn't expose that service. Start/stop is the only thing that works on the MC-21 over BLE.
10
-
11
- The library can still be improved: it currently aborts when FTMS `REQUEST_CONTROL` fails, but KS Fit ignores that failure and proceeds. Mirroring that behavior would make start/stop reliable on MC-21.
12
-
13
- ## Evidence
14
-
15
- ### From the HCI snoop logs
16
-
17
- User attached three Bluetooth HCI snoop logs (~17 MB total). Decoded with `btmon -r`.
18
-
19
- GATT services advertised by the MC-21:
20
-
21
- | Service | UUID | Notes |
22
- |---|---|---|
23
- | Generic Access Profile | `0x1800` | standard |
24
- | Generic Attribute Profile | `0x1801` | standard |
25
- | Fitness Machine | `0x1826` | FTMS — handles `0x000e–0x0028` |
26
- | Device Information | `0x180a` | standard |
27
- | Battery Service | `0x180f` | standard |
28
- | TI vendor service | `f000ffc0-0451-4000-b000-000000000000` | TI BLE-Stack default base UUID; KS Fit does not reference it |
29
-
30
- Notable absences:
31
-
32
- - **No** `0x0000FE00` (legacy WiLink service) — this is what KingSmith's older treadmills (and the `ph4-walkingpad` library) use.
33
- - **No** `24e2521c-…fdf7` (KS-HD-* "supplement" service) — used on other FTMS-based KingSmith models for vendor commands.
34
-
35
- Inside the FTMS service there is one extra writable characteristic:
36
-
37
- - Handle `0x0028`, UUID `d18d2c10-c44c-11e8-a355-529269fb1459`, properties `0x08` (write only).
38
-
39
- KS Fit's behavior in the snoop:
40
-
41
- 1. Writes a fixed 8-byte payload `01 00 0d 00 06 0b 0f 0d` to handle `0x0028`. Done 40 times across the session, byte-for-byte identical, so the payload is *not* dynamic data (not speed, not a timestamp).
42
- 2. Writes `00` (`REQUEST_CONTROL`) to the FTMS Control Point (`0x0022`). The device responds **every single time** with the indication `80 00 04` — i.e. response code `0x80`, request opcode `0x00`, result `0x04` (`OPERATION_FAILED`). 40 attempts, 40 failures.
43
- 3. Despite that, KS Fit writes `07` (`START_OR_RESUME`) anyway, and the belt actually starts moving (Treadmill Data later shows speed climbing to 2.0 km/h).
44
- 4. **In the entire 16 MB log, KS Fit never writes opcode `0x02` (`SET_TARGET_SPEED`).** Only `0x00` and `0x07`.
45
- 5. Pairing/bonding is attempted by the Android stack (SMP `Pairing Request`) and fails (`Pairing Failed (0x05)` with reason "Unspecified"). The treadmill operates without bonding.
46
-
47
- ### From the KS Fit APKs
48
-
49
- User attached two APKs:
50
-
51
- - `KS_Fit-5.9.10.apk` (133 MB) — older release. arm64-v8a libs included.
52
- - `KS Fit_6.0.7_APKPure.xapk` (185 MB) — newer release in xapk format. Only `config.armeabi_v7a.apk` split bundled.
53
-
54
- KS Fit is built on Flutter, so the `.dex` files are mostly Flutter framework boilerplate; the actual application logic is in `lib/<arch>/libapp.so` as a Dart AOT snapshot. No Dart decompiler was used here — only `strings` on the AOT binary, which exposes class names, method names, source file paths, and log format strings via the symbol table.
55
-
56
- Key strings found in `libapp.so` (cross-checked between v5.9.10 arm64 and v6.0.7 armv7):
57
-
58
- ```
59
- package:ks_blue/src/wilink/wilink_protocol.dart
60
- package:ks_blue/src/wilink/wilink_device.dart
61
- package:ks_blue/src/wilink/treadmill_data.dart
62
- package:ks_blue/src/wilink/ftms_action_executor.dart
63
- package:ks_blue/src/spinning/spinning_ftms_device.dart
64
- package:ks_blue/src/ble/protocol.dart
65
-
66
- WilinkDeviceActionExt|setSpeed
67
- WilinkDeviceActionExt|_setSpeedCommand
68
- ------do setSpeed new speed:
69
- -----> WilinkBleDevice _winlinkDevice.setSpeed :
70
- setSpeed new:
71
-
72
- KsTreadmillDevice
73
- KsTreadmillDevice startOrPause mode:
74
- KsTreadmillDevice listenRunningStateChange event:
75
- Init KsTreadmillDevice bleModel:
76
-
77
- FtmsActionExecutor
78
- FtmsActionExecutor extParamCmd
79
- FtmsActionExecutor extVoidCmd
80
- FtmsActionExecutor supplement STR action[
81
- FtmsActionExecutor supplement UINT16 action[
82
- FtmsActionExecutor supplement Void action[
83
-
84
- 2ad9: request control, result:
85
- 2ad9: request start, result:
86
- 2ad9: request pause or stop, result:
87
- 2ad9: request reset, result:
88
- 2ad9: response:
89
- 2ad9: write error:
90
-
91
- HW-KS-HC-MC21A
92
- KS-MC21
93
- KS-SMC21C
94
- get:isMC21
95
- get:plan_not_support_mc21
96
- _uploadMc21Record@1418084129
97
- --> mc21 fireRecord
98
- ```
99
-
100
- Interpretation:
101
-
102
- - **`setSpeed` lives only in the WiLink path.** The only string `setSpeed` references (across both APK versions) are `WilinkDeviceActionExt|setSpeed`, `WilinkDeviceActionExt|_setSpeedCommand`, `WilinkBleDevice _winlinkDevice.setSpeed`. There is no FTMS-side `setSpeed`. The WiLink protocol requires the `0xFE00` service, which the MC-21 doesn't have.
103
- - **`KsTreadmillDevice` is the FTMS treadmill class.** Its visible methods are `startOrPause`, `listenRunningStateChange`, `close`, `Init`. No setSpeed, no setTargetSpeed.
104
- - **`FtmsActionExecutor` is the FTMS extension command path.** It works against the supplement service (`24e2521c-…`), with `extParamCmd` / `extVoidCmd` / `supplement STR/UINT16/Void action`. The MC-21 doesn't have the supplement service, so this path also doesn't apply.
105
- - **KS Fit explicitly knows the MC-21 is constrained.** `isMC21` getter, `plan_not_support_mc21` flag, separate `_uploadMc21Record` upload routine, dedicated MC-21 logging. The hardware identifier `HW-KS-HC-MC21A` confirms model recognition.
106
-
107
- The vendor write to handle `0x0028` (`01 00 0d 00 06 0b 0f 0d`) does not appear to come from any documented control flow in the Dart code — based on the strings, it's almost certainly KS Fit's BLE plugin opportunistically writing to a discovered writable characteristic, not a deliberate handshake. The snoops also show it doesn't unlock anything: `REQUEST_CONTROL` still fails after the write.
108
-
109
- ### Why the user's HA log shows result `5` while KS Fit gets result `4`
110
-
111
- User's HA log: `FTMS: Command 0x00 result: 5` (`CONTROL_NOT_PERMITTED`).
112
- KS Fit snoop: response indication `80 00 04` (`OPERATION_FAILED`).
113
-
114
- Both mean "no, can't do that." The exact code probably depends on prior state (whether some other client recently held control, whether the device just powered on, etc.). It's not the load-bearing detail — the load-bearing detail is that `REQUEST_CONTROL` fails on this device regardless of caller, and KS Fit just ignores it.
115
-
116
- ## Implications for the library
117
-
118
- 1. **Add MC-21 to FTMS name detection.** `FTMS_NAME_PREFIXES` is currently `("KS-HD-",)`. The MC-21 advertises as `KS-MC21-…` and falls through to the service-UUID probe path — that part works correctly today, but adding `"KS-MC21-"` (and likely `"KS-SMC21"` per the strings) would skip the probe round-trip on first connect.
119
- 2. **Tolerate `REQUEST_CONTROL` rejection.** `FTMSController._request_control` sets `_has_control` only on success; subsequent commands then keep retrying `REQUEST_CONTROL`. Per the snoop, the MC-21 will *never* grant control via REQUEST_CONTROL, but it does accept `START_OR_RESUME` / `STOP_OR_PAUSE` directly. The library should attempt control once, log the failure, and proceed — same as KS Fit. Don't gate start/stop on `_has_control`.
120
- 3. **Document MC-21 speed limitation.** Add a note (README "Known Behavior" or a per-device caveat) that on MC-21 the BLE interface exposes start/stop and telemetry only; speed is controlled exclusively by the physical remote. The slider in HA will reflect the physical speed but cannot drive it. This is a firmware constraint, not a library bug.
121
- 4. **Optional: emit a friendlier error if `SET_TARGET_SPEED` is rejected.** Today it just logs `result: 5` warnings. On a known-constrained model like MC-21 we could short-circuit and either no-op the call or raise a clearer "speed control not supported on this device" exception.
122
-
123
- ## What's not yet confirmed
124
-
125
- The strings analysis is suggestive but not definitive — class and method names exist in the symbol table, but their actual call graphs do not. To rule out the possibility that KS Fit drives speed via the vendor characteristic at handle `0x0028` with a payload format the snoop didn't capture, the user would need to:
126
-
127
- 1. Start a fresh HCI snoop.
128
- 2. Open KS Fit, connect, start the belt.
129
- 3. Explicitly drag the speed slider in the app and confirm the belt physically responds (without touching the remote).
130
- 4. Attach the resulting log.
131
-
132
- If that capture also contains only the fixed `01 00 0d 00 06 0b 0f 0d` writes, "no BLE speed control on MC-21" is settled. If it contains a different payload, that's the missing command path and worth implementing.
133
-
134
- A proper Dart decompiler (`blutter`, `reFlutter`, `Doldrums`) would also resolve the question by direct inspection of the Dart code — not used here.
135
-
136
- ## Methodology / repro
137
-
138
- ```bash
139
- # Decode HCI snoop logs
140
- btmon -r btsnoop_hci_*.log > decoded.txt
141
-
142
- # Find FTMS Control Point activity
143
- grep -nE "Handle: 0x0022 Type: Fitness Machine Control Point|Data\[" decoded.txt
144
-
145
- # Extract Flutter AOT binary from APK
146
- unzip APK.apk "lib/<arch>/libapp.so"
147
-
148
- # String inventory
149
- strings -a libapp.so | grep -iE "setSpeed|FTMS|wilink|MC21|2ad9"
150
- ```