Skip to content

Commit 0b14c45

Browse files
author
mcdax
committed
fix(BeltState.PAUSED): two regressions where pause didn't land at PAUSED
Both surfaced via tests/stress_dynamic.py (pause_resume scenario + a random walk that reconnected mid-session). Each could mis-derive belt_state as STOPPED instead of PAUSED: 1) `_paused` deceleration race in `_on_treadmill_data`. The previous version cleared `_paused` on every Treadmill Data frame with speed > 0. Pause sets the flag at speed ≈ 2.5; the ~5–10 s deceleration to zero produces dozens of speed > 0 frames, each clearing the flag, so by the time speed reached 0 the `_paused` branch was dead and belt_state derived as STOPPED. `_paused` is now set/cleared exclusively by `_on_machine_status` in response to FM Status events (STOPPED_OR_PAUSED+PAUSE, STOPPED_OR_PAUSED+STOP, STARTED_OR_RESUMED, safety-stop, and on disconnect). The Treadmill Data path only reads it. 2) "First 2ADA event after subscribe" filter dropped real ack state. The defensive filter that drops the first 2ADA event after CCCD subscribe was added because the firmware replays its prior state when notifications are enabled (so `last_fm_event` would flap on every reconnect). But when the user's first command after a fresh connect produces an ack as the first event — e.g. pause() right after reconnect → FM Status PAUSE — the filter ate it as if stale, the ack waiter got woken but _paused was never set, and belt_state ended at STOPPED. Heuristic: if the first event matches `_status_ack_expected_opcode`, treat it as the real ack and fall through to the normal handler. Otherwise (unexpected opcode, no command pending) skip as before.
1 parent 03472dd commit 0b14c45

2 files changed

Lines changed: 40 additions & 17 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "walkingpad-controller"
7-
version = "0.4.8"
7+
version = "0.4.9"
88
description = "Python library for controlling KingSmith WalkingPad treadmills over BLE (FTMS and legacy WiLink protocols)"
99
readme = "README.md"
1010
license = "MIT"

src/walkingpad_controller/ftms.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,20 @@ def _on_treadmill_data(self, sender: int, data: bytearray) -> None:
410410
speed_raw = struct.unpack_from("<H", data, offset)[0]
411411
self._status.speed = speed_raw / 100.0
412412
# Belt state derivation:
413-
# speed > 0 -> ACTIVE (also clears any pending pause flag,
414-
# since the belt is moving again)
413+
# speed > 0 -> ACTIVE
415414
# speed = 0 + paused -> PAUSED (session alive, awaiting resume)
416415
# speed = 0 + ~paused -> STOPPED (session ended or never started)
417-
# The `_paused` flag is set by _on_machine_status when the device
418-
# emits STOPPED_OR_PAUSED with PAUSE param.
416+
#
417+
# `_paused` is set/cleared exclusively by `_on_machine_status` in
418+
# response to FM Status events — NOT here. The previous version
419+
# cleared it on every speed > 0 frame, which raced with the
420+
# deceleration window after PAUSE: the FM Status PAUSE event
421+
# would set _paused = True at speed ≈ 2.5, then every Treadmill
422+
# Data frame during the 5-10 s decel cleared it again, leaving
423+
# _paused = False by the time speed reached 0 and belt_state
424+
# mis-reporting STOPPED instead of PAUSED.
419425
if speed_raw > 0:
420426
self._status.belt_state = BeltState.ACTIVE
421-
self._paused = False
422427
elif self._paused:
423428
self._status.belt_state = BeltState.PAUSED
424429
else:
@@ -525,23 +530,41 @@ def _on_machine_status(self, sender: int, data: bytearray) -> None:
525530
"FTMS: Machine status event: 0x%02x (data: %s)", opcode, data.hex()
526531
)
527532

528-
# The very first 2ADA event after subscribing is the device replaying
529-
# its current state — typically a stale STOPPED_OR_PAUSED carried over
530-
# from before we connected. Skip it so `last_fm_event` doesn't flip
531-
# to a misleading value on every reconnect. Wake any pending CP
532-
# waiter though, in case our command actually elicited the event.
533+
# The very first 2ADA event after subscribing is sometimes the
534+
# device replaying its prior state — typically a stale
535+
# STOPPED_OR_PAUSED carried over from before we connected. We
536+
# used to drop that unconditionally so `last_fm_event` wouldn't
537+
# flap on every reconnect.
538+
#
539+
# That over-filtered when the user's first command lands an ack
540+
# *as* the first event: e.g. pause() right after a fresh
541+
# connect → the FM Status PAUSE arrives before any other event,
542+
# and dropping it lost the `_paused = True` state update,
543+
# leaving belt_state derived as STOPPED instead of PAUSED.
544+
#
545+
# Heuristic: if the first event matches what we're currently
546+
# waiting for, treat it as the real ack and apply state
547+
# changes (fall through). Otherwise skip — most likely a
548+
# stale replay.
533549
if not self._machine_status_first_event_skipped:
534550
self._machine_status_first_event_skipped = True
535-
_LOGGER.debug(
536-
"FTMS: Ignoring first 2ADA event after subscribe (opcode 0x%02x)",
537-
opcode,
538-
)
539551
if (
540552
self._status_ack_expected_opcode is not None
541553
and opcode == self._status_ack_expected_opcode
542554
):
543-
self._status_ack_event.set()
544-
return
555+
_LOGGER.debug(
556+
"FTMS: First 2ADA event after subscribe matches expected "
557+
"ack opcode 0x%02x — treating as real ack, not stale replay",
558+
opcode,
559+
)
560+
# Fall through to the normal handler below.
561+
else:
562+
_LOGGER.debug(
563+
"FTMS: Ignoring first 2ADA event after subscribe "
564+
"(opcode 0x%02x, no matching pending command)",
565+
opcode,
566+
)
567+
return
545568

546569
# Record the most-recent event so callers can read it via
547570
# `controller.status.last_fm_event` and fan out to the status

0 commit comments

Comments
 (0)