walkingpad-controller 0.2.0__tar.gz → 0.3.0__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.2.0
3
+ Version: 0.3.0
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.2.0"
7
+ version = "0.3.0"
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"
@@ -13,8 +13,11 @@ Example usage:
13
13
  controller = WalkingPadController(ble_device=device)
14
14
  await controller.connect()
15
15
 
16
- # Start at 3.0 km/h
17
- await controller.start(target_speed=3.0)
16
+ # Start the belt (runs at minimum speed)
17
+ await controller.start()
18
+
19
+ # Set desired speed via the speed slider / set_speed()
20
+ await controller.set_speed(3.0)
18
21
 
19
22
  # Get status
20
23
  print(controller.status)
@@ -40,7 +43,6 @@ from .const import (
40
43
  MAX_CONNECT_RETRIES,
41
44
  RETRY_DELAY_SECONDS,
42
45
  WILINK_SERVICE_UUID,
43
- BeltState,
44
46
  OperatingMode,
45
47
  ProtocolType,
46
48
  )
@@ -76,15 +78,6 @@ class WalkingPadController:
76
78
  self._status_callbacks: list[Callable[[TreadmillStatus], None]] = []
77
79
  self._disconnect_callbacks: list[Callable[[], None]] = []
78
80
 
79
- # Pending target speed for FTMS cold-start recovery.
80
- # When a cold START_OR_RESUME causes a BLE drop, we store the desired
81
- # speed here so it can be re-sent after reconnection.
82
- self._pending_target_speed: float | None = None
83
- self._pending_speed_task_active: bool = False
84
- # Suppresses Layer 2 pending speed during an active start() call,
85
- # since ftms.start() already handles the speed command internally.
86
- self._start_in_progress: bool = False
87
-
88
81
  # Eagerly detect protocol from BLE name
89
82
  name_protocol = self._detect_protocol_from_name()
90
83
  if name_protocol is not None:
@@ -172,73 +165,6 @@ class WalkingPadController:
172
165
  except Exception:
173
166
  _LOGGER.exception("Error in status callback")
174
167
 
175
- # FTMS cold-start recovery: if the belt is running at min speed
176
- # but we have a pending target speed, re-send it after a delay.
177
- # IMPORTANT: We must NOT send SET_TARGET_SPEED too early after a
178
- # reconnect — the BLE connection is fragile during motor startup.
179
- # Also suppress during an active start() call to avoid duplicate
180
- # speed commands (ftms.start() already handles this internally).
181
- if (
182
- self._pending_target_speed is not None
183
- and self._ftms
184
- and self._ftms.connected
185
- and not self._start_in_progress
186
- ):
187
- if status.speed > 0 and status.speed < self._pending_target_speed - 0.15:
188
- # Only fire once — check if we already have a pending task
189
- if not self._pending_speed_task_active:
190
- pending = self._pending_target_speed
191
- self._pending_target_speed = None
192
- _LOGGER.info(
193
- "Belt at %.1f km/h after reconnect, will apply "
194
- "pending target %.1f km/h after stabilization delay",
195
- status.speed,
196
- pending,
197
- )
198
- self._pending_speed_task_active = True
199
- asyncio.ensure_future(self._apply_pending_speed(pending))
200
- elif abs(status.speed - self._pending_target_speed) <= 0.15:
201
- _LOGGER.debug(
202
- "Pending target speed %.1f reached, clearing",
203
- self._pending_target_speed,
204
- )
205
- self._pending_target_speed = None
206
-
207
- async def _apply_pending_speed(self, speed: float) -> None:
208
- """Apply a pending target speed after reconnection.
209
-
210
- Waits for the BLE connection and motor to stabilize before sending
211
- SET_TARGET_SPEED. This prevents the disconnect that occurs when
212
- speed commands are sent too early after a cold start.
213
- """
214
- stabilize_delay = 5.0
215
- try:
216
- _LOGGER.info(
217
- "Waiting %.1fs for motor to stabilize before SET_TARGET_SPEED(%.1f)",
218
- stabilize_delay,
219
- speed,
220
- )
221
- await asyncio.sleep(stabilize_delay)
222
-
223
- if self._ftms and self._ftms.connected:
224
- _LOGGER.info("Re-sending SET_TARGET_SPEED(%.1f) after reconnect", speed)
225
- result = await self._ftms.set_target_speed(speed)
226
- if result:
227
- _LOGGER.info("SET_TARGET_SPEED(%.1f) applied successfully", speed)
228
- else:
229
- _LOGGER.warning(
230
- "SET_TARGET_SPEED(%.1f) failed after reconnect", speed
231
- )
232
- else:
233
- _LOGGER.warning(
234
- "Connection lost during stabilization, cannot apply speed %.1f",
235
- speed,
236
- )
237
- except BleakError as err:
238
- _LOGGER.warning("BLE error applying pending speed: %s", err)
239
- finally:
240
- self._pending_speed_task_active = False
241
-
242
168
  def _on_disconnect(self) -> None:
