walkingpad-controller 0.4.0__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.0
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.0"
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"
@@ -22,6 +22,13 @@ SUPPLEMENT_SERVICE_UUID = "24e2521c-f63b-48ed-85be-c5330a00fdf7"
22
22
  SUPPLEMENT_NOTIFY_UUID = "24e2521c-f63b-48ed-85be-c5330b00fdf7"
23
23
  SUPPLEMENT_WRITE_UUID = "24e2521c-f63b-48ed-85be-c5330d00fdf7"
24
24
 
25
+ # KingSmith MC-21 vendor pre-amble. KS Fit writes a fixed 8-byte payload to
26
+ # this characteristic (located inside the FTMS service) before every Control
27
+ # Point command. Without it, MC-21 firmware rejects SET_TARGET_SPEED with
28
+ # CONTROL_NOT_PERMITTED. See issue #1.
29
+ KINGSMITH_VENDOR_PREAMBLE_UUID = "d18d2c10-c44c-11e8-a355-529269fb1459"
30
+ KINGSMITH_VENDOR_PREAMBLE_PAYLOAD = bytes.fromhex("01000d00060b0f0d")
31
+
25
32
  # Legacy WiLink Service (for older devices)
26
33
  WILINK_SERVICE_UUID = "0000fe00-0000-1000-8000-00805f9b34fb"
27
34
 
@@ -125,6 +132,11 @@ class TreadmillDataFlags:
125
132
  # These devices have service 0x1826 but NOT 0xFE00.
126
133
  FTMS_NAME_PREFIXES = ("KS-HD-",)
127
134
 