243
169
  """Internal handler for disconnect events from either backend."""
244
170
  _LOGGER.warning("Device disconnected")
@@ -379,49 +305,25 @@ class WalkingPadController:
379
305
 
380
306
  # --- Commands ---
381
307
 
382
- async def start(self, target_speed: float | None = None) -> bool:
383
- """Start the treadmill.
384
-
385
- For FTMS devices, this uses START_OR_RESUME (cold start) followed by
386
- SET_TARGET_SPEED with retry logic. For WiLink devices, this sends the
387
- standard start command.
308
+ async def start(self) -> bool:
309
+ """Start the treadmill belt.
388
310
 
389
- If the BLE connection drops during a cold start (common on KingSmith
390
- FTMS devices), the target speed is stored as "pending" and will be
391
- automatically re-applied after reconnection.
311
+ For FTMS devices, sends START_OR_RESUME and waits for the belt
312
+ to begin moving. Does NOT send SET_TARGET_SPEED the user
313
+ must set speed explicitly via set_speed() (e.g. the HA speed
314
+ slider). Sending a speed command during motor spin-up crashes
315
+ the BLE connection on KingSmith firmware.
392
316
 
393
- Args:
394
- target_speed: Target speed in km/h. If None, uses min speed.
317
+ For WiLink devices, sends the standard start command.
395
318
 
396
319
  Returns:
397
320
  True if the belt is running. False if the connection was lost.
398
321
  """
399
322
  if self._ftms:
400
- # Store pending speed BEFORE calling start (BLE may drop)
401
- if target_speed is not None and target_speed > self._ftms.min_speed:
402
- self._pending_target_speed = target_speed
403
-
404
- # Suppress Layer 2 pending speed during start() to avoid duplicate
405
- # SET_TARGET_SPEED commands — ftms.start() handles it internally.
406
- self._start_in_progress = True
407
- try:
408
- result = await self._ftms.start(target_speed=target_speed)
409
- finally:
410
- self._start_in_progress = False
411
-
412
- if result and self._ftms.connected:
413
- if target_speed and abs(self._ftms.status.speed - target_speed) > 0.15:
414
- _LOGGER.info(
415
- "start() completed but speed is %.1f (target %.1f), keeping pending",
416
- self._ftms.status.speed,
417
- target_speed,
418
- )
419
- else:
420
- self._pending_target_speed = None
421
- return result
323
+ return await self._ftms.start()
422
324
 
423
325
  elif self._wilink:
424
- return await self._wilink.start(target_speed=target_speed)
326
+ return await self._wilink.start()
425
327
 
426
328
  _LOGGER.warning("No protocol backend available")
427
329
  return False
@@ -432,8 +334,6 @@ class WalkingPadController:
432
334
  Returns:
433
335
  True if the command was sent successfully.
434
336
  """
435
- self._pending_target_speed = None
436
-
437
337
  if self._ftms:
438
338
  return await self._ftms.stop()
439
339
  elif self._wilink:
@@ -445,7 +345,9 @@ class WalkingPadController:
445
345
  async def set_speed(self, speed_kmh: float) -> bool:
446
346
  """Set the treadmill speed.
447
347
 
448
- If the belt is stopped, this will start it first (FTMS only).
348
+ If the belt is already running, sends SET_TARGET_SPEED directly.
349
+ If the belt is stopped, starts it first (the belt will run at
350
+ minimum speed until the user adjusts the speed slider).
449
351
 
450
352
  Args:
451
353
  speed_kmh: Target speed in km/h.
@@ -455,19 +357,20 @@ class WalkingPadController:
455
357
  """
456
358
  if self._ftms:
457
359
  if self._ftms.status.speed > 0:
360
+ # Belt already running — safe to send speed directly
458
361
  return await self._ftms.set_target_speed(speed_kmh)
459
362
  else:
460
- # Belt is stopped, need full start sequence
461
- if speed_kmh > self._ftms.min_speed:
462
- self._pending_target_speed = speed_kmh
463
- self._start_in_progress = True
464
- try:
465
- result = await self._ftms.start(target_speed=speed_kmh)
466
- finally:
467
- self._start_in_progress = False
468
- if result and self._ftms.connected:
469
- self._pending_target_speed = None
470
- return result
363
+ # Belt is stopped start it first. The user will need
364
+ # to set the desired speed once the belt is running.
365
+ _LOGGER.info(
366
+ "Belt is stopped — starting first, then setting speed %.1f",
367
+ speed_kmh,
368
+ )
369
+ started = await self._ftms.start()
370
+ if not started:
371
+ return False
372
+ # Belt is now running; send the speed command
373
+ return await self._ftms.set_target_speed(speed_kmh)
471
374
 
472
375
  elif self._wilink:
473
376
  return await self._wilink.set_target_speed(speed_kmh)
@@ -476,25 +476,18 @@ class FTMSController:
476
476
  _LOGGER.warning("FTMS: Failed to acquire control")
477
477
  return result
478
478
 
479
- async def start(self, target_speed: float | None = None) -> bool:
480
- """Start or resume the treadmill.
479
+ async def start(self) -> bool:
480
+ """Start or resume the treadmill belt.
481
481
 
482
- On KingSmith devices, a cold START_OR_RESUME is required for a stopped
483
- belt. SET_TARGET_SPEED alone gets ACKed but ignored.
484
-
485
- IMPORTANT: After a cold start, sending SET_TARGET_SPEED too early
486
- destabilizes the BLE connection and causes a disconnect. This method
487
- waits for the belt to actually start moving (speed > 0 reported via
488
- treadmill data notifications) before sending any speed command.
489
-
490
- Args:
491
- target_speed: Target speed in km/h. If None, uses min speed.
482
+ Sends START_OR_RESUME and, on a cold start, waits for the belt to
483
+ report speed > 0. Does NOT send SET_TARGET_SPEED the user sets
484
+ the speed explicitly via set_target_speed() (e.g. the HA speed
485
+ slider). Sending a speed command during motor spin-up crashes the
486
+ BLE connection on KingSmith firmware.
492
487
 
493
488
  Returns:
494
489
  True if the belt is running. False if the connection was lost.