128
- # Default connection parameters
129
- MAX_CONNECT_RETRIES = 3
130
- 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
@@ -36,6 +36,8 @@ from .const import (
36
36
  FITNESS_MACHINE_STATUS_UUID,
37
37
  FTMS_CONTROL_POINT_UUID,
38
38
  FTMS_FEATURE_UUID,
39
+ KINGSMITH_VENDOR_PREAMBLE_PAYLOAD,
40
+ KINGSMITH_VENDOR_PREAMBLE_UUID,
39
41
  SUPPLEMENT_SERVICE_UUID,
40
42
  SUPPORTED_SPEED_RANGE_UUID,
41
43
  TREADMILL_DATA_UUID,
@@ -126,6 +128,12 @@ class FTMSController:
126
128
 
127
129
  Args:
128
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).
129
137
  """
130
138
  _LOGGER.info("FTMS: Connecting to %s", ble_device.address)
131
139
 
@@ -149,6 +157,18 @@ class FTMSController:
149
157
  # Request control
150
158
  await self._request_control()
151
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
+
152
172
  async def disconnect(self) -> None:
153
173
  """Disconnect from the device."""
154
174
  if self._client and self._client.is_connected:
@@ -196,6 +216,20 @@ class FTMSController:
196
216
  except Exception:
197
217
  self._capabilities.has_supplement = False
198
218
 
219
+ # Check for the KingSmith MC-21 vendor pre-amble characteristic.
220
+ # If present, we'll write the magic payload before every Control
221
+ # Point command — that's what KS Fit does, and without it the
222
+ # firmware refuses SET_TARGET_SPEED.
223
+ try:
224
+ char = self._client.services.get_characteristic(
225
+ KINGSMITH_VENDOR_PREAMBLE_UUID
226
+ )
227
+ if char is not None:
228
+ self._capabilities.has_vendor_preamble = True
229
+ _LOGGER.info("FTMS: KingSmith vendor pre-amble characteristic detected")
230
+ except Exception:
231
+ self._capabilities.has_vendor_preamble = False
232
+
199
233
  async def _read_capabilities(self) -> None:
200
234
  """Read device capabilities from FTMS characteristics."""
201
235
  if not self._client:
@@ -424,6 +458,16 @@ class FTMSController:
424
458
  _LOGGER.warning("FTMS: Not connected, cannot send command")
425
459
  return False
426
460
 
461
+ if self._capabilities.has_vendor_preamble:
462
+ try:
463
+ await self._client.write_gatt_char(
464
+ KINGSMITH_VENDOR_PREAMBLE_UUID,
465
+ KINGSMITH_VENDOR_PREAMBLE_PAYLOAD,
466
+ response=True,
467
+ )
468
+ except BleakError as err:
469
+ _LOGGER.debug("FTMS: Vendor pre-amble write error: %s", err)
470
+
427
471
  command = bytes([opcode]) + params
428
472
  _LOGGER.debug("FTMS: Sending control point command: %s", command.hex())
429
473
 
@@ -437,10 +481,24 @@ class FTMSController:
437
481
  _LOGGER.warning("FTMS: Write error: %s", err)
438
482
  return False
439
483
 
440
- # Wait for indication response
484
+ # Wait for indication response. On firmware that uses the vendor
485
+ # pre-amble (MC-21), the device silently accepts most commands
486
+ # without sending an indication — the snoop shows only the very
487
+ # first REQUEST_CONTROL gets one. Treat timeout as success in that
488
+ # case, with a shorter wait so we don't stall the caller.
489
+ effective_timeout = (
490
+ 1.0 if self._capabilities.has_vendor_preamble else timeout
491
+ )
441
492
  try:
442
- await asyncio.wait_for(self._cp_response_event.wait(), timeout)
493
+ await asyncio.wait_for(self._cp_response_event.wait(), effective_timeout)
443
494
  except asyncio.TimeoutError:
495
+ if self._capabilities.has_vendor_preamble:
496
+ _LOGGER.debug(
497
+ "FTMS: No indication for opcode 0x%02x — assuming success "
498
+ "(vendor pre-amble path)",
499
+ opcode,
500
+ )
501
+ return True
444
502
  _LOGGER.warning(
445
503
  "FTMS: Control point response timeout for opcode 0x%02x", opcode
446
504
  )
@@ -467,13 +525,25 @@ class FTMSController:
467
525
  return False
468
526
 
469
527
  async def _request_control(self) -> bool:
470
- """Request control of the fitness machine."""
528
+ """Request control of the fitness machine.
529
+
530
+ Some KingSmith firmware (e.g. MC-21) rejects REQUEST_CONTROL with
531
+ OPERATION_FAILED / CONTROL_NOT_PERMITTED but still accepts the
532
+ commands that follow. KS Fit ignores the failure and proceeds; we
533
+ do the same — `_has_control` is set regardless of the response so
534
+ every subsequent command doesn't keep retrying a call we know will
535
+ fail. See issue #1.
536
+ """
471
537
  result = await self._write_control_point(FTMSOpcode.REQUEST_CONTROL)
538
+ self._has_control = True
472
539
  if result:
473
- self._has_control = True
474
540
  _LOGGER.info("FTMS: Control acquired")
475
541
  else:
476
- _LOGGER.warning("FTMS: Failed to acquire control")
542
+ _LOGGER.warning(
543
+ "FTMS: REQUEST_CONTROL was rejected — proceeding anyway "
544
+ "(some KingSmith firmware refuses this command but still "
545
+ "accepts START_OR_RESUME / STOP_OR_PAUSE)"
546
+ )
477
547
  return result
478
548
 
479
549
  async def start(self) -> bool:
@@ -491,17 +561,18 @@ class FTMSController:
491
561
  if not self._has_control:
492
562
  await self._request_control()
493
563
 
494
- cold_start = await self._write_control_point(FTMSOpcode.START_OR_RESUME)
495
- if cold_start:
496
- _LOGGER.info("FTMS: START_OR_RESUME succeeded (cold start)")
497
- else:
498
- _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)
499
569
 
500
570
  if not self.connected:
501
- _LOGGER.warning("FTMS: Connection lost after START_OR_RESUME")
571
+ _LOGGER.warning("FTMS: Connection lost during START_OR_RESUME")
502
572
  return False
503
573
 
504
- if cold_start:
574
+ if accepted:
575
+ _LOGGER.info("FTMS: START_OR_RESUME accepted (cold start)")
505
576
  belt_running = await self._wait_for_belt_moving(timeout=15.0)
506
577
  if not belt_running:
507
578
  if not self.connected:
@@ -513,8 +584,26 @@ class FTMSController:
513
584
  "FTMS: Cold start complete — belt running at %.1f km/h",
514
585
  self._status.speed,
515
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
516
601
 
517
- 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
518
607
 
519
608
  async def _wait_for_belt_moving(self, timeout: float = 15.0) -> bool:
520
609
  """Wait for the belt to report speed > 0 after a cold start.
@@ -73,3 +73,6 @@ class DeviceCapabilities:
73
73
 
74
74
  has_supplement: bool = False
75
75
  """Whether the KingSmith supplement service is available."""
76
+
77
+ has_vendor_preamble: bool = False
78
+ """Whether the KingSmith MC-21 vendor pre-amble characteristic is present."""