495
490
  """
496
- speed = target_speed if target_speed is not None else self.min_speed
497
-
498
491
  if not self._has_control:
499
492
  await self._request_control()
500
493
 
@@ -502,15 +495,12 @@ class FTMSController:
502
495
  if cold_start:
503
496
  _LOGGER.info("FTMS: START_OR_RESUME succeeded (cold start)")
504
497
  else:
505
- _LOGGER.debug("FTMS: START_OR_RESUME failed (expected if already started)")
498
+ _LOGGER.debug("FTMS: START_OR_RESUME not needed (belt already running)")
506
499
 
507
500
  if not self.connected:
508
501
  _LOGGER.warning("FTMS: Connection lost after START_OR_RESUME")
509
502
  return False
510
503
 
511
- # After a cold start, wait for the belt to actually start moving before
512
- # sending SET_TARGET_SPEED. Sending speed commands during motor startup
513
- # destabilizes the BLE connection on KingSmith devices (tested on KS-Z1D).
514
504
  if cold_start:
515
505
  belt_running = await self._wait_for_belt_moving(timeout=15.0)
516
506
  if not belt_running:
@@ -519,76 +509,24 @@ class FTMSController:
519
509
  return False
520
510
  _LOGGER.warning("FTMS: Belt did not start moving within timeout")
521
511
  return False
522
-
523
- if not self.connected:
524
- _LOGGER.warning("FTMS: Connection lost before SET_TARGET_SPEED")
525
- return False
526
-
527
- # Now it's safe to set the target speed. After a cold start the device
528
- # may still need a moment to accept speed changes, so retry if needed.
529
- max_attempts = 3 if cold_start else 1
530
- for attempt in range(max_attempts):
531
- if attempt > 0:
532
- await asyncio.sleep(2.0)
533
- if not self.connected:
534
- return False
535
-
536
- result = await self.set_target_speed(speed)
537
- if not result:
538
- _LOGGER.warning(
539
- "FTMS: SET_TARGET_SPEED(%.1f) failed on attempt %d/%d",
540
- speed,
541
- attempt + 1,
542
- max_attempts,
543
- )
544
- continue
545
-
546
- # For min speed, no need to verify (belt already runs at min)
547
- if speed <= self.min_speed + 0.05:
548
- return True
549
-
550
- # Verify the speed actually changed
551
- verified = await self._verify_speed(speed, timeout=5.0)
552
- if verified:
553
- _LOGGER.info(
554
- "FTMS: Speed verified at %.1f km/h (attempt %d/%d)",
555
- speed,
556
- attempt + 1,
557
- max_attempts,
558
- )
559
- return True
560
-
561
512
  _LOGGER.info(
562
- "FTMS: Speed still %.2f after attempt %d/%d (target %.1f)",
513
+ "FTMS: Cold start complete belt running at %.1f km/h",
563
514
  self._status.speed,
564
- attempt + 1,
565
- max_attempts,
566
- speed,
567
515
  )
568
516
 
569
- _LOGGER.warning(
570
- "FTMS: Could not reach %.1f km/h after %d attempts (current: %.2f)",
571
- speed,
572
- max_attempts,
573
- self._status.speed,
574
- )
575
- return True # Belt IS running, just not at desired speed
517
+ return True
576
518
 
577
- async def _wait_for_belt_moving(
578
- self, timeout: float = 15.0, stabilize: float = 5.0
579
- ) -> bool:
580
- """Wait for the belt to start and stabilize before sending speed commands.
519
+ async def _wait_for_belt_moving(self, timeout: float = 15.0) -> bool:
520
+ """Wait for the belt to report speed > 0 after a cold start.
581
521
 
582
- After a cold start on KingSmith devices, the BLE connection is fragile
583
- while the motor is spinning up. This waits for two conditions:
584
- 1. Belt reports speed > 0 via treadmill data notifications.
585
- 2. An additional stabilization period after speed is first reported.
522
+ Polls treadmill-data notifications until the belt is physically
523
+ moving. No stabilisation delay is applied here we deliberately
524
+ avoid sending any speed command while the connection is fragile.
586
525
 
587
526
  Args:
588
527
  timeout: Maximum time to wait for speed > 0 (seconds).
589
- stabilize: Additional delay after speed detected (seconds).
590
528
 
591
- Returns True if the belt started and stabilized, False on timeout/disconnect.
529
+ Returns True if the belt is moving, False on timeout/disconnect.
592
530
  """
593
531
  _LOGGER.debug(
594
532
  "FTMS: Waiting for belt to start moving (timeout=%.0fs)...", timeout
@@ -600,38 +538,14 @@ class FTMSController:
600
538
  if self._status.speed > 0:
601
539
  wait_elapsed = timeout - (deadline - time.time())
602
540
  _LOGGER.info(
603
- "FTMS: Belt moving at %.1f km/h (waited %.1fs), "
604
- "stabilizing %.1fs before speed command...",
541
+ "FTMS: Belt moving at %.1f km/h (waited %.1fs)",
605
542
  self._status.speed,
606
543
  wait_elapsed,
607
- stabilize,
608
- )
609
- # Additional stabilization delay — the motor/firmware needs
610
- # time to settle before it can handle SET_TARGET_SPEED
611
- # without dropping the BLE connection.
612
- await asyncio.sleep(stabilize)
613
- if not self.connected:
614
- return False
615
- _LOGGER.info(
616
- "FTMS: Stabilization complete (belt at %.1f km/h)",
617
- self._status.speed,
618
544
  )
619
545
  return True
620
546
  await asyncio.sleep(0.5)
621
547
  return False
622
548
 
623
- async def _verify_speed(self, target: float, timeout: float = 3.0) -> bool:
624
- """Wait for the reported speed to match the target."""
625
- tolerance = max(self._capabilities.speed_range.increment, 0.15)
626
- deadline = time.time() + timeout
627
- while time.time() < deadline:
628
- if not self.connected:
629
- return False
630
- if abs(self._status.speed - target) <= tolerance:
631
- return True
632
- await asyncio.sleep(0.5)
633
- return False
634
-
635
549
  async def stop(self) -> bool:
636
550
  """Stop the treadmill."""
637
551
  if not self._has_control:
@@ -1,6 +1,8 @@
1
1
  """Test walkingpad-controller library against real KS-HD-Z1D device.
2
2
 
3
- Tests: scan -> connect -> read status -> start at 2.0 km/h -> observe -> stop -> disconnect
3
+ Tests the cold-start flow WITHOUT automatic SET_TARGET_SPEED:
4
+ scan -> connect -> start (no speed) -> observe belt at min speed ->
5
+ set_speed (user action) -> observe -> stop -> disconnect
4
6
  """
5
7
 
6
8
  import asyncio
@@ -18,8 +20,8 @@ logging.basicConfig(
18
20
  _LOGGER = logging.getLogger("test")
19
21
 
20
22
  DEVICE_NAME = "KS-HD-Z1D"
21
- TARGET_SPEED = 2.0 # km/h
22
- RUN_DURATION = 25 # seconds
23
+ TARGET_SPEED = 2.0 # km/h — sent AFTER belt is running (simulates user slider)
24
+ RUN_DURATION = 20 # seconds to observe after setting speed
23
25
 
24
26
 
25
27
  async def main():
@@ -90,27 +92,48 @@ async def main():
90
92
  s.steps,
91
93
  )
92
94
 
93
- # --- Step 5: Start at target speed ---
94
- _LOGGER.info("Starting at %.1f km/h...", TARGET_SPEED)
95
- result = await controller.start(target_speed=TARGET_SPEED)
95
+ # --- Step 5: Start (no target speed — belt runs at minimum) ---
96
+ _LOGGER.info("Starting belt (no target speed)...")
97
+ result = await controller.start()
96
98
  _LOGGER.info("start() returned: %s", result)
97
99
 
98
100
  if not controller.connected:
99
- _LOGGER.warning(
100
- "Connection lost during start! Waiting 10s for reconnect possibility..."
101
- )
102
- await asyncio.sleep(10)
103
- if not controller.connected:
104
- _LOGGER.error("Still disconnected. Test cannot continue.")
105
- return
101
+ _LOGGER.error("Connection lost during start! TEST FAILED.")
102
+ return
103
+
104
+ s = controller.status
105
+ _LOGGER.info(
106
+ "After start: speed=%.2f km/h (should be ~%.1f min speed)",
107
+ s.speed,
108
+ controller.min_speed,
109
+ )
110
+
111
+ # --- Step 6: Wait a moment, then set speed (simulates user slider) ---
112
+ _LOGGER.info(
113
+ "Belt running at min speed. Waiting 5s before setting speed to %.1f...",
114
+ TARGET_SPEED,
115
+ )
116
+ await asyncio.sleep(5)
117
+
118
+ if not controller.connected:
119
+ _LOGGER.error("Connection lost while waiting! TEST FAILED.")
120
+ return
106
121
 
107
- # --- Step 6: Run and observe ---
108
- _LOGGER.info("Belt should be running. Observing for %ds...", RUN_DURATION)
122
+ _LOGGER.info("Setting speed to %.1f km/h (simulates user slider)...", TARGET_SPEED)
123
+ result = await controller.set_speed(TARGET_SPEED)
124
+ _LOGGER.info("set_speed() returned: %s", result)
125
+
126
+ if not controller.connected:
127
+ _LOGGER.error("Connection lost after set_speed! TEST FAILED.")
128
+ return
129
+
130
+ # --- Step 7: Run and observe ---
131
+ _LOGGER.info("Observing for %ds...", RUN_DURATION)
109
132
  start_time = time.time()
110
133
  while time.time() - start_time < RUN_DURATION:
111
134
  if disconnected.is_set():
112
- _LOGGER.warning("Disconnected during run!")
113
- break
135
+ _LOGGER.error("Disconnected during run! TEST FAILED.")
136
+ return
114
137
  await asyncio.sleep(1)
115
138
 
116
139
  s = controller.status
@@ -123,7 +146,7 @@ async def main():
123
146
  s.steps,
124
147
  )
125
148
 
126
- # --- Step 7: Stop ---
149
+ # --- Step 8: Stop ---
127
150
  if controller.connected:
128
151
  _LOGGER.info("Stopping belt...")
129
152
  result = await controller.stop()
@@ -132,11 +155,11 @@ async def main():
132
155
  s = controller.status
133
156
  _LOGGER.info("After stop: speed=%.2f, belt=%s", s.speed, s.belt_state)
134
157
 
135
- # --- Step 8: Disconnect ---
158
+ # --- Step 9: Disconnect ---
136
159
  _LOGGER.info("Disconnecting...")
137
160
  await controller.disconnect()
138
161
  _LOGGER.info("Disconnected. Total status updates received: %d", status_count)
139
- _LOGGER.info("TEST PASSED!")
162
+ _LOGGER.info("TEST PASSED — no BLE disconnect during cold start!")
140
163
 
141
164
 
142
165
  if __name__ == "__main__":