From 9e77d1bb8d06d57861b33675b469d196dcc286a8 Mon Sep 17 00:00:00 2001 From: mcdax Date: Sat, 18 Jul 2026 10:40:28 +0200 Subject: [PATCH 1/8] Add Sperax P3 Max (wi-linktech WLT6200) protocol support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third protocol backend alongside FTMS and WiLink for the Sperax P3 Max walking pad (BLE name SPERAX_P3MAX). The device uses a wi-linktech WLT6200 module with a custom framed protocol on vendor service 0xFFF0 — not FTMS (0x1826) and not the legacy KingSmith WiLink protocol (0xFE00). Protocol (fully documented in docs/sperax-p3max-protocol.md): F5 | LEN | 00 | CMD | ARGS | CRC16 | FA - byte-stuffing: body bytes 0xF0-0xFF sent as F0 (byte & 0x0F) - CRC-16 poly 0xA327, init 0xFFFF, reflected, little-endian - commands: hello 0x01, run 0x15 (speed = km/h x10, incline 0-2), vibration 0x16 (level 1-4), status poll 0x19 - device only streams status while polled -> keep-alive poll loop Reverse-engineered from a full HCI snoop capture of a real P3 Max session; the codec is verified against 146/146 distinct captured frames and encode() reproduces every captured command frame byte-for-byte. Changes: - sperax.py: SperaxController backend + frame codec + status parser - const.py: Sperax UUIDs, name prefixes, ProtocolType.SPERAX - controller.py: auto-detection + dispatch; set_incline/set_vibration - __init__.py: export SperaxController and Sperax constants - tests/test_sperax_codec.py: hardware-free codec/parser tests - docs/sperax-p3max-protocol.md: full protocol reference Speed range (0.5-6.0 km/h) and status counters (distance/duration/steps) are best-effort pending confirmation on hardware; speed, belt state and vibration level are verified. Co-Authored-By: Claude Opus 4.8 --- docs/sperax-p3max-protocol.md | 359 +++++++++++++++++++ src/walkingpad_controller/__init__.py | 11 +- src/walkingpad_controller/const.py | 12 + src/walkingpad_controller/controller.py | 119 ++++++- src/walkingpad_controller/sperax.py | 452 ++++++++++++++++++++++++ tests/test_sperax_codec.py | 88 +++++ 6 files changed, 1037 insertions(+), 4 deletions(-) create mode 100644 docs/sperax-p3max-protocol.md create mode 100644 src/walkingpad_controller/sperax.py create mode 100644 tests/test_sperax_codec.py diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md new file mode 100644 index 0000000..ed211e9 --- /dev/null +++ b/docs/sperax-p3max-protocol.md @@ -0,0 +1,359 @@ +# Sperax P3 Max — BLE Protocol + +Reverse-engineered protocol for the **Sperax P3 Max** walking pad / vibration treadmill +(advertised BLE name `SPERAX_P3MAX`). Tracking: [issue #3](https://github.com/mcdax/hass-walkingpad/issues/3). + +The device is built around a **wi-linktech WLT6200** BLE module +(GATT *Manufacturer Name* = `wi-linktech`, *Model Number* = `WLT6200`). +It does **not** use FTMS (`0x1826`) and it is **not** the legacy KingSmith WiLink +protocol (`0xFE00` / `0xFE01` / `0xFE02`). It uses a custom framed protocol on a +vendor service, documented below. + +All command and status frames in this document were validated against a full, +unfiltered HCI snoop capture: **146/146 distinct frames pass CRC**, and a from-scratch +encoder reproduces every captured frame byte-for-byte. + +--- + +## 1. GATT layout + +| Role | Service | Characteristic | Handle | Properties | +|------|---------|----------------|--------|------------| +| **Command (write)** | `0000fff0-0000-1000-8000-00805f9b34fb` | `0000fff2-…` | `0x002c` | Write **No Response** | +| **Status (notify)** | `0000fff0-0000-1000-8000-00805f9b34fb` | `0000fff1-…` | `0x0029` | Notify | + +To talk to the device: + +1. Connect. +2. Enable notifications on `0xFFF1` (write `0x0001` to its CCCD, handle `0x002a`). +3. Send the **hello** command (§4.1) once. +4. Send a **status-poll** command (§4.4) periodically (the app does ~3×/s) to keep + the status stream flowing, and send control commands as needed. + +> A second vendor service `0x0000ff10-…` (`0xFF11` notify / `0xFF12` write) is present +> on the device but is **not** used by the official app for control. Ignore it. + +The device also exposes a standard **Device Information** service (`0x180A`): +`Manufacturer = wi-linktech`, `Model = WLT6200`, `SW Rev = R22_V227.04.03`, +`HW Rev = V1.0.0`. + +--- + +## 2. Frame format + +Every frame — in both directions — has the same envelope: + +``` +F5 | LEN | 00 | CMD | [ ARGS… ] | CRC_lo | CRC_hi | FA +``` + +| Field | Size | Description | +|-------|------|-------------| +| `F5` | 1 | Start-of-frame delimiter | +| `LEN` | 1 | **Transmitted** (post-stuffing) total frame length, in bytes | +| `00` | 1 | Constant channel/address byte (always `0x00`) | +| `CMD` | 1 | Command / message type (see §4, §5) | +| `ARGS` | n | Command arguments (may be empty) | +| `CRC` | 2 | CRC-16, little-endian (see §3) | +| `FA` | 1 | End-of-frame delimiter | + +### 2.1 Byte stuffing + +`0xF5`, `0xFA` and `0xF0` are reserved. Any **logical** byte in the range +`0xF0`–`0xFF` that would otherwise appear in the body (i.e. in `00 | CMD | ARGS | CRC`, +**not** the `F5`/`LEN`/`FA` framing) is escaped: + +``` +encode: 0xFn -> F0 0n (emit 0xF0, then the low nibble) +decode: F0 0n -> 0xF0 | 0n (= 0xFn) +``` + +Examples: `0xFA → F0 0A`, `0xF5 → F0 05`, `0xF4 → F0 04`, `0xFF → F0 0F`. + +Because stuffing changes the byte count, `LEN` is the **stuffed** length as it appears +on the wire, while the CRC is computed over the **de-stuffed** bytes (see §3). + +--- + +## 3. Checksum (CRC-16) + +| Parameter | Value | +|-----------|-------| +| Width | 16 bits | +| Polynomial | `0xA327` (reflected form, as used directly below) | +| Init | `0xFFFF` | +| Reflected | yes (input and output) | +| XOR out | `0x0000` | +| Byte order in frame | **little-endian** (`CRC_lo` first) | + +The CRC is computed over the **de-stuffed** byte sequence +`[ 0xF5, LOGICAL_LEN, 0x00, CMD, ARGS… ]`, where `LOGICAL_LEN` is the de-stuffed frame +length (which equals `LEN` when no stuffing occurred). + +Reference implementation: + +```python +def crc16(data: bytes) -> int: + c = 0xFFFF + for b in data: + c ^= b + for _ in range(8): + c = (c >> 1) ^ 0xA327 if (c & 1) else (c >> 1) + return c & 0xFFFF +``` + +### 3.1 Full encoder / decoder + +```python +def stuff(b: bytes) -> bytes: + out = bytearray() + for x in b: + if 0xF0 <= x <= 0xFF: + out += bytes([0xF0, x & 0x0F]) + else: + out.append(x) + return bytes(out) + +def destuff(b: bytes) -> bytes: + out = bytearray(); i = 0 + while i < len(b): + if b[i] == 0xF0 and i + 1 < len(b): + out.append(0xF0 | b[i + 1]); i += 2 + else: + out.append(b[i]); i += 1 + return bytes(out) + +def encode(inner: bytes) -> bytes: + # inner = the logical body WITHOUT framing/CRC, i.e. [0x00, CMD, ARGS...] + logical_len = 1 + 1 + len(inner) + 2 + 1 # F5 + LEN + inner + CRC(2) + FA + c = crc16(bytes([0xF5, logical_len]) + inner) + body = stuff(inner + bytes([c & 0xFF, (c >> 8) & 0xFF])) + tx_len = 1 + 1 + len(body) + 1 # actual on-wire length + return bytes([0xF5, tx_len]) + body + bytes([0xFA]) + +def decode(frame: bytes) -> bytes: + # returns the logical inner body [0x00, CMD, ARGS...] (CRC verified) + full = bytes([0xF5]) + destuff(frame[1:-1]) + bytes([0xFA]) + data = bytearray(full[:-3]); data[1] = len(full) # LOGICAL_LEN for CRC + lo, hi = full[-3], full[-2] + assert crc16(bytes(data)) == (hi << 8 | lo), "CRC mismatch" + return full[2:-3] +``` + +--- + +## 4. Commands (app → device, characteristic `0xFFF2`) + +All commands share the `F5 | LEN | 00 | CMD | … | CRC | FA` envelope. The +**inner body** column below is `00 | CMD | ARGS` (i.e. what `encode()` takes / `decode()` +returns). + +| CMD | Meaning | Inner body | Args | +|-----|---------|-----------|------| +| `0x01` | Hello / handshake | `00 01` | none | +| `0x15` | Belt / run control | `00 15 ` | see §4.2 | +| `0x16` | Vibration control | `00 16 ` | see §4.3 | +| `0x19` | Status poll (keep-alive) | `00 19` | none | + +### 4.1 Hello — `0x01` + +Sent once, right after connecting and enabling notifications. + +``` +00 01 (inner) +F5 07 00 01 26 D8 FA (on wire) +``` + +The device replies with a **hello response** (§5.1) carrying capability/info bytes. + +### 4.2 Belt / run control — `0x15` + +Inner body: `00 15 ` + +| Arg | Values | Meaning | +|-----|--------|---------| +| `state` | `0x01` = run, `0x02` = stop | Belt state | +| `speed` | `0x00`–… | **Target speed in km/h × 10** (e.g. `0x1E` = 3.0 km/h). `0x00` when stopping. | +| `incline` | `0x00`, `0x01`, `0x02` | Incline step | + +To **start** the belt and set a speed, send `state=0x01` with the desired `speed`. +The official app ramps the speed up by sending successive `0x15` frames with an +increasing `speed` byte, but a single frame with the final target also works. + +To **stop** the belt: `00 15 02 00 00`. + +> The captured session only exercised speeds up to 3.0 km/h and inclines 0–2. +> Higher speeds are produced by the same formula (`speed = km/h × 10`) and the CRC +> encoder above; a longer capture would be needed to confirm the device's real max +> speed and max incline. + +**Run-control reference frames** (incline `0x00`; generated by the verified encoder, +those overlapping the capture match byte-for-byte): + +| km/h | `speed` | On-wire frame | +|------|---------|---------------| +| 0.5 | `0x05` | `F5 0A 00 15 01 05 00 CB 88 FA` | +| 1.0 | `0x0A` | `F5 0A 00 15 01 0A 00 B4 41 FA` | +| 1.5 | `0x0F` | `F5 0A 00 15 01 0F 00 A4 C4 FA` | +| 2.0 | `0x14` | `F5 0A 00 15 01 14 00 05 95 FA` | +| 2.5 | `0x19` | `F5 0A 00 15 01 19 00 1F C9 FA` | +| 3.0 | `0x1E` | `F5 0A 00 15 01 1E 00 6A D9 FA` | +| 3.5 | `0x23` | `F5 0A 00 15 01 23 00 D2 DF FA` | +| 4.0 | `0x28` | `F5 0A 00 15 01 28 00 28 7A FA` | +| 4.5 | `0x2D` | `F5 0B 00 15 01 2D 00 38 F0 0F FA` ¹ | +| 5.0 | `0x32` | `F5 0A 00 15 01 32 00 1C C2 FA` | +| 5.5 | `0x37` | `F5 0A 00 15 01 37 00 0C 47 FA` | +| 6.0 | `0x3C` | `F5 0B 00 15 01 3C 00 F0 06 E2 FA` ¹ | + +¹ CRC contains a byte ≥ `0xF0`, so byte-stuffing applies and `LEN` becomes `0x0B`. + +**Incline** (at 3.0 km/h), captured: + +| Incline | Frame | +|---------|-------| +| 0 | `F5 0A 00 15 01 1E 00 6A D9 FA` | +| 1 | `F5 0A 00 15 01 1E 01 9E B7 FA` | +| 2 | `F5 0A 00 15 01 1E 02 82 04 FA` | + +### 4.3 Vibration control — `0x16` + +Inner body: `00 16 ` + +| Arg | Values | Meaning | +|-----|--------|---------| +| `state` | `0x01` = on, `0x00` = off | Vibration state | +| `level` | `0x01`–`0x04` (`0x00` when off) | Vibration intensity | + +> The belt and the vibration motor are **mutually exclusive**: enabling vibration +> stops the belt. + +Captured frames: + +| Action | Frame | +|--------|-------| +| Vibration level 1 | `F5 09 00 16 01 01 55 EA FA` | +| Vibration level 2 | `F5 09 00 16 01 02 49 59 FA` | +| Vibration level 3 | `F5 09 00 16 01 03 BD 37 FA` | +| Vibration level 4 | `F5 09 00 16 01 04 3E 79 FA` | +| Vibration off | `F5 09 00 16 00 00 34 6D FA` | + +### 4.4 Status poll — `0x19` + +Inner body: `00 19`. On wire: `F5 08 00 19 F0 0A 59 FA` +(note the CRC low byte `0xFA` is stuffed to `F0 0A`, so `LEN = 0x08`). + +Sent repeatedly (~3×/s in the app) to keep the device streaming status +notifications (§5.2). + +--- + +## 5. Notifications (device → app, characteristic `0xFFF1`) + +Same envelope. Three message types were observed: `0x01` (hello response), +`0xD0` (command ACK) and `0x19` (status). + +### 5.1 Hello response — `0x01` + +Sent once after the hello command. Inner body (example): + +``` +00 01 00 06 00 A8 27 11 01 00 05 78 03 4B 00 0A 01 04 00 +``` + +Carries device capability/config data (units, limits, etc.). Exact field layout is +not yet decoded — not required for control; useful later for exposing device limits. + +### 5.2 Status — `0x19` + +The core telemetry frame, streamed while polling. Inner body is fixed-layout +(19 bytes). Offsets below are within the **inner body** (`byte[0] = 0x00`, +`byte[1] = 0x19`): + +| Offset | Field | Notes | +|-------:|-------|-------| +| 0 | `0x00` | constant channel byte | +| 1 | `0x19` | message type | +| 2–3 | — | constant `00 00` in all samples | +| **4** | **state** | `0x10` = running, `0x0F` = decelerating/stopping, `0x01` = stopped, `0x50` = vibration mode, `0x00` = idle/off | +| 5–7 | — | constant `00 00 00` | +| **8** | **counter A** | monotonic while active (slow); resets on idle — elapsed-time-like | +| 9 | `0x00` | | +| **10** | **counter B** | slow monotonic 16-bit (with offset 9) — distance/calorie-like | +| 11 | `0x00` | | +| **12** | **counter C** | slow monotonic 16-bit (with offset 11) | +| 13 | `0x00` | | +| **14** | **counter D** | faster monotonic (steps/distance-like); resets on idle | +| **15** | **speed** | **current speed in km/h × 10** (`0x1E` = 3.0; ramps down during stop) | +| **16** | **incline** | `0x00` / `0x01` / `0x02` | +| 17 | `0x00` | | +| **18** | **vibration level** | `0x00`–`0x04`; retains last level until reset | + +The **confirmed, directly usable** fields are **state (4)**, **speed (15)**, +**incline (16)** and **vibration level (18)** — enough for Home Assistant sensors and +state. Offsets 8/10/12/14 are cumulative counters (some combination of elapsed time, +distance, steps and calories); separating them precisely needs a longer capture with +known distance/time references. + +**Worked examples** (inner body, spaces added): + +``` +running 3.0 km/h, incline 0: 00 19 00 00 10 00 00 00 10 00 01 00 01 00 0F 1E 00 00 00 +running 3.0 km/h, incline 1: 00 19 00 00 10 00 00 00 18 00 01 00 01 00 1C 1E 01 00 00 +running 3.0 km/h, incline 2: 00 19 00 00 10 00 00 00 22 00 02 00 02 00 2D 1E 02 00 00 +decelerating (speed 2.8): 00 19 00 00 0F 00 00 00 2B 00 03 00 02 00 3D 1C 00 00 00 +stopped: 00 19 00 00 01 00 00 00 2F 00 03 00 02 00 44 00 00 00 00 +vibration mode, level 1: 00 19 00 00 50 00 00 00 2F 00 03 00 02 00 44 00 00 00 01 +idle / off: 00 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 +``` + +### 5.3 Command ACK — `0xD0` + +Immediately after each control command, the device sends a `0xD0` acknowledgement +echoing the command type and its primary argument. Inner body: `00 D0 `. + +| After command | ACK inner body | +|---------------|----------------| +| Run (`0x15`, running) | `00 D0 15 01 00` | +| Stop (`0x15`, stop) | `00 D0 15 02 00` | +| Vibration on (`0x16`) | `00 D0 16 01 00` | +| Vibration off (`0x16`)| `00 D0 16 00 00` | + +--- + +## 6. Example session + +Reproduced from the reference capture (times in seconds from start): + +| Time | Direction | Meaning | Frame | +|------|-----------|---------|-------| +| 138.6 | → | Hello | `F5 07 00 01 26 D8 FA` | +| 138.8 | ← | Hello response | `F5 19 00 01 …` | +| 146.9 | → | Run, speed 0.2, incline 0 | `F5 0A 00 15 01 02 00 BE 98 FA` | +| … | → | Speed ramps 0.2 → 3.0 km/h | successive `0x15` frames | +| 167.2 | → | Run, speed 3.0, incline 0 | `F5 0A 00 15 01 1E 00 6A D9 FA` | +| 175.3 | → | Incline 1 | `F5 0A 00 15 01 1E 01 9E B7 FA` | +| 184.8 | → | Incline 2 | `F5 0A 00 15 01 1E 02 82 04 FA` | +| 194.1 | → | Stop belt | `F5 0A 00 15 02 00 00 C3 19 FA` | +| 210.2 | → | Vibration level 1 | `F5 09 00 16 01 01 55 EA FA` | +| 217.7 | → | Vibration level 2 | `F5 09 00 16 01 02 49 59 FA` | +| 225.2 | → | Vibration level 3 | `F5 09 00 16 01 03 BD 37 FA` | +| 231.5 | → | Vibration level 4 | `F5 09 00 16 01 04 3E 79 FA` | +| 238.3 | → | Vibration off | `F5 09 00 16 00 00 34 6D FA` | + +Throughout, the app sends the status poll (`F5 08 00 19 F0 0A 59 FA`) ~3×/s and the +device streams `0x19` status notifications. + +--- + +## 7. Open items + +- Decode the hello-response (`0x01`) fields → device limits / units. +- Separate status counters at offsets 8 / 10 / 12 / 14 (time vs distance vs steps vs + calories) with a longer, instrumented capture. +- Confirm the device's true **max speed** and **max incline** (capture only reached + 3.0 km/h / incline 2). + +--- + +*Derived from an unfiltered Android HCI snoop capture of a real P3 Max session. +CRC/framing verified against 146/146 distinct captured frames.* diff --git a/src/walkingpad_controller/__init__.py b/src/walkingpad_controller/__init__.py index bda7ce2..1f4e3df 100644 --- a/src/walkingpad_controller/__init__.py +++ b/src/walkingpad_controller/__init__.py @@ -1,7 +1,8 @@ """walkingpad-controller — Python library for controlling WalkingPad treadmills over BLE. -Supports both FTMS (Fitness Machine Service) and legacy WiLink protocols. -Protocol is auto-detected based on the BLE device name and services. +Supports FTMS (Fitness Machine Service), legacy WiLink, and the Sperax / +wi-linktech (WLT6200) protocols. Protocol is auto-detected based on the BLE +device name and services. Quick start: @@ -20,6 +21,8 @@ from .const import ( FTMS_NAME_PREFIXES, FTMS_SERVICE_UUID, + SPERAX_NAME_PREFIXES, + SPERAX_SERVICE_UUID, WILINK_SERVICE_UUID, BeltState, FTMSOpcode, @@ -30,6 +33,7 @@ from .controller import WalkingPadController from .ftms import FTMSController from .models import DeviceCapabilities, SpeedRange, TreadmillStatus +from .sperax import SperaxController from .wilink import WiLinkController __version__ = "0.4.0" @@ -40,6 +44,7 @@ # Protocol-specific controllers "FTMSController", "WiLinkController", + "SperaxController", # Data models "TreadmillStatus", "SpeedRange", @@ -53,5 +58,7 @@ # Constants "FTMS_SERVICE_UUID", "WILINK_SERVICE_UUID", + "SPERAX_SERVICE_UUID", "FTMS_NAME_PREFIXES", + "SPERAX_NAME_PREFIXES", ] diff --git a/src/walkingpad_controller/const.py b/src/walkingpad_controller/const.py index 445032f..f7292cc 100644 --- a/src/walkingpad_controller/const.py +++ b/src/walkingpad_controller/const.py @@ -33,6 +33,13 @@ # Legacy WiLink Service (for older devices) WILINK_SERVICE_UUID = "0000fe00-0000-1000-8000-00805f9b34fb" +# Sperax / wi-linktech (WLT6200) vendor service — Sperax P3 Max walking pad. +# Custom framed protocol (F5 .. FA, CRC-16/0xA327). Not FTMS, not WiLink. +# See docs/sperax-p3max-protocol.md. +SPERAX_SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb" +SPERAX_WRITE_UUID = "0000fff2-0000-1000-8000-00805f9b34fb" # write-no-response +SPERAX_NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb" # notify + # --- Enums --- @@ -43,6 +50,7 @@ class ProtocolType(Enum): WILINK = "wilink" # Legacy protocol (service 0xFE00, ph4-walkingpad) FTMS = "ftms" # Standard FTMS (service 0x1826) + SPERAX = "sperax" # Sperax P3 Max / wi-linktech WLT6200 (service 0xFFF0) UNKNOWN = "unknown" @@ -257,6 +265,10 @@ class TreadmillDataFlags: # getter matches all three). FTMS_NAME_PREFIXES = ("KS-HD-", "KS-MC21-", "KS-SMC21C-", "ZP-ZEALR1-") +# BLE name prefixes for the Sperax / wi-linktech (WLT6200) vendor protocol. +# The P3 Max advertises as "SPERAX_P3MAX". +SPERAX_NAME_PREFIXES = ("SPERAX_",) + # Default connection parameters. # KingSmith FTMS firmware can be left in a bad state for several seconds # after a previous abrupt disconnect — Bleak/BlueZ then accepts the next diff --git a/src/walkingpad_controller/controller.py b/src/walkingpad_controller/controller.py index ae37b58..cf21f3f 100644 --- a/src/walkingpad_controller/controller.py +++ b/src/walkingpad_controller/controller.py @@ -43,6 +43,8 @@ FTMS_SERVICE_UUID, MAX_CONNECT_RETRIES, RETRY_DELAY_SECONDS, + SPERAX_NAME_PREFIXES, + SPERAX_SERVICE_UUID, WILINK_SERVICE_UUID, OperatingMode, ProtocolType, @@ -74,6 +76,7 @@ def __init__(self, ble_device: BLEDevice, name: str | None = None) -> None: # Protocol backends self._ftms: FTMSController | None = None self._wilink = None # WiLinkController (lazy import) + self._sperax = None # SperaxController (lazy import) # Status callbacks self._status_callbacks: list[Callable[[TreadmillStatus], None]] = [] @@ -113,6 +116,8 @@ def connected(self) -> bool: return self._ftms.connected if self._wilink is not None: return self._wilink.connected + if self._sperax is not None: + return self._sperax.connected return self._connected @property @@ -122,6 +127,8 @@ def status(self) -> TreadmillStatus: return self._ftms.status if self._wilink: return self._wilink.status + if self._sperax: + return self._sperax.status return TreadmillStatus() @property @@ -131,6 +138,8 @@ def min_speed(self) -> float: return self._ftms.min_speed if self._wilink: return self._wilink.min_speed + if self._sperax: + return self._sperax.min_speed return 0.5 @property @@ -140,6 +149,8 @@ def max_speed(self) -> float: return self._ftms.max_speed if self._wilink: return self._wilink.max_speed + if self._sperax: + return self._sperax.max_speed return 6.0 @property @@ -149,14 +160,16 @@ def speed_increment(self) -> float: return self._ftms.speed_increment if self._wilink: return self._wilink.speed_increment + if self._sperax: + return self._sperax.speed_increment return 0.1 @property def firmware_version(self) -> str: - """Firmware version string, or empty if unavailable. + """Firmware string, or empty if unavailable. Read from Software Revision String (`0x2A28`) on FTMS devices. - Returns an empty string for WiLink devices (not implemented). + Returns an empty string for WiLink and Sperax devices (not implemented). """ if self._ftms: return self._ftms.firmware_version @@ -212,12 +225,21 @@ def _detect_protocol_from_name(self) -> ProtocolType | None: prefix, ) return ProtocolType.FTMS + for prefix in SPERAX_NAME_PREFIXES: + if ble_name.startswith(prefix): + _LOGGER.info( + "Detected Sperax protocol from BLE name '%s' (prefix '%s')", + ble_name, + prefix, + ) + return ProtocolType.SPERAX return None def _detect_protocol_from_services(self, service_uuids: set[str]) -> ProtocolType: """Determine the protocol based on discovered service UUIDs.""" has_ftms = FTMS_SERVICE_UUID.lower() in service_uuids has_wilink = WILINK_SERVICE_UUID.lower() in service_uuids + has_sperax = SPERAX_SERVICE_UUID.lower() in service_uuids if has_ftms and not has_wilink: _LOGGER.info("Detected FTMS protocol (no WiLink service)") @@ -228,6 +250,9 @@ def _detect_protocol_from_services(self, service_uuids: set[str]) -> ProtocolTyp elif has_ftms: _LOGGER.info("Detected FTMS protocol (with WiLink fallback)") return ProtocolType.FTMS + elif has_sperax: + _LOGGER.info("Detected Sperax protocol (service 0xFFF0)") + return ProtocolType.SPERAX else: _LOGGER.warning("No known protocol detected") return ProtocolType.UNKNOWN @@ -281,6 +306,8 @@ async def connect(self) -> None: await self._connect_ftms() elif self._protocol == ProtocolType.WILINK: await self._connect_wilink() + elif self._protocol == ProtocolType.SPERAX: + await self._connect_sperax() else: raise RuntimeError( f"Unknown protocol for device {self._ble_device.address}" @@ -320,6 +347,30 @@ async def _connect_wilink(self) -> None: self._wilink.register_disconnect_callback(self._on_disconnect) await self._wilink.connect(self._ble_device) + async def _connect_sperax(self) -> None: + """Connect using the Sperax / WLT6200 protocol with retry logic.""" + from .sperax import SperaxController + + last_error: Exception | None = None + for attempt in range(1, MAX_CONNECT_RETRIES + 1): + try: + self._sperax = SperaxController() + self._sperax.register_status_callback(self._on_status_update) + self._sperax.register_disconnect_callback(self._on_disconnect) + await self._sperax.connect(self._ble_device) + return + except (BleakError, TimeoutError) as err: + last_error = err + _LOGGER.warning( + "Sperax connection attempt %d/%d failed: %s", + attempt, + MAX_CONNECT_RETRIES, + err, + ) + if attempt < MAX_CONNECT_RETRIES: + await asyncio.sleep(RETRY_DELAY_SECONDS) + raise last_error # type: ignore[misc] + async def disconnect(self) -> None: """Disconnect from the device. @@ -332,6 +383,7 @@ async def disconnect(self) -> None: backend_alive = ( (self._ftms is not None and self._ftms.connected) or (self._wilink is not None and self._wilink.connected) + or (self._sperax is not None and self._sperax.connected) ) if not self._connected and not backend_alive: return @@ -340,6 +392,8 @@ async def disconnect(self) -> None: await self._ftms.disconnect() elif self._wilink: await self._wilink.disconnect() + elif self._sperax: + await self._sperax.disconnect() except Exception: _LOGGER.exception("Error during disconnect") finally: @@ -367,6 +421,9 @@ async def start(self) -> bool: elif self._wilink: return await self._wilink.start() + elif self._sperax: + return await self._sperax.start() + _LOGGER.warning("No protocol backend available") return False @@ -385,6 +442,8 @@ async def stop(self) -> bool: return await self._ftms.stop() elif self._wilink: return await self._wilink.stop() + elif self._sperax: + return await self._sperax.stop() _LOGGER.warning("No protocol backend available") return False @@ -411,6 +470,10 @@ async def pause(self) -> bool: "WiLink protocol has no separate pause; falling back to stop" ) return await self._wilink.stop() + elif self._sperax: + # Sperax has no separate pause opcode; the backend falls back to + # stop internally and logs it. + return await self._sperax.pause() _LOGGER.warning("No protocol backend available") return False @@ -450,9 +513,52 @@ async def set_speed(self, speed_kmh: float) -> bool: elif self._wilink: return await self._wilink.set_target_speed(speed_kmh) + elif self._sperax: + # The WLT6200 run command carries the speed directly and there is + # no cold-start crash to work around, so a single call suffices + # whether the belt is stopped or already moving. + return await self._sperax.set_target_speed(speed_kmh) + _LOGGER.warning("No protocol backend available") return False + async def set_incline(self, incline_step: int) -> bool: + """Set the incline as a discrete step. + + Only the Sperax / WLT6200 backend supports incline today (steps 0-2). + FTMS/WiLink backends return False. + + Args: + incline_step: Incline step (0-2). + + Returns: + True if the command was sent successfully. + """ + if self._sperax: + return await self._sperax.set_target_inclination(incline_step) + + _LOGGER.warning("Incline control not supported on this device") + return False + + async def set_vibration(self, level: int) -> bool: + """Set the vibration level (0 = off, 1-4). + + Only the Sperax / WLT6200 backend supports vibration. Note the belt + and vibration motor are mutually exclusive on the P3 Max. FTMS/WiLink + backends return False. + + Args: + level: Vibration level (0 = off, 1-4). + + Returns: + True if the command was sent successfully. + """ + if self._sperax: + return await self._sperax.set_vibration(level) + + _LOGGER.warning("Vibration control not supported on this device") + return False + async def switch_mode(self, mode: OperatingMode) -> bool: """Switch the treadmill operating mode. @@ -475,6 +581,9 @@ async def switch_mode(self, mode: OperatingMode) -> bool: elif self._wilink: return await self._wilink.switch_mode(mode.value) + elif self._sperax: + return await self._sperax.switch_mode(mode.value) + _LOGGER.warning("No protocol backend available") return False @@ -492,6 +601,12 @@ async def update_state(self) -> None: self._connected = False elif self._wilink: await self._wilink.ask_stats() + elif self._sperax: + # Status is pushed via the poll loop; surface the latest cache. + if self._sperax.connected: + self._on_status_update(self._sperax.status) + else: + self._connected = False def update_ble_device(self, ble_device: BLEDevice) -> None: """Update the BLE device reference (e.g., after rediscovery). diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py new file mode 100644 index 0000000..2d80637 --- /dev/null +++ b/src/walkingpad_controller/sperax.py @@ -0,0 +1,452 @@ +"""Sperax / wi-linktech (WLT6200) protocol implementation. + +Implements the custom framed BLE protocol used by the **Sperax P3 Max** walking +pad (BLE name ``SPERAX_P3MAX``). The device is built around a wi-linktech +**WLT6200** module and does NOT speak FTMS (0x1826) nor the legacy KingSmith +WiLink protocol (0xFE00). Instead it exposes a vendor service (0xFFF0) with a +framed, CRC-checked protocol: + + F5 | LEN | 00 | CMD | [ARGS...] | CRC_lo | CRC_hi | FA + + - ``F5``/``FA`` : start/end delimiters + - ``LEN`` : transmitted (post byte-stuffing) frame length + - byte-stuffing : any body byte 0xF0-0xFF is sent as ``F0 (byte & 0x0F)`` + - ``CRC`` : CRC-16, poly 0xA327, init 0xFFFF, reflected, little-endian, + computed over the *de-stuffed* ``F5 LOGICAL_LEN 00 CMD ARGS`` + +Commands (app -> device, char 0xFFF2, write-no-response): + - 0x01 hello / handshake inner ``00 01`` + - 0x15 run control ``00 15 `` + state 0x01=run / 0x02=stop ; speed = km/h x 10 ; incline 0/1/2 + - 0x16 vibration ``00 16 `` state 0x01=on/0x00=off, level 1-4 + - 0x19 status poll / keep-alive inner ``00 19`` + +Status notifications (device -> app, char 0xFFF1, CMD 0x19) carry belt state, +current speed (km/h x 10), incline and vibration level, plus several cumulative +counters that are not yet fully separated. + +Protocol reference: ``docs/sperax-p3max-protocol.md`` (verified against a full +HCI snoop capture — 146/146 distinct frames pass CRC). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Callable + +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak.exc import BleakError +from bleak_retry_connector import establish_connection + +from .const import ( + SPERAX_NOTIFY_UUID, + SPERAX_WRITE_UUID, + BeltState, +) +from .models import DeviceCapabilities, SpeedRange, TreadmillStatus + +_LOGGER = logging.getLogger(__name__) + + +# --- Frame codec ----------------------------------------------------------- + +_SOF = 0xF5 +_EOF = 0xFA +_ESC = 0xF0 +_CRC_POLY = 0xA327 # reflected form +_CRC_INIT = 0xFFFF + + +def crc16(data: bytes) -> int: + """CRC-16 used by the WLT6200 protocol (poly 0xA327, init 0xFFFF, reflected).""" + c = _CRC_INIT + for b in data: + c ^= b + for _ in range(8): + c = (c >> 1) ^ _CRC_POLY if (c & 1) else (c >> 1) + return c & 0xFFFF + + +def _stuff(b: bytes) -> bytes: + out = bytearray() + for x in b: + if _ESC <= x <= 0xFF: + out += bytes([_ESC, x & 0x0F]) + else: + out.append(x) + return bytes(out) + + +def _destuff(b: bytes) -> bytes: + out = bytearray() + i = 0 + while i < len(b): + if b[i] == _ESC and i + 1 < len(b): + out.append(_ESC | b[i + 1]) + i += 2 + else: + out.append(b[i]) + i += 1 + return bytes(out) + + +def encode(inner: bytes) -> bytes: + """Frame a logical body ``[0x00, CMD, ARGS...]`` into an on-wire frame.""" + logical_len = 1 + 1 + len(inner) + 2 + 1 # F5 + LEN + inner + CRC(2) + FA + c = crc16(bytes([_SOF, logical_len]) + inner) + body = _stuff(inner + bytes([c & 0xFF, (c >> 8) & 0xFF])) + tx_len = 1 + 1 + len(body) + 1 + return bytes([_SOF, tx_len]) + body + bytes([_EOF]) + + +def decode(frame: bytes) -> bytes | None: + """De-frame and CRC-check an on-wire frame. + + Returns the logical body ``[0x00, CMD, ARGS...]`` or ``None`` if the frame + is malformed or fails the CRC check. + """ + if len(frame) < 6 or frame[0] != _SOF or frame[-1] != _EOF: + return None + full = bytes([_SOF]) + _destuff(frame[1:-1]) + bytes([_EOF]) + if len(full) < 6: + return None + data = bytearray(full[:-3]) + data[1] = len(full) # LOGICAL_LEN participates in the CRC + lo, hi = full[-3], full[-2] + if crc16(bytes(data)) != ((hi << 8) | lo): + return None + return full[2:-3] + + +# Command opcodes / helpers +_CMD_HELLO = 0x01 +_CMD_RUN = 0x15 +_CMD_VIBRATION = 0x16 +_CMD_STATUS = 0x19 +_CMD_ACK = 0xD0 + +_RUN_STATE_RUN = 0x01 +_RUN_STATE_STOP = 0x02 +_VIB_STATE_ON = 0x01 +_VIB_STATE_OFF = 0x00 + +# How often to poll the device. The WLT6200 only streams status while it is +# being polled; the official app polls ~3x/s. 0.5 s keeps the link alive and +# the status fresh without hammering the write characteristic. +_POLL_INTERVAL = 0.5 + +# Speed capabilities. The reference capture only exercised up to 3.0 km/h; +# the P3 Max spec sheet advertises 6.0 km/h. These are best-effort defaults +# until confirmed on hardware — the device is not known to expose a readable +# speed-range characteristic. +_MIN_SPEED = 0.5 +_MAX_SPEED = 6.0 +_SPEED_INCREMENT = 0.1 +_MAX_INCLINE = 2 +_MAX_VIBRATION = 4 + + +class SperaxController: + """Controller for Sperax P3 Max (wi-linktech WLT6200) walking pads.""" + + def __init__(self) -> None: + self._client: BleakClient | None = None + self._connected = False + self._status = TreadmillStatus() + self._capabilities = DeviceCapabilities( + speed_range=SpeedRange( + min_speed=_MIN_SPEED, max_speed=_MAX_SPEED, increment=_SPEED_INCREMENT + ) + ) + self._status_callbacks: list[Callable[[TreadmillStatus], None]] = [] + self._disconnect_callbacks: list[Callable[[], None]] = [] + + # Desired belt state. Every run command carries speed AND incline, so + # we track both and re-send the full command on any change. + self._target_speed_tenths = int(round(_MIN_SPEED * 10)) + self._target_incline = 0 + self._vibration_level = 0 + + self._poll_task: asyncio.Task | None = None + + # --- Properties --- + + @property + def connected(self) -> bool: + """Return whether the device is connected.""" + return ( + self._connected and self._client is not None and self._client.is_connected + ) + + @property + def status(self) -> TreadmillStatus: + """Return the current treadmill status.""" + return self._status + + @property + def capabilities(self) -> DeviceCapabilities: + """Return the device capabilities.""" + return self._capabilities + + @property + def min_speed(self) -> float: + """Minimum speed in km/h.""" + return self._capabilities.speed_range.min_speed + + @property + def max_speed(self) -> float: + """Maximum speed in km/h.""" + return self._capabilities.speed_range.max_speed + + @property + def speed_increment(self) -> float: + """Speed increment in km/h.""" + return self._capabilities.speed_range.increment + + @property + def firmware_version(self) -> str: + """Firmware version — not read from this device yet.""" + return self._capabilities.firmware_version + + @property + def vibration_level(self) -> int: + """Last known vibration level (0 = off, 1-4).""" + return self._vibration_level + + def register_status_callback( + self, callback: Callable[[TreadmillStatus], None] + ) -> None: + """Register a callback for status updates.""" + self._status_callbacks.append(callback) + + def register_disconnect_callback(self, callback: Callable[[], None]) -> None: + """Register a callback for disconnect events.""" + self._disconnect_callbacks.append(callback) + + def _notify_status(self) -> None: + for cb in self._status_callbacks: + try: + cb(self._status) + except Exception: + _LOGGER.exception("Error in status callback") + + # --- Connection --- + + async def connect(self, ble_device: BLEDevice) -> None: + """Connect, subscribe to status, send hello, and start polling.""" + _LOGGER.info("Sperax: Connecting to %s", ble_device.address) + self._client = await establish_connection( + BleakClient, + ble_device, + ble_device.name or ble_device.address, + disconnected_callback=self._on_disconnect, + ) + self._connected = True + _LOGGER.info("Sperax: Connected to %s", ble_device.address) + + # Subscribe to status notifications (0xFFF1) then say hello (0x01). + await self._client.start_notify(SPERAX_NOTIFY_UUID, self._on_notify) + await self._write(bytes([0x00, _CMD_HELLO])) + + # The device only streams status while polled — start the keep-alive. + self._poll_task = asyncio.get_running_loop().create_task(self._poll_loop()) + + if not self.connected: + raise BleakError( + "Sperax: BLE link dropped during connection setup; treating " + "as a failed connect." + ) + + async def disconnect(self) -> None: + """Disconnect from the device.""" + self._stop_poll() + if self._client and self._client.is_connected: + try: + await self._client.disconnect() + except BleakError: + pass + self._connected = False + + def _on_disconnect(self, client: BleakClient) -> None: + _LOGGER.warning("Sperax: Device disconnected") + self._connected = False + self._stop_poll() + for cb in self._disconnect_callbacks: + try: + cb() + except Exception: + _LOGGER.exception("Error in disconnect callback") + + def _stop_poll(self) -> None: + if self._poll_task is not None: + self._poll_task.cancel() + self._poll_task = None + + async def _poll_loop(self) -> None: + """Periodically poll the device so it keeps streaming status.""" + try: + while self.connected: + try: + await self._write(bytes([0x00, _CMD_STATUS])) + except BleakError as err: + _LOGGER.debug("Sperax: poll write failed: %s", err) + break + await asyncio.sleep(_POLL_INTERVAL) + except asyncio.CancelledError: + pass + + # --- Write helper --- + + async def _write(self, inner: bytes) -> None: + """Frame ``inner`` and write it to the command characteristic.""" + if not self._client: + raise BleakError("Sperax: not connected") + await self._client.write_gatt_char( + SPERAX_WRITE_UUID, encode(inner), response=False + ) + + # --- Notification handling --- + + def _on_notify(self, sender: int, data: bytearray) -> None: + inner = decode(bytes(data)) + if inner is None or len(inner) < 2: + return + cmd = inner[1] + if cmd == _CMD_STATUS: + self._parse_status(inner) + elif cmd == _CMD_ACK: + _LOGGER.debug("Sperax: command ack %s", inner.hex()) + elif cmd == _CMD_HELLO: + _LOGGER.debug("Sperax: hello response %s", inner.hex()) + + def _parse_status(self, inner: bytes) -> None: + """Parse a 0x19 status body ``[0x00, 0x19, ...]`` into TreadmillStatus. + + Byte offsets within ``inner`` (see docs/sperax-p3max-protocol.md §5.2): + 4 state (0x10 run, 0x0F decel, 0x01 stopped, 0x50 vibration, 0x00 idle) + 8 counter A (elapsed-time-like) 14 counter D (steps/distance-like) + 15 speed x10 16 incline 18 vibration level + + Only speed / belt-state / vibration are confidently decoded; the + cumulative counters are not yet separated (time vs distance vs steps + vs calories), so distance/duration/steps are left at their captured + raw counters as a best-effort and clearly flagged here. + """ + if len(inner) < 19: + return + + speed_raw = inner[15] + self._status.speed = speed_raw / 10.0 + if speed_raw > 0: + self._status.belt_state = BeltState.ACTIVE + else: + self._status.belt_state = BeltState.STOPPED + + self._vibration_level = inner[18] + + # Best-effort counters — NOT yet unit-verified. Exposed so callers have + # *something* trending; treat with caution until confirmed on hardware. + # counter D (offset 14) trends fastest (~steps/distance). + self._status.steps = inner[14] + # counter A (offset 8) trends ~1/s (~elapsed seconds). + self._status.duration = inner[8] + + self._status.timestamp = time.time() + self._notify_status() + + # --- Commands --- + + async def _send_run(self) -> bool: + """(Re)send the current run target (speed + incline).""" + speed = max( + 0, + min(int(round(self._max_speed_tenths())), self._target_speed_tenths), + ) + inner = bytes( + [0x00, _CMD_RUN, _RUN_STATE_RUN, speed & 0xFF, self._target_incline & 0xFF] + ) + try: + await self._write(inner) + return True + except BleakError as err: + _LOGGER.warning("Sperax: run command failed: %s", err) + return False + + def _max_speed_tenths(self) -> int: + return int(round(self.max_speed * 10)) + + async def start(self) -> bool: + """Start the belt at the minimum speed. + + Mirrors the other backends: start does not jump straight to a high + speed. Callers set the desired speed afterwards via set_target_speed(). + """ + self._target_speed_tenths = max( + int(round(self.min_speed * 10)), self._target_speed_tenths + ) + return await self._send_run() + + async def stop(self) -> bool: + """Stop the belt (``15 02 00 00``).""" + try: + await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_STOP, 0x00, 0x00])) + return True + except BleakError as err: + _LOGGER.warning("Sperax: stop failed: %s", err) + return False + + async def pause(self) -> bool: + """Pause the belt. + + The WLT6200 protocol has no separate pause opcode, so this falls back + to stop() — same approach the WiLink backend takes. + """ + _LOGGER.debug("Sperax: no pause opcode; falling back to stop") + return await self.stop() + + async def set_target_speed(self, speed_kmh: float) -> bool: + """Set the belt speed in km/h.""" + speed_kmh = max(self.min_speed, min(self.max_speed, speed_kmh)) + self._target_speed_tenths = int(round(speed_kmh * 10)) + return await self._send_run() + + async def set_target_inclination(self, incline_step: int) -> bool: + """Set the incline as a step (0-2). + + Unlike FTMS (which uses a percentage), the P3 Max exposes incline as + discrete steps. The value is clamped to 0..2. + """ + self._target_incline = max(0, min(_MAX_INCLINE, int(incline_step))) + return await self._send_run() + + async def set_vibration(self, level: int) -> bool: + """Set the vibration level (0 = off, 1-4). + + Note: the belt and the vibration motor are mutually exclusive — turning + vibration on stops the belt. + """ + level = max(0, min(_MAX_VIBRATION, int(level))) + state = _VIB_STATE_ON if level > 0 else _VIB_STATE_OFF + try: + await self._write(bytes([0x00, _CMD_VIBRATION, state, level & 0xFF])) + self._vibration_level = level + return True + except BleakError as err: + _LOGGER.warning("Sperax: set vibration failed: %s", err) + return False + + async def switch_mode(self, mode: int) -> bool: + """Switch operating mode (compat shim). + + The P3 Max has no auto/manual mode concept; STANDBY (2) maps to stop. + """ + from .const import OperatingMode + + if mode == OperatingMode.STANDBY.value: + return await self.stop() + if mode == OperatingMode.AUTO.value: + return await self.start() + return True diff --git a/tests/test_sperax_codec.py b/tests/test_sperax_codec.py new file mode 100644 index 0000000..53f5127 --- /dev/null +++ b/tests/test_sperax_codec.py @@ -0,0 +1,88 @@ +"""Unit tests for the Sperax / WLT6200 protocol codec and status parser. + +These are pure-logic tests — no BLE hardware required. The frames below are +real captures from a Sperax P3 Max HCI snoop log (see +docs/sperax-p3max-protocol.md). +""" + +from __future__ import annotations + +from walkingpad_controller.sperax import crc16, decode, encode + +# --- Real captured command frames (app -> device) --------------------------- +# (hex on wire, logical inner body [0x00, CMD, ARGS...]) +CAPTURED_COMMANDS = [ + ("f507000126d8fa", "0001"), # hello + ("f50a0015010200be98fa", "0015010200"), # run speed 0.2, incline 0 + ("f50a0015011e006ad9fa", "0015011e00"), # run speed 3.0, incline 0 + ("f50a0015011e019eb7fa", "0015011e01"), # run speed 3.0, incline 1 + ("f50a0015011e028204fa", "0015011e02"), # run speed 3.0, incline 2 + ("f50a0015020000c319fa", "0015020000"), # stop + ("f5090016010155eafa", "00160101"), # vibration level 1 + ("f509001601043e79fa", "00160104"), # vibration level 4 + ("f50900160000346dfa", "00160000"), # vibration off + ("f5080019f00a59fa", "0019"), # status poll (note CRC byte 0xFA is stuffed) + ("f50b00150106003bf004fa", "0015010600"), # run speed 0.6 (CRC contains 0xF4 -> stuffed) +] + +# A representative status notification (device -> app), running at 3.0 km/h, +# incline 0, no vibration. +STATUS_RUN_3KMH = "f51800190000100000001000010001000f1e0000009dd4fa" + + +def test_decode_captured_commands(): + for onwire, expected_inner in CAPTURED_COMMANDS: + inner = decode(bytes.fromhex(onwire)) + assert inner is not None, f"decode failed for {onwire}" + assert inner.hex() == expected_inner, ( + f"{onwire}: got {inner.hex()}, want {expected_inner}" + ) + + +def test_encode_roundtrips_captured_commands(): + # encode(inner) must reproduce the exact on-wire bytes (incl. stuffing). + for onwire, inner_hex in CAPTURED_COMMANDS: + got = encode(bytes.fromhex(inner_hex)) + assert got.hex() == onwire, f"encode({inner_hex}) = {got.hex()}, want {onwire}" + + +def test_decode_rejects_bad_crc(): + b = bytearray.fromhex("f50a0015011e006ad9fa") + b[5] ^= 0xFF # corrupt the speed byte, CRC no longer matches + assert decode(bytes(b)) is None + + +def test_decode_rejects_malformed(): + assert decode(b"") is None + assert decode(bytes.fromhex("f50a00")) is None # too short + assert decode(bytes.fromhex("aa0a0015011e006ad9fa")) is None # bad SOF + assert decode(bytes.fromhex("f50a0015011e006ad9bb")) is None # bad EOF + + +def test_crc_known_value(): + # CRC over [F5, LEN, 00, 15, 01, 1E, 00] for the run-3.0 frame = 0xD96A LE. + assert crc16(bytes.fromhex("f50a0015011e00")) == 0xD96A + + +def test_encode_speed_formula(): + # speed byte = round(km/h * 10) + def run_speed_byte(kmh): + s = int(round(kmh * 10)) + return decode(encode(bytes([0x00, 0x15, 0x01, s, 0x00])))[3] + + assert run_speed_byte(3.0) == 0x1E + assert run_speed_byte(1.0) == 0x0A + assert run_speed_byte(0.5) == 0x05 + + +def test_status_parse_running(): + from walkingpad_controller.const import BeltState + from walkingpad_controller.sperax import SperaxController + + ctrl = SperaxController() + inner = decode(bytes.fromhex(STATUS_RUN_3KMH)) + assert inner is not None + ctrl._parse_status(inner) + assert ctrl.status.speed == 3.0 + assert ctrl.status.belt_state == BeltState.ACTIVE + assert ctrl.vibration_level == 0 From d9f647fdbf1c87d1153f6f2b5d912d582a9b7485 Mon Sep 17 00:00:00 2001 From: mcdax Date: Mon, 20 Jul 2026 08:45:56 +0200 Subject: [PATCH 2/8] Sperax: raise max speed to 12 km/h, expose vibration level in status - P3 Max tops out at 12.0 km/h (confirmed by device owner, hass-walkingpad#3), not the previously-assumed 6.0. The speed byte still follows km/h x10. - Record the vibration level (status offset 18) in TreadmillStatus.vibration_level so consumers can surface it. - Doc: update open items (confirmed max speed; incline/decline capture still needed). Co-Authored-By: Claude Opus 4.8 --- docs/sperax-p3max-protocol.md | 9 +++++++-- src/walkingpad_controller/models.py | 4 ++++ src/walkingpad_controller/sperax.py | 10 +++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md index ed211e9..a9dc079 100644 --- a/docs/sperax-p3max-protocol.md +++ b/docs/sperax-p3max-protocol.md @@ -350,8 +350,13 @@ device streams `0x19` status notifications. - Decode the hello-response (`0x01`) fields → device limits / units. - Separate status counters at offsets 8 / 10 / 12 / 14 (time vs distance vs steps vs calories) with a longer, instrumented capture. -- Confirm the device's true **max speed** and **max incline** (capture only reached - 3.0 km/h / incline 2). +- **Max speed is 12.0 km/h** (confirmed by the device owner in hass-walkingpad#3; + the reference capture only reached 3.0 km/h). The `` byte still follows + km/h × 10, so 12.0 km/h = `0x78`. +- **Incline / decline** encoding beyond steps 0–2 is not yet captured. The P3 Max + advertises 15-level motorised incline *and* decline; a capture sweeping the full + incline range and into decline is needed to decode the `` byte (range and + how decline is represented — e.g. signed / offset). --- diff --git a/src/walkingpad_controller/models.py b/src/walkingpad_controller/models.py index 856fab0..02e90cd 100644 --- a/src/walkingpad_controller/models.py +++ b/src/walkingpad_controller/models.py @@ -53,6 +53,10 @@ class TreadmillStatus: See `FitnessMachineStatusOpcode` for known values; 0 means no event has been received yet this session.""" + vibration_level: int = 0 + """Vibration intensity level (0 = off, 1-4). Sperax / WLT6200 devices + only; stays 0 for FTMS and WiLink.""" + timestamp: float = field(default_factory=time.time) """Wall-clock time when this status was received.""" diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index 2d80637..5f80674 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -138,12 +138,11 @@ def decode(frame: bytes) -> bytes | None: # the status fresh without hammering the write characteristic. _POLL_INTERVAL = 0.5 -# Speed capabilities. The reference capture only exercised up to 3.0 km/h; -# the P3 Max spec sheet advertises 6.0 km/h. These are best-effort defaults -# until confirmed on hardware — the device is not known to expose a readable -# speed-range characteristic. +# Speed capabilities. The P3 Max tops out at 12.0 km/h (confirmed by the +# device owner in hass-walkingpad#3). The device is not known to expose a +# readable speed-range characteristic, so these are fixed defaults. _MIN_SPEED = 0.5 -_MAX_SPEED = 6.0 +_MAX_SPEED = 12.0 _SPEED_INCREMENT = 0.1 _MAX_INCLINE = 2 _MAX_VIBRATION = 4 @@ -346,6 +345,7 @@ def _parse_status(self, inner: bytes) -> None: self._status.belt_state = BeltState.STOPPED self._vibration_level = inner[18] + self._status.vibration_level = inner[18] # Best-effort counters — NOT yet unit-verified. Exposed so callers have # *something* trending; treat with caution until confirmed on hardware. From 4aee4a3969851e87366af3375f68ed7234cb5645 Mon Sep 17 00:00:00 2001 From: mcdax Date: Tue, 21 Jul 2026 07:39:01 +0200 Subject: [PATCH 3/8] Sperax: incline 0-10 (no decline), expose incline in status, apply while running - Incline range confirmed 0-10 (flat..max), no decline (full-sweep capture, hass-walkingpad#3). Raise _MAX_INCLINE 2 -> 10. - Expose TreadmillStatus.incline (status offset 16). - set_target_inclination only re-sends the run command while the belt is moving; when stopped it caches the target (applied on next start/set_speed) so adjusting incline can't start a stopped belt. Co-Authored-By: Claude Opus 4.8 --- docs/sperax-p3max-protocol.md | 8 ++++---- src/walkingpad_controller/models.py | 4 ++++ src/walkingpad_controller/sperax.py | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md index a9dc079..5f59da3 100644 --- a/docs/sperax-p3max-protocol.md +++ b/docs/sperax-p3max-protocol.md @@ -353,10 +353,10 @@ device streams `0x19` status notifications. - **Max speed is 12.0 km/h** (confirmed by the device owner in hass-walkingpad#3; the reference capture only reached 3.0 km/h). The `` byte still follows km/h × 10, so 12.0 km/h = `0x78`. -- **Incline / decline** encoding beyond steps 0–2 is not yet captured. The P3 Max - advertises 15-level motorised incline *and* decline; a capture sweeping the full - incline range and into decline is needed to decode the `` byte (range and - how decline is represented — e.g. signed / offset). +- **Incline is 0–10, no decline** (confirmed by a full incline-sweep capture in + hass-walkingpad#3). The `` byte in the run command and the incline field + in the status frame both range `0x00` (flat) .. `0x0A` (max); values never go + negative. The device does not expose a separate decline in the protocol. --- diff --git a/src/walkingpad_controller/models.py b/src/walkingpad_controller/models.py index 02e90cd..bdecf6c 100644 --- a/src/walkingpad_controller/models.py +++ b/src/walkingpad_controller/models.py @@ -57,6 +57,10 @@ class TreadmillStatus: """Vibration intensity level (0 = off, 1-4). Sperax / WLT6200 devices only; stays 0 for FTMS and WiLink.""" + incline: int = 0 + """Incline level (0 = flat .. 10 = max). Sperax / WLT6200 devices only; + stays 0 for FTMS and WiLink.""" + timestamp: float = field(default_factory=time.time) """Wall-clock time when this status was received.""" diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index 5f80674..901a25e 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -144,7 +144,7 @@ def decode(frame: bytes) -> bytes | None: _MIN_SPEED = 0.5 _MAX_SPEED = 12.0 _SPEED_INCREMENT = 0.1 -_MAX_INCLINE = 2 +_MAX_INCLINE = 10 _MAX_VIBRATION = 4 @@ -346,6 +346,7 @@ def _parse_status(self, inner: bytes) -> None: self._vibration_level = inner[18] self._status.vibration_level = inner[18] + self._status.incline = inner[16] # Best-effort counters — NOT yet unit-verified. Exposed so callers have # *something* trending; treat with caution until confirmed on hardware. @@ -414,13 +415,20 @@ async def set_target_speed(self, speed_kmh: float) -> bool: return await self._send_run() async def set_target_inclination(self, incline_step: int) -> bool: - """Set the incline as a step (0-2). + """Set the incline as a step (0 = flat .. 10 = max). Unlike FTMS (which uses a percentage), the P3 Max exposes incline as - discrete steps. The value is clamped to 0..2. + discrete steps, clamped to 0..10. + + Incline rides inside the run command (``15 01 ``), so + applying it re-sends a run. We only do that while the belt is already + moving — sending it while stopped would start the belt. When stopped we + just cache the target; it is applied on the next start()/set_speed(). """ self._target_incline = max(0, min(_MAX_INCLINE, int(incline_step))) - return await self._send_run() + if self._status.speed > 0: + return await self._send_run() + return True async def set_vibration(self, level: int) -> bool: """Set the vibration level (0 = off, 1-4). From 37cc42e2dacb1967037bc0ab3ec81661b36f3c6e Mon Sep 17 00:00:00 2001 From: mcdax Date: Tue, 21 Jul 2026 19:03:35 +0200 Subject: [PATCH 4/8] Sperax: fix vibration-off reporting; correct stop/pause semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vibration level (status offset 18) is a "last selected" field the device does not clear on stop; it signals vibration-active via the state byte (offset 4 == 0x50). Report the level only while vibrating, else 0 — fixes the HA value staying stuck at the last level after turning vibration off. - stop() now sends 15 00 00 00 (full stop: belt stops AND session counters reset to 0), matching FTMS stop semantics. pause() sends 15 02 00 00 (belt stops, counters kept) instead of falling back to stop(). Both confirmed by capture in hass-walkingpad#3. - Tests + protocol doc updated for the three run states and the vibration rule. Co-Authored-By: Claude Opus 4.8 --- docs/sperax-p3max-protocol.md | 19 +- src/walkingpad_controller/sperax.py | 46 ++- tests/commands/__init__.py | 0 tests/commands/_common.py | 224 ++++++++++++++ tests/commands/test_connect.py | 22 ++ tests/commands/test_disconnect.py | 15 + tests/commands/test_observe.py | 69 +++++ tests/commands/test_pause.py | 77 +++++ tests/commands/test_reset.py | 17 ++ tests/commands/test_resume.py | 74 +++++ tests/commands/test_set_inclination.py | 24 ++ tests/commands/test_set_speed.py | 48 +++ tests/commands/test_start.py | 27 ++ tests/commands/test_stop.py | 31 ++ tests/commands/test_switch_mode.py | 28 ++ tests/commands/test_update_state.py | 26 ++ tests/probe_disconnect.py | 274 +++++++++++++++++ tests/probe_keepalive.py | 217 ++++++++++++++ tests/probe_lib_stability.py | 75 +++++ tests/probe_preworkout.py | 329 ++++++++++++++++++++ tests/probe_preworkout2.py | 243 +++++++++++++++ tests/probe_setup_killer.py | 210 +++++++++++++ tests/probe_setup_step.py | 167 +++++++++++ tests/stress_dynamic.py | 398 +++++++++++++++++++++++++ tests/stress_real_device.py | 207 +++++++++++++ tests/stress_start_stop.py | 269 +++++++++++++++++ tests/stress_traced.py | 260 ++++++++++++++++ tests/test_sperax_codec.py | 23 ++ tests/watch_ble.py | 221 ++++++++++++++ 29 files changed, 3622 insertions(+), 18 deletions(-) create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/_common.py create mode 100644 tests/commands/test_connect.py create mode 100644 tests/commands/test_disconnect.py create mode 100644 tests/commands/test_observe.py create mode 100644 tests/commands/test_pause.py create mode 100644 tests/commands/test_reset.py create mode 100644 tests/commands/test_resume.py create mode 100644 tests/commands/test_set_inclination.py create mode 100644 tests/commands/test_set_speed.py create mode 100644 tests/commands/test_start.py create mode 100644 tests/commands/test_stop.py create mode 100644 tests/commands/test_switch_mode.py create mode 100644 tests/commands/test_update_state.py create mode 100644 tests/probe_disconnect.py create mode 100644 tests/probe_keepalive.py create mode 100644 tests/probe_lib_stability.py create mode 100644 tests/probe_preworkout.py create mode 100644 tests/probe_preworkout2.py create mode 100644 tests/probe_setup_killer.py create mode 100644 tests/probe_setup_step.py create mode 100644 tests/stress_dynamic.py create mode 100644 tests/stress_real_device.py create mode 100644 tests/stress_start_stop.py create mode 100644 tests/stress_traced.py create mode 100644 tests/watch_ble.py diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md index 5f59da3..0972a59 100644 --- a/docs/sperax-p3max-protocol.md +++ b/docs/sperax-p3max-protocol.md @@ -172,20 +172,23 @@ Inner body: `00 15 ` | Arg | Values | Meaning | |-----|--------|---------| -| `state` | `0x01` = run, `0x02` = stop | Belt state | +| `state` | `0x01` = run, `0x02` = pause, `0x00` = stop | Belt state (see below) | | `speed` | `0x00`–… | **Target speed in km/h × 10** (e.g. `0x1E` = 3.0 km/h). `0x00` when stopping. | -| `incline` | `0x00`, `0x01`, `0x02` | Incline step | +| `incline` | `0x00`–`0x0A` | Incline step (0 = flat, 10 = max; no decline) | + +The three `state` values behave differently (confirmed in hass-walkingpad#3): + +- **`0x01` run** — start / keep the belt moving at `speed`. +- **`0x02` pause** — belt decelerates to 0 but the device **keeps** its session + counters (steps / distance / time); a later `run` resumes the session. +- **`0x00` stop** — belt stops **and** the device **resets** the session counters + to 0 (the next status frame reports all-zero counters). To **start** the belt and set a speed, send `state=0x01` with the desired `speed`. The official app ramps the speed up by sending successive `0x15` frames with an increasing `speed` byte, but a single frame with the final target also works. -To **stop** the belt: `00 15 02 00 00`. - -> The captured session only exercised speeds up to 3.0 km/h and inclines 0–2. -> Higher speeds are produced by the same formula (`speed = km/h × 10`) and the CRC -> encoder above; a longer capture would be needed to confirm the device's real max -> speed and max incline. +To **pause** (keep totals): `00 15 02 00 00`. To **stop** (reset totals): `00 15 00 00 00`. **Run-control reference frames** (incline `0x00`; generated by the verified encoder, those overlapping the capture match byte-for-byte): diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index 901a25e..ad91c48 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -17,7 +17,8 @@ Commands (app -> device, char 0xFFF2, write-no-response): - 0x01 hello / handshake inner ``00 01`` - 0x15 run control ``00 15 `` - state 0x01=run / 0x02=stop ; speed = km/h x 10 ; incline 0/1/2 + state 0x01=run / 0x02=pause (keep counters) / 0x00=stop (reset counters); + speed = km/h x 10 ; incline 0..10 - 0x16 vibration ``00 16 `` state 0x01=on/0x00=off, level 1-4 - 0x19 status poll / keep-alive inner ``00 19`` @@ -128,11 +129,20 @@ def decode(frame: bytes) -> bytes | None: _CMD_STATUS = 0x19 _CMD_ACK = 0xD0 +# Run-command state byte (2nd arg of 0x15). Confirmed in hass-walkingpad#3: +# 0x01 = run, 0x02 = pause (belt stops, session counters kept), +# 0x00 = stop (belt stops AND steps/distance/time reset to 0). _RUN_STATE_RUN = 0x01 -_RUN_STATE_STOP = 0x02 +_RUN_STATE_PAUSE = 0x02 +_RUN_STATE_STOP = 0x00 _VIB_STATE_ON = 0x01 _VIB_STATE_OFF = 0x00 +# Status-frame state byte (offset 4). 0x50 means the device is in vibration +# mode; the vibration-level field (offset 18) retains its last value even +# after vibration is turned off, so it's only meaningful in this state. +_STATUS_STATE_VIBRATION = 0x50 + # How often to poll the device. The WLT6200 only streams status while it is # being polled; the official app polls ~3x/s. 0.5 s keeps the link alive and # the status fresh without hammering the write characteristic. @@ -344,8 +354,13 @@ def _parse_status(self, inner: bytes) -> None: else: self._status.belt_state = BeltState.STOPPED - self._vibration_level = inner[18] - self._status.vibration_level = inner[18] + # The vibration-level field (offset 18) holds the *last selected* + # level and is not cleared when vibration turns off — the device + # signals "vibration active" via the state byte (offset 4 == 0x50). + # Report a level only while actually vibrating, else 0. + vib = inner[18] if inner[4] == _STATUS_STATE_VIBRATION else 0 + self._vibration_level = vib + self._status.vibration_level = vib self._status.incline = inner[16] # Best-effort counters — NOT yet unit-verified. Exposed so callers have @@ -391,7 +406,13 @@ async def start(self) -> bool: return await self._send_run() async def stop(self) -> bool: - """Stop the belt (``15 02 00 00``).""" + """Stop the belt and end the session (``15 00 00 00``). + + This is the full stop: the belt stops AND the device resets its + session counters (steps / distance / time) to 0 — matching the FTMS + backend's stop() semantics. Use pause() to stop the belt while keeping + the running totals. + """ try: await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_STOP, 0x00, 0x00])) return True @@ -400,13 +421,18 @@ async def stop(self) -> bool: return False async def pause(self) -> bool: - """Pause the belt. + """Pause the belt, keeping the session (``15 02 00 00``). - The WLT6200 protocol has no separate pause opcode, so this falls back - to stop() — same approach the WiLink backend takes. + The belt stops but the device preserves its session counters + (steps / distance / time), so a subsequent start() continues the + session — matching the FTMS backend's pause() semantics. """ - _LOGGER.debug("Sperax: no pause opcode; falling back to stop") - return await self.stop() + try: + await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_PAUSE, 0x00, 0x00])) + return True + except BleakError as err: + _LOGGER.warning("Sperax: pause failed: %s", err) + return False async def set_target_speed(self, speed_kmh: float) -> bool: """Set the belt speed in km/h.""" diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/_common.py b/tests/commands/_common.py new file mode 100644 index 0000000..eaebf80 --- /dev/null +++ b/tests/commands/_common.py @@ -0,0 +1,224 @@ +"""Shared test helpers for the per-command scripts and the orchestrator. + +Goals: + - Each command lives in its own file; they all use the same envelope so + the orchestrator can call them uniformly. + - Single connect-with-retry implementation (the device is BLE-flaky; + retrying is mandatory in the real world). + - Structured result dict per run, suitable for both stdout JSON and + in-process orchestration. + +Usage: + + from tests.commands._common import find_device, connect, safe_disconnect, run_module + + # standalone + if __name__ == "__main__": + run_module(do_command) + + async def do_command(controller, args) -> dict: + ok = await controller.start() + return {"ok": ok} +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import json +import logging +import sys +import time +from dataclasses import asdict, dataclass, field +from typing import Awaitable, Callable + +from bleak import BleakScanner + +from walkingpad_controller import WalkingPadController, TreadmillStatus + +DEFAULT_DEVICE_NAME = "KS-HD-Z1D" +DEFAULT_CONNECT_TIMEOUT = 15.0 +DEFAULT_CONNECT_ATTEMPTS = 3 +DEFAULT_RETRY_GAP = 4.0 + + +def setup_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("bleak").setLevel(logging.WARNING) + + +@dataclass +class CommandResult: + """Standard result envelope every command script returns.""" + + command: str + ok: bool = False + latency_ms: float = 0.0 + error: str | None = None + before_connected: bool | None = None + after_connected: bool | None = None + before_speed: float | None = None + after_speed: float | None = None + before_belt_state: int | None = None + after_belt_state: int | None = None + args: dict = field(default_factory=dict) + extra: dict = field(default_factory=dict) + + def as_json(self) -> str: + return json.dumps(asdict(self), default=str) + + +async def find_device(name: str = DEFAULT_DEVICE_NAME, timeout: float = DEFAULT_CONNECT_TIMEOUT): + """Locate the device by advertised name. Returns a BLEDevice or None.""" + return await BleakScanner.find_device_by_name(name, timeout=timeout) + + +async def connect( + name: str = DEFAULT_DEVICE_NAME, + attempts: int = DEFAULT_CONNECT_ATTEMPTS, + retry_gap: float = DEFAULT_RETRY_GAP, + on_status=None, + on_disconnect=None, +) -> WalkingPadController | None: + """Scan + connect with retry. Returns a connected controller or None.""" + log = logging.getLogger("connect") + last_err: Exception | None = None + for attempt in range(1, attempts + 1): + try: + device = await find_device(name) + if device is None: + log.warning("attempt %d: device %r not found", attempt, name) + last_err = RuntimeError(f"device {name!r} not advertising") + else: + controller = WalkingPadController(ble_device=device) + if on_status: + controller.register_status_callback(on_status) + if on_disconnect: + controller.register_disconnect_callback(on_disconnect) + await controller.connect() + if controller.connected: + return controller + log.warning("attempt %d: connect returned but not connected", attempt) + last_err = RuntimeError("connect: not connected after return") + except Exception as err: # noqa: BLE001 + log.warning("attempt %d: %s", attempt, err) + last_err = err + if attempt < attempts: + await asyncio.sleep(retry_gap) + log.error("all %d attempts failed; last error: %s", attempts, last_err) + return None + + +async def safe_disconnect(controller: WalkingPadController | None) -> None: + if controller is None: + return + with contextlib.suppress(Exception): + if controller.connected: + await controller.disconnect() + + +def snapshot(controller: WalkingPadController | None) -> dict: + """Capture {connected, speed, belt_state} for before/after comparison.""" + if controller is None: + return {"connected": False, "speed": None, "belt_state": None} + s: TreadmillStatus = controller.status + return { + "connected": controller.connected, + "speed": s.speed, + "belt_state": s.belt_state, + } + + +async def run_inprocess( + command: str, + fn: Callable[[WalkingPadController, argparse.Namespace], Awaitable[dict]], + args: argparse.Namespace, + keep_open: bool = False, + controller: WalkingPadController | None = None, +) -> tuple[CommandResult, WalkingPadController | None]: + """Connect (or reuse), run fn, disconnect (unless keep_open). Used by the + orchestrator when running multiple commands on the same connection.""" + + own_connection = controller is None + if own_connection: + controller = await connect(name=args.name) + + result = CommandResult(command=command, args=vars(args)) + before = snapshot(controller) + result.before_connected = before["connected"] + result.before_speed = before["speed"] + result.before_belt_state = before["belt_state"] + + if controller is None: + result.ok = False + result.error = "could-not-connect" + return result, None + + t0 = time.time() + try: + extra = await fn(controller, args) + result.ok = bool(extra.get("ok", True)) + result.extra = {k: v for k, v in extra.items() if k != "ok"} + if "error" in extra: + result.error = str(extra["error"]) + except Exception as err: # noqa: BLE001 + result.ok = False + result.error = f"{type(err).__name__}: {err}" + result.latency_ms = (time.time() - t0) * 1000.0 + + after = snapshot(controller) + result.after_connected = after["connected"] + result.after_speed = after["speed"] + result.after_belt_state = after["belt_state"] + + if own_connection and not keep_open: + await safe_disconnect(controller) + controller = None + + return result, controller + + +def base_argparser(command: str) -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=f"walkingpad-controller test: {command}") + p.add_argument("--name", default=DEFAULT_DEVICE_NAME, help="BLE device name") + p.add_argument("--log", default="INFO", help="log level") + p.add_argument("--json", action="store_true", help="emit JSON result and nothing else on stdout") + return p + + +def run_module( + command: str, + fn: Callable[[WalkingPadController, argparse.Namespace], Awaitable[dict]], + extra_args: Callable[[argparse.ArgumentParser], None] | None = None, +) -> int: + """Boilerplate for a standalone command script. + + The script defines an `async fn(controller, args)` and calls + `run_module("name", fn)`. We handle CLI parsing, connect, run, disconnect. + """ + parser = base_argparser(command) + if extra_args: + extra_args(parser) + args = parser.parse_args() + setup_logging(args.log) + + async def main(): + result, _ = await run_inprocess(command, fn, args) + if args.json: + print(result.as_json()) + else: + print( + f"[{result.command}] ok={result.ok} latency={result.latency_ms:.0f}ms " + f"connected: {result.before_connected}->{result.after_connected} " + f"speed: {result.before_speed}->{result.after_speed} " + f"belt: {result.before_belt_state}->{result.after_belt_state} " + f"err={result.error} extra={result.extra}" + ) + return 0 if result.ok else 1 + + return asyncio.run(main()) diff --git a/tests/commands/test_connect.py b/tests/commands/test_connect.py new file mode 100644 index 0000000..9c2a982 --- /dev/null +++ b/tests/commands/test_connect.py @@ -0,0 +1,22 @@ +"""connect — exercise the full connect flow (scan + BleakClient + setup).""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + # If we got here, _common.run_inprocess already connected. Just verify. + return { + "ok": controller.connected, + "protocol": controller.protocol.value, + "min_speed": controller.min_speed, + "max_speed": controller.max_speed, + "speed_increment": controller.speed_increment, + "firmware_version": controller.firmware_version, + } + + +if __name__ == "__main__": + sys.exit(run_module("connect", fn)) diff --git a/tests/commands/test_disconnect.py b/tests/commands/test_disconnect.py new file mode 100644 index 0000000..07fa415 --- /dev/null +++ b/tests/commands/test_disconnect.py @@ -0,0 +1,15 @@ +"""disconnect — connect, then disconnect cleanly. Verifies post-state.""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + await controller.disconnect() + return {"ok": not controller.connected} + + +if __name__ == "__main__": + sys.exit(run_module("disconnect", fn)) diff --git a/tests/commands/test_observe.py b/tests/commands/test_observe.py new file mode 100644 index 0000000..dba5236 --- /dev/null +++ b/tests/commands/test_observe.py @@ -0,0 +1,69 @@ +"""observe — sit idle on an existing connection for N seconds and snapshot +the resulting status. No commands sent. + +Used by the orchestrator to insert observation windows between commands +(e.g. "verify counters keep advancing during a 5-second walk window"). +""" + +import asyncio +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + if not controller.connected: + return {"ok": False, "error": "not connected"} + + pre = { + "speed": controller.status.speed, + "belt_state": int(controller.status.belt_state), + "duration": controller.status.duration, + "distance": controller.status.distance, + "steps": controller.status.steps, + "calories": controller.status.calories, + } + + deadline = time.time() + args.duration + samples = 0 + last_speed = pre["speed"] + speed_min, speed_max = pre["speed"], pre["speed"] + while time.time() < deadline: + if not controller.connected: + return {"ok": False, "error": "disconnected during observe", "pre": pre} + s = controller.status.speed + speed_min = min(speed_min, s) + speed_max = max(speed_max, s) + last_speed = s + samples += 1 + await asyncio.sleep(0.2) + + post = { + "speed": last_speed, + "belt_state": int(controller.status.belt_state), + "duration": controller.status.duration, + "distance": controller.status.distance, + "steps": controller.status.steps, + "calories": controller.status.calories, + } + return { + "ok": True, + "pre": pre, + "post": post, + "samples": samples, + "speed_min": speed_min, + "speed_max": speed_max, + } + + +def add_args(p): + p.add_argument("--duration", type=float, default=3.0, + help="seconds to observe") + + +if __name__ == "__main__": + sys.exit(run_module("observe", fn, add_args)) diff --git a/tests/commands/test_pause.py b/tests/commands/test_pause.py new file mode 100644 index 0000000..3fc5886 --- /dev/null +++ b/tests/commands/test_pause.py @@ -0,0 +1,77 @@ +"""pause — sends FTMS STOP_OR_PAUSE with PAUSE param via the public API. + +Distinct from stop in that the session counters (time/distance/cal/steps) +survive the pause and a subsequent start() resumes them. Verifies that +once the belt has decelerated to zero, belt_state lands at PAUSED — the +distinguishing observable from a hard stop, which lands at STOPPED. +""" + +import asyncio +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from walkingpad_controller import BeltState # noqa: E402 + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + if not controller.connected: + return {"ok": False, "error": "not connected"} + + # Optionally bring the belt up first so we have something to pause + if args.start_first and controller.status.speed == 0: + started = await controller.start() + if not started: + return {"ok": False, "error": "start_first failed"} + # Let speed climb a bit so the deceleration window after pause + # is observable + await asyncio.sleep(args.run_for) + + speed_before = controller.status.speed + ok = await controller.pause() + if not ok: + return { + "ok": False, "error": "pause() returned False", + "speed_before": speed_before, + } + + # Wait until the belt actually reaches zero (deceleration takes ~5-10s + # on KS-HD-Z1D from 2.5 km/h). Cap at args.timeout. + deadline = time.time() + args.timeout + while time.time() < deadline: + if not controller.connected: + return { + "ok": False, "error": "disconnected during deceleration", + "speed_before": speed_before, + } + if controller.status.speed < 0.05: + break + await asyncio.sleep(0.2) + + final_state = int(controller.status.belt_state) + final_speed = controller.status.speed + return { + "ok": final_state == BeltState.PAUSED, + "speed_before": speed_before, + "speed_after": final_speed, + "belt_state": final_state, + "belt_state_name": BeltState(final_state).name if final_state in iter(BeltState) else "?", + "duration_s": controller.status.duration, + } + + +def add_args(p): + p.add_argument("--start-first", action="store_true", + help="cold-start the belt first if it isn't moving") + p.add_argument("--run-for", type=float, default=3.0, + help="seconds to leave belt running before pause (when --start-first)") + p.add_argument("--timeout", type=float, default=15.0, + help="seconds to wait for speed to reach zero before giving up") + + +if __name__ == "__main__": + sys.exit(run_module("pause", fn, add_args)) diff --git a/tests/commands/test_reset.py b/tests/commands/test_reset.py new file mode 100644 index 0000000..8e5db47 --- /dev/null +++ b/tests/commands/test_reset.py @@ -0,0 +1,17 @@ +"""reset — FTMS RESET (opcode 0x01). Some firmwares don't support this.""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + if controller._ftms is None: + return {"ok": False, "error": "no FTMS backend (WiLink device?)"} + ok = await controller._ftms.reset() + return {"ok": ok} + + +if __name__ == "__main__": + sys.exit(run_module("reset", fn)) diff --git a/tests/commands/test_resume.py b/tests/commands/test_resume.py new file mode 100644 index 0000000..b567599 --- /dev/null +++ b/tests/commands/test_resume.py @@ -0,0 +1,74 @@ +"""resume — call start() against a paused belt and verify the session +continues (counters not reset, belt_state ACTIVE). + +Functionally identical to test_start.py at the BLE layer (FTMS uses one +opcode for both — `START_OR_RESUME`), but distinguished by the expected +observable: counters carry over rather than start at zero. + +Useful as a discrete script for the orchestrator's pause→resume scenario, +where we want to verify the resume specifically preserved session state. +""" + +import asyncio +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from walkingpad_controller import BeltState # noqa: E402 + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + if not controller.connected: + return {"ok": False, "error": "not connected"} + + pre_state = int(controller.status.belt_state) + pre_duration = controller.status.duration + pre_distance = controller.status.distance + pre_steps = controller.status.steps + + started = await controller.start() + if not started: + return { + "ok": False, "error": "start() returned False", + "pre_state": pre_state, + } + + # Wait for belt_state == ACTIVE (or timeout) + deadline = time.time() + args.timeout + while time.time() < deadline: + if not controller.connected: + return {"ok": False, "error": "disconnected during resume"} + if int(controller.status.belt_state) == BeltState.ACTIVE: + break + await asyncio.sleep(0.2) + + post_state = int(controller.status.belt_state) + return { + "ok": post_state == BeltState.ACTIVE, + "pre_state": pre_state, + "post_state": post_state, + "pre_state_name": BeltState(pre_state).name if pre_state in iter(BeltState) else "?", + "post_state_name": BeltState(post_state).name if post_state in iter(BeltState) else "?", + # Session counters before/after — orchestrator checks they don't + # regress (which would mean the device treated this as a fresh + # session rather than a resume). + "pre_duration": pre_duration, + "post_duration": controller.status.duration, + "pre_distance": pre_distance, + "post_distance": controller.status.distance, + "pre_steps": pre_steps, + "post_steps": controller.status.steps, + } + + +def add_args(p): + p.add_argument("--timeout", type=float, default=10.0, + help="seconds to wait for belt to reach ACTIVE") + + +if __name__ == "__main__": + sys.exit(run_module("resume", fn, add_args)) diff --git a/tests/commands/test_set_inclination.py b/tests/commands/test_set_inclination.py new file mode 100644 index 0000000..458418f --- /dev/null +++ b/tests/commands/test_set_inclination.py @@ -0,0 +1,24 @@ +"""set_target_inclination — FTMS opcode 0x03. Most KingSmith treadmills +have fixed inclination so this typically returns OPERATION_FAILED, but +the test exists to confirm graceful handling.""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + if controller._ftms is None: + return {"ok": False, "error": "no FTMS backend (WiLink device?)"} + ok = await controller._ftms.set_target_inclination(args.percent) + return {"ok": ok, "target_percent": args.percent} + + +def add_args(p): + p.add_argument("--percent", type=float, default=0.0, + help="inclination in percent") + + +if __name__ == "__main__": + sys.exit(run_module("set_target_inclination", fn, add_args)) diff --git a/tests/commands/test_set_speed.py b/tests/commands/test_set_speed.py new file mode 100644 index 0000000..da65ca2 --- /dev/null +++ b/tests/commands/test_set_speed.py @@ -0,0 +1,48 @@ +"""set_speed — set a single target speed (default 2.0 km/h) or sweep.""" + +import asyncio +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + # Belt must be moving for set_target_speed to apply on KingSmith firmware. + if controller.status.speed == 0 and args.start_first: + await controller.start() + await asyncio.sleep(args.settle) + + if args.sweep: + results = [] + for v in args.sweep: + ok = await controller.set_speed(v) + await asyncio.sleep(args.gap) + results.append({"target": v, "ok": ok, "observed": controller.status.speed}) + all_ok = all(r["ok"] for r in results) + return {"ok": all_ok, "results": results} + + ok = await controller.set_speed(args.speed) + await asyncio.sleep(args.gap) + return { + "ok": ok, + "target": args.speed, + "observed": controller.status.speed, + } + + +def add_args(p): + p.add_argument("--speed", type=float, default=2.0, + help="target speed in km/h (single value)") + p.add_argument("--sweep", type=float, nargs="*", + help="sweep multiple target speeds (overrides --speed)") + p.add_argument("--start-first", action="store_true", + help="cold-start belt first if stopped") + p.add_argument("--settle", type=float, default=4.0, + help="settle time after start before set_speed") + p.add_argument("--gap", type=float, default=2.0, + help="seconds to wait after set_speed before reading") + + +if __name__ == "__main__": + sys.exit(run_module("set_speed", fn, add_args)) diff --git a/tests/commands/test_start.py b/tests/commands/test_start.py new file mode 100644 index 0000000..c850228 --- /dev/null +++ b/tests/commands/test_start.py @@ -0,0 +1,27 @@ +"""start — exercise the cold-start path. Returns ok if the belt is moving.""" + +import asyncio +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + started = await controller.start() + # Give the belt a moment to ramp up before we sample speed + if started and controller.connected: + await asyncio.sleep(args.settle) + return { + "ok": started and controller.connected, + "moving": controller.status.speed > 0, + } + + +def add_args(p): + p.add_argument("--settle", type=float, default=0.0, + help="seconds to wait after start() before sampling speed") + + +if __name__ == "__main__": + sys.exit(run_module("start", fn, add_args)) diff --git a/tests/commands/test_stop.py b/tests/commands/test_stop.py new file mode 100644 index 0000000..ff201c1 --- /dev/null +++ b/tests/commands/test_stop.py @@ -0,0 +1,31 @@ +"""stop — must be issued only when the belt is running, otherwise the +device may reject it. Optionally starts the belt first via --start-first.""" + +import asyncio +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + started = None + if args.start_first and controller.status.speed == 0: + started = await controller.start() + await asyncio.sleep(args.run_for) + stopped = await controller.stop() + return { + "ok": stopped, + "pre_started": started, + } + + +def add_args(p): + p.add_argument("--start-first", action="store_true", + help="cold-start the belt first if it isn't moving") + p.add_argument("--run-for", type=float, default=2.0, + help="seconds to leave belt running before stop (when --start-first)") + + +if __name__ == "__main__": + sys.exit(run_module("stop", fn, add_args)) diff --git a/tests/commands/test_switch_mode.py b/tests/commands/test_switch_mode.py new file mode 100644 index 0000000..57ae166 --- /dev/null +++ b/tests/commands/test_switch_mode.py @@ -0,0 +1,28 @@ +"""switch_mode — exercise the OperatingMode (AUTO/MANUAL/STANDBY) path.""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from walkingpad_controller import OperatingMode # noqa: E402 + +from commands._common import run_module # noqa: E402 + + +_NAMED = {m.name.lower(): m for m in OperatingMode} + + +async def fn(controller, args) -> dict: + mode = _NAMED.get(args.mode.lower()) + if mode is None: + return {"ok": False, "error": f"unknown mode: {args.mode!r}"} + ok = await controller.switch_mode(mode) + return {"ok": ok, "requested": mode.name} + + +def add_args(p): + p.add_argument("--mode", default="standby", + choices=sorted(_NAMED), help="target OperatingMode") + + +if __name__ == "__main__": + sys.exit(run_module("switch_mode", fn, add_args)) diff --git a/tests/commands/test_update_state.py b/tests/commands/test_update_state.py new file mode 100644 index 0000000..b212f92 --- /dev/null +++ b/tests/commands/test_update_state.py @@ -0,0 +1,26 @@ +"""update_state — fire a status refresh; on FTMS this is a synthetic update.""" + +import sys +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from commands._common import run_module # noqa: E402 + + +async def fn(controller, args) -> dict: + await controller.update_state() + s = controller.status + return { + "ok": True, + "speed": s.speed, + "belt_state": int(s.belt_state), + "duration_s": s.duration, + "distance_m": s.distance, + "calories": s.calories, + "steps": s.steps, + "training_status": s.training_status, + "last_fm_event": s.last_fm_event, + } + + +if __name__ == "__main__": + sys.exit(run_module("update_state", fn)) diff --git a/tests/probe_disconnect.py b/tests/probe_disconnect.py new file mode 100644 index 0000000..77f188f --- /dev/null +++ b/tests/probe_disconnect.py @@ -0,0 +1,274 @@ +"""Diagnostic probe for the random-disconnect pattern. + +Runs four scenarios back-to-back and times exactly when the BLE link +dies in each. Each scenario is preceded by a clean reconnect. + +A: idle — connect, send nothing, wait for disconnect +B: belt running — connect, start, set 2 km/h, then do nothing +C: post-stop — connect, start, set speed, stop, then do nothing +D: post-stop+poll — connect, start, set speed, stop, then read battery + every second to keep traffic flowing + +For each scenario we record every notification (handle, payload, dt) +and the time between the last action and the eventual disconnect. +If scenario D survives noticeably longer than C, the cause is "no +traffic → supervision-timeout." If A survives indefinitely we've +ruled out plain idle-disconnect. +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import time +from dataclasses import dataclass, field + +from bleak import BleakScanner + +from walkingpad_controller import WalkingPadController, ProtocolType + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logging.getLogger("bleak").setLevel(logging.WARNING) +_LOGGER = logging.getLogger("probe") + +DEVICE_NAME = "KS-HD-Z1D" +OBSERVATION = 60.0 # max seconds to wait per scenario before giving up + + +@dataclass +class Scenario: + name: str + description: str + last_action_at: float = 0.0 + disconnect_at: float = 0.0 + notif_count: int = 0 + notif_intervals: list[float] = field(default_factory=list) + last_notif_at: float = 0.0 + cmd_log: list[str] = field(default_factory=list) + completed: bool = False + disconnected: bool = False + + +async def find_device(): + _LOGGER.info("Scanning %s…", DEVICE_NAME) + d = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=15.0) + if d is None: + _LOGGER.error("Device not found") + sys.exit(1) + return d + + +async def connect_with_capture(scenario: Scenario): + device = await find_device() + controller = WalkingPadController(ble_device=device) + assert controller.protocol == ProtocolType.FTMS + + disconnect_event = asyncio.Event() + + def on_status(status): + now = time.time() + if scenario.last_notif_at: + scenario.notif_intervals.append(now - scenario.last_notif_at) + scenario.last_notif_at = now + scenario.notif_count += 1 + + def on_disc(): + scenario.disconnect_at = time.time() + scenario.disconnected = True + disconnect_event.set() + + controller.register_status_callback(on_status) + controller.register_disconnect_callback(on_disc) + + await controller.connect() + if not controller.connected: + _LOGGER.error("Initial connect failed for scenario %s", scenario.name) + return None, None + return controller, disconnect_event + + +async def wait_for_disconnect(scenario: Scenario, disconnect_event: asyncio.Event, timeout: float): + deadline = time.time() + timeout + last_log = 0.0 + while time.time() < deadline: + if disconnect_event.is_set(): + elapsed = scenario.disconnect_at - scenario.last_action_at + _LOGGER.warning( + " -> DISCONNECTED after %.1fs of idle (scenario %s)", + elapsed, + scenario.name, + ) + return + # heartbeat + now = time.time() + if now - last_log > 5: + elapsed = now - scenario.last_action_at + _LOGGER.info( + " scenario %s alive: %.0fs since last action, notifs=%d", + scenario.name, + elapsed, + scenario.notif_count, + ) + last_log = now + await asyncio.sleep(0.5) + scenario.completed = True + _LOGGER.info( + " scenario %s SURVIVED %.0fs (notifs=%d) — no disconnect", + scenario.name, + timeout, + scenario.notif_count, + ) + + +async def run_scenario_a(): + """Connect, do nothing.""" + s = Scenario(name="A", description="idle, no actions") + _LOGGER.info("=== Scenario A: %s ===", s.description) + controller, disc = await connect_with_capture(s) + if controller is None: + return s + s.last_action_at = time.time() + s.cmd_log.append("connect") + await wait_for_disconnect(s, disc, OBSERVATION) + try: + if controller.connected: + await controller.disconnect() + except Exception: + pass + return s + + +async def run_scenario_b(): + """Connect, start, set 2 km/h, leave belt running.""" + s = Scenario(name="B", description="belt running, then idle") + _LOGGER.info("=== Scenario B: %s ===", s.description) + controller, disc = await connect_with_capture(s) + if controller is None: + return s + s.cmd_log.append("connect") + await controller.start() + s.cmd_log.append("start") + await controller.set_speed(2.0) + s.cmd_log.append("set_speed 2.0") + s.last_action_at = time.time() + await wait_for_disconnect(s, disc, OBSERVATION) + try: + if controller.connected: + await controller.stop() + await controller.disconnect() + except Exception: + pass + return s + + +async def run_scenario_c(): + """Connect, start, set speed, stop, do nothing — replicates 'after-stop' bug.""" + s = Scenario(name="C", description="post-stop, no further traffic") + _LOGGER.info("=== Scenario C: %s ===", s.description) + controller, disc = await connect_with_capture(s) + if controller is None: + return s + s.cmd_log.append("connect") + await controller.start() + s.cmd_log.append("start") + await controller.set_speed(2.0) + s.cmd_log.append("set_speed 2.0") + await asyncio.sleep(3) + await controller.stop() + s.cmd_log.append("stop") + s.last_action_at = time.time() + await wait_for_disconnect(s, disc, OBSERVATION) + try: + if controller.connected: + await controller.disconnect() + except Exception: + pass + return s + + +async def run_scenario_d(): + """Connect, start, set speed, stop, then poll something every 1s.""" + s = Scenario(name="D", description="post-stop, periodic 1Hz read") + _LOGGER.info("=== Scenario D: %s ===", s.description) + controller, disc = await connect_with_capture(s) + if controller is None: + return s + s.cmd_log.append("connect") + await controller.start() + s.cmd_log.append("start") + await controller.set_speed(2.0) + s.cmd_log.append("set_speed 2.0") + await asyncio.sleep(3) + await controller.stop() + s.cmd_log.append("stop") + s.last_action_at = time.time() + + deadline = time.time() + OBSERVATION + while time.time() < deadline and not disc.is_set(): + try: + # Read Battery Level (a benign characteristic) — keeps traffic flowing. + await controller._ftms._client.read_gatt_char( + "00002a19-0000-1000-8000-00805f9b34fb" + ) + except Exception as err: + _LOGGER.warning(" read_gatt_char failed: %s", err) + break + await asyncio.sleep(1.0) + if disc.is_set(): + elapsed = s.disconnect_at - s.last_action_at + _LOGGER.warning( + " -> DISCONNECTED after %.1fs while polling 1Hz", elapsed + ) + else: + s.completed = True + _LOGGER.info( + " scenario %s SURVIVED %.0fs of 1Hz polling — no disconnect", + s.name, + OBSERVATION, + ) + try: + if controller.connected: + await controller.disconnect() + except Exception: + pass + return s + + +async def main(): + results: list[Scenario] = [] + for runner in (run_scenario_a, run_scenario_b, run_scenario_c, run_scenario_d): + try: + r = await runner() + results.append(r) + except Exception as err: + _LOGGER.exception("scenario raised: %s", err) + # back off between scenarios so the device fully releases the link + await asyncio.sleep(8) + + _LOGGER.info("=" * 70) + _LOGGER.info("SUMMARY") + _LOGGER.info("=" * 70) + for s in results: + if s.disconnected: + elapsed = s.disconnect_at - s.last_action_at + verdict = f"disconnected after {elapsed:.1f}s" + elif s.completed: + verdict = f"survived {OBSERVATION:.0f}s" + else: + verdict = "did not run" + _LOGGER.info( + " %s | %-32s | notifs=%-3d | %s", + s.name, + s.description, + s.notif_count, + verdict, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_keepalive.py b/tests/probe_keepalive.py new file mode 100644 index 0000000..c9d0cb6 --- /dev/null +++ b/tests/probe_keepalive.py @@ -0,0 +1,217 @@ +"""Decisive test: does adding traffic prevent disconnects? + +Three parallel scenarios, each with a fresh connection: + +A: silent — connect, then sit idle. No GATT traffic from us. +B: read-1Hz — connect, then read Battery Level once per second. +C: read-fast — connect, then read Battery Level every 200 ms. +D: write-fast — connect, then re-write CCCD on Treadmill Data every + second (lots of writes from us). + +For each: log every notification we receive, log every disconnect with +its timestamp, log every read latency. The hypothesis "lib not sending +enough traffic causes disconnects" predicts: A disconnects fastest, D +holds longest. The hypothesis "signal/firmware" predicts: roughly the +same. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from bleak import BleakClient, BleakScanner + +from commands._common import DEFAULT_DEVICE_NAME, setup_logging # noqa: E402 + +_LOGGER = logging.getLogger("keepalive") + +BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb" +TREADMILL_DATA_UUID = "00002acd-0000-1000-8000-00805f9b34fb" +TM_CCCD_HANDLE = None # discovered at runtime + + +@dataclass +class ScenarioResult: + name: str + description: str + connected_at: float = 0.0 + disconnected_at: float = 0.0 + held_seconds: float = 0.0 + activity_count: int = 0 + last_activity_ok: bool = True + notif_count: int = 0 + read_latencies_ms: list[float] = field(default_factory=list) + early_failure: str | None = None + + +async def find_device(name: str): + return await BleakScanner.find_device_by_name(name, timeout=15) + + +async def run_scenario( + name: str, + description: str, + activity_fn, + duration: float, + device_name: str, +) -> ScenarioResult: + res = ScenarioResult(name=name, description=description) + device = await find_device(device_name) + if device is None: + res.early_failure = "device-not-found" + return res + + disc_event = asyncio.Event() + + def on_disc(_): + res.disconnected_at = time.time() + disc_event.set() + + client = BleakClient(device, disconnected_callback=on_disc) + try: + await client.connect() + except Exception as err: + res.early_failure = f"connect-failed: {err}" + return res + + res.connected_at = time.time() + if not client.is_connected: + res.early_failure = "not-connected-post-connect" + return res + + # Subscribe to Treadmill Data to count incoming notifications (a bystander + # signal — both scenarios receive these the same way, so notifs only tell + # us whether the link is up, not whether our traffic helped) + def on_notif(_h, _d): + res.notif_count += 1 + + with contextlib.suppress(Exception): + await client.start_notify(TREADMILL_DATA_UUID, on_notif) + + deadline = time.time() + duration + last_log = res.connected_at + while time.time() < deadline and not disc_event.is_set(): + if activity_fn is not None: + t0 = time.time() + try: + await activity_fn(client) + res.activity_count += 1 + res.last_activity_ok = True + res.read_latencies_ms.append((time.time() - t0) * 1000.0) + except Exception as err: + res.last_activity_ok = False + # don't break — keep counting, but record + res.early_failure = f"activity-failed at {time.time() - res.connected_at:.1f}s: {err}" + break + # Heartbeat + now = time.time() + if now - last_log > 5: + elapsed = now - res.connected_at + _LOGGER.info( + " %s: alive %.0fs, activity=%d, notifs=%d, conn=%s", + name, elapsed, res.activity_count, res.notif_count, client.is_connected, + ) + last_log = now + + # Compute held time + if disc_event.is_set(): + res.held_seconds = res.disconnected_at - res.connected_at + else: + res.held_seconds = duration + + # Cleanup + with contextlib.suppress(Exception): + if client.is_connected: + await client.disconnect() + return res + + +# Activity functions + +async def silent_activity(_client): + # Do nothing — but yield to event loop + await asyncio.sleep(0.5) + + +async def read_1hz(client): + await client.read_gatt_char(BATTERY_LEVEL_UUID) + await asyncio.sleep(1.0) + + +async def read_fast(client): + await client.read_gatt_char(BATTERY_LEVEL_UUID) + await asyncio.sleep(0.2) + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--duration", type=float, default=45.0, + help="seconds to observe each scenario") + p.add_argument("--inter-gap", type=float, default=8.0, + help="seconds between scenarios") + p.add_argument("--reps", type=int, default=2) + p.add_argument("--log", default="INFO") + args = p.parse_args() + setup_logging(args.log) + + scenarios = [ + ("A_silent", "no GATT activity", silent_activity), + ("B_read_1Hz", "Battery Level read 1× / sec", read_1hz), + ("C_read_5Hz", "Battery Level read 5× / sec", read_fast), + ] + + all_results: list[ScenarioResult] = [] + for rep in range(1, args.reps + 1): + for sname, sdesc, sfn in scenarios: + label = f"{sname}#{rep}" + print(f"\n=== rep {rep}: {label} — {sdesc} ===") + r = await run_scenario(label, sdesc, sfn, args.duration, args.name) + all_results.append(r) + verdict = " -> " + if r.early_failure: + verdict += f"EARLY FAIL ({r.early_failure}) at held {r.held_seconds:.1f}s" + else: + outcome = "still connected" if r.disconnected_at == 0 else f"disconnected after {r.held_seconds:.1f}s" + avg_lat = (sum(r.read_latencies_ms) / len(r.read_latencies_ms) + if r.read_latencies_ms else 0) + verdict += f"{outcome} | activity={r.activity_count} avg-lat={avg_lat:.0f}ms | notifs={r.notif_count}" + print(verdict) + await asyncio.sleep(args.inter_gap) + + # Summary + print() + print("=" * 80) + print("SUMMARY (by scenario, held seconds)") + print("=" * 80) + by_name: dict[str, list[float]] = {} + for r in all_results: + bare = r.name.split("#")[0] + by_name.setdefault(bare, []).append(r.held_seconds) + + print(f" {'scenario':<14} {'reps':>4} {'min':>7} {'avg':>7} {'max':>7} description") + for sname, sdesc, _ in scenarios: + held = by_name.get(sname, []) + if not held: + continue + mn = min(held); mx = max(held); avg = sum(held) / len(held) + print(f" {sname:<14} {len(held):>4} {mn:>7.1f} {avg:>7.1f} {mx:>7.1f} {sdesc}") + + print() + print("Interpretation:") + print(" - If A held >> B/C: signal/firmware-driven disconnects, traffic doesn't help") + print(" - If C held >> A: more traffic helps, suggests keepalive theory") + print(" - If they're all roughly equal: traffic isn't the lever") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_lib_stability.py b/tests/probe_lib_stability.py new file mode 100644 index 0000000..048e810 --- /dev/null +++ b/tests/probe_lib_stability.py @@ -0,0 +1,75 @@ +"""How often does WalkingPadController.connect() + idle survive? + +Runs N reps of: connect → idle → disconnect, on a fresh controller +each time. Measures how long the connection actually holds. + +This is the headline metric — if the connect-path trim worked, the +held times should be much closer to the bare-BleakClient baseline +(reliably ≥ idle_seconds). +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from commands._common import DEFAULT_DEVICE_NAME, connect, safe_disconnect, setup_logging # noqa: E402 + +_LOGGER = logging.getLogger("stability") + + +async def run_one(idle: float, name: str) -> tuple[bool, float]: + """Returns (survived, held_seconds).""" + disc_at = [None] + + def disc(): + disc_at[0] = time.time() + + controller = await connect(name=name, on_disconnect=disc) + if controller is None: + return False, 0.0 + t_connected = time.time() + deadline = t_connected + idle + while time.time() < deadline and disc_at[0] is None: + await asyncio.sleep(0.2) + survived = disc_at[0] is None + held = (disc_at[0] - t_connected) if disc_at[0] else (time.time() - t_connected) + await safe_disconnect(controller) + return survived, held + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--reps", type=int, default=5) + p.add_argument("--idle", type=float, default=20.0) + p.add_argument("--gap", type=float, default=6.0) + p.add_argument("--log", default="WARNING") + args = p.parse_args() + setup_logging(args.log) + + survived = 0 + helds: list[float] = [] + for rep in range(1, args.reps + 1): + s, h = await run_one(args.idle, args.name) + helds.append(h) + flag = "OK" if s else "DISC" + print(f" rep {rep}: {flag} held={h:.1f}s") + if s: + survived += 1 + await asyncio.sleep(args.gap) + + print() + print(f"Survived {survived}/{args.reps} idle-{args.idle:.0f}s windows") + if helds: + print(f"Held seconds: min={min(helds):.1f} avg={sum(helds)/len(helds):.1f} max={max(helds):.1f}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_preworkout.py b/tests/probe_preworkout.py new file mode 100644 index 0000000..5b8ebf2 --- /dev/null +++ b/tests/probe_preworkout.py @@ -0,0 +1,329 @@ +"""Rigorous probe of the 'PRE_WORKOUT blocks SET_TARGET_SPEED' hypothesis. + +Runs four experiments, each with the BLE notification trace recorded +inline. Every set_speed records the device's training_status at the +moment of the write and the outcome. We then tabulate the correlation +to confirm or falsify the hypothesis. + +Experiments: + E1: after_reset × 3 + Reproducibility — does reset+start+set_speed always fail? + E2: cold_start_then_setspeed_repeated + After a cold start, try set_speed every 5s for 30s. Does any + set_speed succeed once enough time has passed in PRE_WORKOUT? + E3: cold_start_then_set_inclination + Is the block specific to SET_TARGET_SPEED, or does it apply to + any target-setting opcode (e.g. SET_TARGET_INCLINATION)? + E4: stop_start_setspeed × 3 + "Resume" path (start while belt is decelerating). Does set_speed + work in this state? + +Output: per-attempt rows + a falsification check. Hypothesis is +falsified if any set_speed in PRE_WORKOUT succeeded, or any set_speed +out of PRE_WORKOUT failed. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import struct +import sys +import time +from collections import deque +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from commands._common import DEFAULT_DEVICE_NAME, connect, safe_disconnect, setup_logging # noqa: E402 + +_LOGGER = logging.getLogger("preworkout") + +TRAINING_STATUS_UUID = "00002ad3-0000-1000-8000-00805f9b34fb" +FM_STATUS_UUID = "00002ada-0000-1000-8000-00805f9b34fb" +TM_DATA_UUID = "00002acd-0000-1000-8000-00805f9b34fb" + + +@dataclass +class Attempt: + experiment: str + rep: int + target_speed: float | None + target_incline: float | None + training_status_at_write: int | None + last_fm_event: int | None + speed_before: float + speed_after: float + cmd_ok: bool # what the lib reported + actually_changed: bool # did the speed move toward target by >= 0.5 + elapsed_since_start_s: float + extra: str = "" + + +class TraceState: + def __init__(self): + self.training_status: int | None = None + self.last_fm_event: int | None = None + self.last_speed: float = 0.0 + self.events: deque = deque() + self.t0 = time.time() + + def record(self, kind: str, msg: str): + self.events.append((time.time() - self.t0, kind, msg)) + + def on_training_status(self, _h, data: bytes): + if len(data) >= 2: + self.training_status = data[1] + self.record("ts", f"Training Status flags=0x{data[0]:02x} status={data[1]}") + + def on_fm_status(self, _h, data: bytes): + if data: + self.last_fm_event = data[0] + self.record("fm", f"FM Status op=0x{data[0]:02x} data={bytes(data).hex()}") + + def on_treadmill_data(self, _h, data: bytes): + if len(data) >= 4: + self.last_speed = struct.unpack_from(" {s.training_status}") + if s.last_fm_event != ts.last_fm_event: + ts.last_fm_event = s.last_fm_event + ts.record("fm", f"last_fm_event -> 0x{s.last_fm_event:02x}") + ts.last_speed = s.speed + await asyncio.sleep(0.1) + except asyncio.CancelledError: + return + + ts._poll_task = asyncio.create_task(_poll()) # type: ignore[attr-defined] + + +async def teardown_trace(ts: TraceState): + task = getattr(ts, "_poll_task", None) + if task: + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + +async def attempt_set_speed(controller, ts: TraceState, target: float, + experiment: str, rep: int, t_start: float) -> Attempt: + s_before = controller.status + speed_before = s_before.speed + training_at_write = s_before.training_status + fm_at_write = s_before.last_fm_event + ok = await controller.set_speed(target) + await asyncio.sleep(2.0) + speed_after = controller.status.speed + return Attempt( + experiment=experiment, rep=rep, + target_speed=target, target_incline=None, + training_status_at_write=training_at_write, + last_fm_event=fm_at_write, + speed_before=speed_before, speed_after=speed_after, + cmd_ok=ok, + actually_changed=abs(speed_after - target) < 0.3, + elapsed_since_start_s=time.time() - t_start, + ) + + +async def attempt_set_inclination(controller, ts: TraceState, percent: float, + experiment: str, rep: int, t_start: float) -> Attempt: + s_before = controller.status + training_at_write = s_before.training_status + fm_at_write = s_before.last_fm_event + speed_before = s_before.speed + ok = False + err = "" + try: + ok = await controller._ftms.set_target_inclination(percent) + except Exception as e: + err = str(e) + await asyncio.sleep(2.0) + return Attempt( + experiment=experiment, rep=rep, + target_speed=None, target_incline=percent, + training_status_at_write=training_at_write, + last_fm_event=fm_at_write, + speed_before=speed_before, speed_after=controller.status.speed, + cmd_ok=ok, + actually_changed=False, + elapsed_since_start_s=time.time() - t_start, + extra=err, + ) + + +async def safe_full_stop(controller): + with contextlib.suppress(Exception): + if controller.connected: + await controller.stop() + await asyncio.sleep(2.0) + + +async def experiment_after_reset(controller, ts: TraceState, rep: int) -> list[Attempt]: + _LOGGER.info("E1 rep %d: reset + start + set_speed", rep) + await controller._ftms.reset() + await asyncio.sleep(2.0) + t_cold = time.time() + await controller.start() + await asyncio.sleep(3.0) # let belt settle at min speed + a = await attempt_set_speed(controller, ts, 2.0, "E1_after_reset", rep, t_cold) + await safe_full_stop(controller) + return [a] + + +async def experiment_cold_setspeed_repeated(controller, ts: TraceState, rep: int) -> list[Attempt]: + """After a cold start, try set_speed every 5s for 30s — does PRE_WORKOUT + eventually time out on its own and let the speed change through?""" + _LOGGER.info("E2 rep %d: cold start + set_speed every 5s for 30s", rep) + await controller._ftms.reset() + await asyncio.sleep(2.0) + t_cold = time.time() + await controller.start() + await asyncio.sleep(3.0) + attempts = [] + targets = [2.0, 2.5, 3.0, 1.5, 2.0, 2.5] + for i, tg in enumerate(targets): + a = await attempt_set_speed(controller, ts, tg, "E2_cold_repeated", rep, t_cold) + attempts.append(a) + await asyncio.sleep(3.0) # 5s total between attempts + await safe_full_stop(controller) + return attempts + + +async def experiment_set_inclination(controller, ts: TraceState, rep: int) -> list[Attempt]: + _LOGGER.info("E3 rep %d: cold start + set_inclination", rep) + await controller._ftms.reset() + await asyncio.sleep(2.0) + t_cold = time.time() + await controller.start() + await asyncio.sleep(3.0) + a = await attempt_set_inclination(controller, ts, 0.0, "E3_set_incline", rep, t_cold) + await safe_full_stop(controller) + return [a] + + +async def experiment_stop_start_setspeed(controller, ts: TraceState, rep: int) -> list[Attempt]: + """Stop a running belt, then start (=resume), then set_speed. + Does set_speed work in the 'resumed without going through a fresh + cold start' state?""" + _LOGGER.info("E4 rep %d: stop -> start (resume) -> set_speed", rep) + if controller.status.speed == 0: + await controller.start() + await asyncio.sleep(4.0) + await controller.stop() + await asyncio.sleep(2.0) # short gap — belt likely still decelerating + t_cold = time.time() + await controller.start() + await asyncio.sleep(3.0) + a = await attempt_set_speed(controller, ts, 2.5, "E4_stop_start_setspeed", rep, t_cold) + await safe_full_stop(controller) + return a if isinstance(a, list) else [a] + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--reps", type=int, default=2, + help="repetitions per experiment") + p.add_argument("--out", type=Path, default=Path("/tmp/preworkout_results.tsv")) + p.add_argument("--log", default="WARNING") + args = p.parse_args() + setup_logging(args.log) + + all_attempts: list[Attempt] = [] + + for exp_fn, exp_name in [ + (experiment_after_reset, "E1_after_reset"), + (experiment_cold_setspeed_repeated, "E2_cold_repeated"), + (experiment_set_inclination, "E3_set_incline"), + (experiment_stop_start_setspeed, "E4_stop_start_setspeed"), + ]: + for rep in range(1, args.reps + 1): + controller = await connect(name=args.name) + if controller is None: + _LOGGER.error("connect failed; skipping %s rep %d", exp_name, rep) + continue + ts = TraceState() + await setup_trace(controller, ts) + try: + attempts = await exp_fn(controller, ts, rep) + all_attempts.extend(attempts) + except Exception as err: # noqa: BLE001 + _LOGGER.exception("%s rep %d raised: %s", exp_name, rep, err) + finally: + await teardown_trace(ts) + await safe_disconnect(controller) + await asyncio.sleep(5.0) # let device settle between experiments + + # Write TSV + with open(args.out, "w") as f: + f.write("experiment\trep\ttarget\ttrain_status\tfm_event\tspeed_before\tspeed_after\tcmd_ok\tactually_changed\telapsed_s\textra\n") + for a in all_attempts: + target = a.target_speed if a.target_speed is not None else f"incline:{a.target_incline}" + f.write( + f"{a.experiment}\t{a.rep}\t{target}\t{a.training_status_at_write}\t" + f"{a.last_fm_event}\t{a.speed_before:.2f}\t{a.speed_after:.2f}\t" + f"{a.cmd_ok}\t{a.actually_changed}\t{a.elapsed_since_start_s:.1f}\t{a.extra}\n" + ) + + # Falsification analysis + print() + print("=" * 100) + print("FALSIFICATION ANALYSIS") + print("=" * 100) + print(f"{'experiment':<28} {'rep':>4} {'target':>10} {'train':>6} {'fm_ev':>6} " + f"{'before':>7} {'after':>7} {'cmd_ok':>7} {'changed':>8} {'elapsed':>9}") + print("-" * 100) + for a in all_attempts: + target = (f"{a.target_speed:.2f}" if a.target_speed is not None else f"i:{a.target_incline}") + print(f"{a.experiment:<28} {a.rep:>4} {target:>10} " + f"{a.training_status_at_write!s:>6} {a.last_fm_event!s:>6} " + f"{a.speed_before:>7.2f} {a.speed_after:>7.2f} " + f"{str(a.cmd_ok):>7} {str(a.actually_changed):>8} {a.elapsed_since_start_s:>9.1f}") + + speed_attempts = [a for a in all_attempts if a.target_speed is not None] + if not speed_attempts: + return + + in_pw = [a for a in speed_attempts if a.training_status_at_write == 13] + out_pw = [a for a in speed_attempts if a.training_status_at_write is not None and a.training_status_at_write != 13] + unknown = [a for a in speed_attempts if a.training_status_at_write is None] + + print() + print("Hypothesis: training_status == 13 (PRE_WORKOUT) blocks SET_TARGET_SPEED") + print("-" * 100) + print(f" set_speed in PRE_WORKOUT: {len(in_pw)} attempts, " + f"{sum(1 for a in in_pw if a.actually_changed)} actually changed speed") + print(f" set_speed not in PRE_WORKOUT (status known): {len(out_pw)} attempts, " + f"{sum(1 for a in out_pw if a.actually_changed)} actually changed speed") + print(f" set_speed with unknown training_status: {len(unknown)} attempts, " + f"{sum(1 for a in unknown if a.actually_changed)} actually changed") + print() + + fal1 = any(a.actually_changed for a in in_pw) + fal2 = any(not a.actually_changed for a in out_pw) + if fal1: + print(" FALSIFIED: at least one set_speed succeeded while in PRE_WORKOUT.") + if fal2: + print(" FALSIFIED: at least one set_speed failed while NOT in PRE_WORKOUT.") + if not fal1 and not fal2 and (in_pw or out_pw): + print(" Hypothesis NOT falsified by this run.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_preworkout2.py b/tests/probe_preworkout2.py new file mode 100644 index 0000000..acd7d97 --- /dev/null +++ b/tests/probe_preworkout2.py @@ -0,0 +1,243 @@ +"""Targeted probe — wait for a specific training_status, then test set_speed. + +Pinpoints whether PRE_WORKOUT specifically blocks SET_TARGET_SPEED, or +whether the failures are correlated with something else entirely. + +For each rep: + 1. reset + start (cold) + 2. wait up to 15 s for training_status to reach 13 (PRE_WORKOUT) — the + lib's _on_training_status puts it on controller.status.training_status + 3. snapshot (status, speed) and write SET_TARGET_SPEED(2.0) + 4. wait up to 5 s for either: + - speed to physically change to ~2.0 (success) + - any change in training_status + - or 5 s elapse (timeout) + 5. record outcome and what happened + +Also captures the full sequence of training_status / fm_status / speed +changes for forensic review. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from commands._common import DEFAULT_DEVICE_NAME, connect, safe_disconnect, setup_logging # noqa: E402 + +_LOGGER = logging.getLogger("preworkout2") + + +TRAINING_STATUS_NAME = { + 0: "OTHER", 1: "IDLE", 2: "WARMING_UP", + 3: "LOW_INT", 4: "HIGH_INT", 5: "RECOVERY", + 6: "ISOMETRIC", 7: "HR_CONTROL", 8: "FITNESS_TEST", + 9: "SPEED_OOC", 10: "COOL_DOWN", 11: "WATT_CONTROL", + 12: "MANUAL", 13: "PRE_WORKOUT", 14: "POST_WORKOUT", +} + + +def ts_name(s: int | None) -> str: + if s is None: + return "?" + return TRAINING_STATUS_NAME.get(s, f"0x{s:02x}") + + +@dataclass +class Sample: + t: float + training_status: int + last_fm_event: int + speed: float + + +@dataclass +class Result: + rep: int + sequence: str + target: float + pre_set_speed_status: int + pre_set_speed_fm: int + pre_set_speed_speed: float + post_set_speed_status: int + post_set_speed_fm: int + post_set_speed_speed: float + cmd_ok: bool + speed_changed: bool + transitions: list[Sample] = field(default_factory=list) + + +async def watch_status(controller, until: callable, timeout: float, samples: list[Sample]): + """Poll controller.status until `until(status)` returns True or timeout.""" + deadline = time.time() + timeout + last_ts = -999 + last_fm = -999 + last_sp = -999.0 + while time.time() < deadline: + s = controller.status + if s.training_status != last_ts or s.last_fm_event != last_fm or abs(s.speed - last_sp) > 0.01: + samples.append(Sample( + t=time.time(), + training_status=s.training_status, + last_fm_event=s.last_fm_event, + speed=s.speed, + )) + last_ts = s.training_status + last_fm = s.last_fm_event + last_sp = s.speed + if until(s): + return True + await asyncio.sleep(0.1) + return False + + +async def run_rep(controller, rep: int, target: float = 2.0) -> Result: + samples: list[Sample] = [] + + # Force-rewrap the training_status handler so we KNOW updates flow into + # controller.status. In some Bleak/BlueZ orderings, a stale subscription + # from a prior process can shadow the new one — re-binding here makes + # this probe deterministic regardless. + client = controller._ftms._client + orig_ts_handler = controller._ftms._on_training_status + with contextlib.suppress(Exception): + await client.stop_notify("00002ad3-0000-1000-8000-00805f9b34fb") + with contextlib.suppress(Exception): + await client.start_notify( + "00002ad3-0000-1000-8000-00805f9b34fb", + lambda h, d: orig_ts_handler(h, bytearray(d)), + ) + + # Reset + cold start + await controller._ftms.reset() + await asyncio.sleep(1.0) + await controller.start() + + # Wait up to 15s for training_status==13 (PRE_WORKOUT) + reached_pre = await watch_status( + controller, + until=lambda s: s.training_status == 13, + timeout=15.0, + samples=samples, + ) + _LOGGER.info("rep %d: waited for PRE_WORKOUT, reached=%s, current=%s", + rep, reached_pre, ts_name(controller.status.training_status)) + + # Capture pre-state + s_pre = controller.status + pre_status = s_pre.training_status + pre_fm = s_pre.last_fm_event + pre_speed = s_pre.speed + + # Send SET_TARGET_SPEED. The library logs the result code if any. + cmd_ok = await controller.set_speed(target) + _LOGGER.info("rep %d: set_speed(%.1f) returned %s", rep, target, cmd_ok) + + # Watch for speed change for up to 5s after the command + await watch_status( + controller, + until=lambda s: abs(s.speed - target) < 0.3, + timeout=5.0, + samples=samples, + ) + + s_post = controller.status + speed_changed = abs(s_post.speed - target) < 0.3 + return Result( + rep=rep, + sequence="reset_start_setspeed", + target=target, + pre_set_speed_status=pre_status, + pre_set_speed_fm=pre_fm, + pre_set_speed_speed=pre_speed, + post_set_speed_status=s_post.training_status, + post_set_speed_fm=s_post.last_fm_event, + post_set_speed_speed=s_post.speed, + cmd_ok=cmd_ok, + speed_changed=speed_changed, + transitions=samples, + ) + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--reps", type=int, default=3) + p.add_argument("--log", default="WARNING") + args = p.parse_args() + setup_logging(args.log) + + results: list[Result] = [] + for rep in range(1, args.reps + 1): + controller = await connect(name=args.name) + if controller is None: + _LOGGER.error("rep %d connect failed", rep) + continue + try: + r = await run_rep(controller, rep) + results.append(r) + except Exception as err: # noqa: BLE001 + _LOGGER.exception("rep %d raised: %s", rep, err) + finally: + with contextlib.suppress(Exception): + if controller.connected and controller.status.speed > 0: + await controller.stop() + await safe_disconnect(controller) + await asyncio.sleep(8.0) + + print() + print("=" * 90) + print("RESULTS") + print("=" * 90) + print(f"{'rep':>3} {'pre_status':<14} {'pre_fm':<8} {'pre_sp':>7} " + f"{'post_status':<14} {'post_fm':<8} {'post_sp':>7} " + f"{'cmd_ok':>7} {'changed':>8}") + for r in results: + print(f"{r.rep:>3} {ts_name(r.pre_set_speed_status):<14} " + f"0x{r.pre_set_speed_fm:02x} {r.pre_set_speed_speed:>7.2f} " + f"{ts_name(r.post_set_speed_status):<14} " + f"0x{r.post_set_speed_fm:02x} {r.post_set_speed_speed:>7.2f} " + f"{str(r.cmd_ok):>7} {str(r.speed_changed):>8}") + + print() + print("=" * 90) + print("PER-REP TRANSITIONS") + print("=" * 90) + for r in results: + print(f"--- rep {r.rep} ---") + if not r.transitions: + print(" (no samples)") + continue + t0 = r.transitions[0].t + for s in r.transitions: + print(f" +{s.t - t0:5.2f}s status={ts_name(s.training_status):<13} " + f"fm=0x{s.last_fm_event:02x} speed={s.speed:.2f}") + print() + + # Final verdict + pre_attempts = [r for r in results if r.pre_set_speed_status == 13] + print("=" * 90) + print("VERDICT") + print("=" * 90) + print(f" attempts in PRE_WORKOUT (status=13): {len(pre_attempts)}") + if pre_attempts: + ok_count = sum(1 for r in pre_attempts if r.speed_changed) + print(f" speed actually changed: {ok_count}/{len(pre_attempts)}") + if ok_count == 0 and len(pre_attempts) >= 2: + print(" HYPOTHESIS HOLDS: PRE_WORKOUT consistently blocks set_speed") + elif ok_count == len(pre_attempts): + print(" HYPOTHESIS FALSIFIED: set_speed always worked in PRE_WORKOUT") + else: + print(" PARTIAL: outcome inconsistent in PRE_WORKOUT — other factor at play") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_setup_killer.py b/tests/probe_setup_killer.py new file mode 100644 index 0000000..c717f73 --- /dev/null +++ b/tests/probe_setup_killer.py @@ -0,0 +1,210 @@ +"""Bisect which connection-setup step destabilises the link. + +Earlier finding: bare BleakClient.connect() + idle 45 s holds reliably. +Lib's full setup (subscribe ×4 + reads + REQUEST_CONTROL) disconnects in +~3 s. So one (or several) of those ops is the trigger. + +Each scenario starts from a fresh connect, performs a specific subset of +the setup, then idles for IDLE seconds. We record the actual time +to disconnect. + +Scenarios (additive): + S0: connect-only + S1: + subscribe Treadmill Data (notify) + S2: + subscribe FM Status (notify) + S3: + subscribe Training Status (notify) + S4: + subscribe Control Point (INDICATE — different from notify, suspicious) + S5: + read FTMS Feature + S6: + read Supported Speed Range + S7: + write REQUEST_CONTROL +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from bleak import BleakClient, BleakScanner + +from commands._common import DEFAULT_DEVICE_NAME, setup_logging # noqa: E402 + +_LOGGER = logging.getLogger("killer") + +FTMS_FEATURE = "00002acc-0000-1000-8000-00805f9b34fb" +SUPPORTED_SPEED_RANGE = "00002ad4-0000-1000-8000-00805f9b34fb" +TREADMILL_DATA = "00002acd-0000-1000-8000-00805f9b34fb" +MACHINE_STATUS = "00002ada-0000-1000-8000-00805f9b34fb" +TRAINING_STATUS = "00002ad3-0000-1000-8000-00805f9b34fb" +CONTROL_POINT = "00002ad9-0000-1000-8000-00805f9b34fb" + + +@dataclass +class Result: + name: str + setup_ok: bool + held_seconds: float + setup_error: str | None = None + disconnect_during: str | None = None # "setup" or "idle" + + +async def find_device(name: str): + return await BleakScanner.find_device_by_name(name, timeout=15) + + +async def run_scenario(label: str, setup_fn, idle: float, device_name: str) -> Result: + res = Result(name=label, setup_ok=False, held_seconds=0.0) + device = await find_device(device_name) + if device is None: + res.setup_error = "device-not-found" + return res + + disc_event = asyncio.Event() + disc_at = [0.0] + + def on_disc(_): + disc_at[0] = time.time() + disc_event.set() + + client = BleakClient(device, disconnected_callback=on_disc) + try: + await client.connect() + except Exception as err: + res.setup_error = f"connect: {err}" + return res + if not client.is_connected: + res.setup_error = "not-connected-post-connect" + return res + connected_at = time.time() + + # Run the setup operations + try: + await setup_fn(client) + res.setup_ok = True + except Exception as err: + res.setup_error = f"setup: {err}" + if disc_event.is_set(): + res.disconnect_during = "setup" + res.held_seconds = disc_at[0] - connected_at + return res + # else fall through and idle anyway + + # Idle and watch + deadline = time.time() + idle + while time.time() < deadline and not disc_event.is_set(): + await asyncio.sleep(0.2) + if disc_event.is_set(): + res.disconnect_during = "idle" if res.setup_ok else "setup" + res.held_seconds = disc_at[0] - connected_at + else: + res.held_seconds = idle # survived + res.disconnect_during = None + + with contextlib.suppress(Exception): + if client.is_connected: + await client.disconnect() + return res + + +# Setup functions, additive + +async def setup_connect_only(c): + pass + + +async def setup_sub_treadmill(c): + await c.start_notify(TREADMILL_DATA, lambda *_: None) + + +async def setup_sub_fm(c): + await setup_sub_treadmill(c) + await c.start_notify(MACHINE_STATUS, lambda *_: None) + + +async def setup_sub_training(c): + await setup_sub_fm(c) + await c.start_notify(TRAINING_STATUS, lambda *_: None) + + +async def setup_sub_cp(c): + await setup_sub_training(c) + await c.start_notify(CONTROL_POINT, lambda *_: None) + + +async def setup_read_feature(c): + await setup_sub_cp(c) + await c.read_gatt_char(FTMS_FEATURE) + + +async def setup_read_speedrange(c): + await setup_read_feature(c) + await c.read_gatt_char(SUPPORTED_SPEED_RANGE) + + +async def setup_request_control(c): + await setup_read_speedrange(c) + await c.write_gatt_char(CONTROL_POINT, b"\x00", response=True) + + +SCENARIOS = [ + ("S0_connect_only", setup_connect_only), + ("S1_sub_treadmill", setup_sub_treadmill), + ("S2_sub_fm", setup_sub_fm), + ("S3_sub_training", setup_sub_training), + ("S4_sub_cp_indic", setup_sub_cp), + ("S5_read_feature", setup_read_feature), + ("S6_read_speedrange", setup_read_speedrange), + ("S7_request_control", setup_request_control), +] + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--idle", type=float, default=30.0) + p.add_argument("--reps", type=int, default=2) + p.add_argument("--log", default="WARNING") + args = p.parse_args() + setup_logging(args.log) + + all_results: list[Result] = [] + for rep in range(1, args.reps + 1): + for label, setup_fn in SCENARIOS: + print(f"\n=== rep {rep}: {label} ===") + r = await run_scenario(f"{label}#{rep}", setup_fn, args.idle, args.name) + all_results.append(r) + verdict = ( + f"setup_ok={r.setup_ok} held={r.held_seconds:5.1f}s " + f"disc_during={r.disconnect_during} err={r.setup_error}" + ) + print(f" -> {verdict}") + await asyncio.sleep(6) + + print() + print("=" * 90) + print("SUMMARY") + print("=" * 90) + by_name: dict[str, list[Result]] = {} + for r in all_results: + bare = r.name.split("#")[0] + by_name.setdefault(bare, []).append(r) + + print(f" {'scenario':<22} {'reps':>4} {'min_held':>9} {'avg_held':>9} {'survivors':>10}") + for label, _ in SCENARIOS: + rs = by_name.get(label, []) + if not rs: + continue + held = [r.held_seconds for r in rs] + survivors = sum(1 for r in rs if r.disconnect_during is None) + print(f" {label:<22} {len(rs):>4} {min(held):>9.1f} {sum(held)/len(held):>9.1f} {survivors:>10}/{len(rs)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/probe_setup_step.py b/tests/probe_setup_step.py new file mode 100644 index 0000000..c197749 --- /dev/null +++ b/tests/probe_setup_step.py @@ -0,0 +1,167 @@ +"""Bisect which connection-setup step causes the device to disconnect. + +Each step is performed on a fresh connection. After the step we sit +idle for IDLE_OBSERVATION seconds and record when (if) the device +disconnects. + +Steps, additive: + S0: BleakClient connect only (vanilla) + S1: + service discovery (services already discovered by Bleak on connect) + S2: + read FTMS Feature (0x2acc) + S3: + read Supported Speed Range (0x2ad4) + S4: + subscribe Treadmill Data (0x2acd) + S5: + subscribe Fitness Machine Status (0x2ada) + S6: + subscribe Control Point indications (0x2ad9) + S7: + write REQUEST_CONTROL (0x00 to 0x2ad9) +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import time + +from bleak import BleakClient, BleakScanner + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logging.getLogger("bleak").setLevel(logging.WARNING) +_LOGGER = logging.getLogger("step") + +DEVICE_NAME = "KS-HD-Z1D" +IDLE_OBSERVATION = 20.0 +INTER_STEP_GAP = 8.0 # seconds between scenarios + +FTMS_FEATURE = "00002acc-0000-1000-8000-00805f9b34fb" +SUPPORTED_SPEED_RANGE = "00002ad4-0000-1000-8000-00805f9b34fb" +TREADMILL_DATA = "00002acd-0000-1000-8000-00805f9b34fb" +MACHINE_STATUS = "00002ada-0000-1000-8000-00805f9b34fb" +CONTROL_POINT = "00002ad9-0000-1000-8000-00805f9b34fb" + + +async def find_device(): + d = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=15.0) + if d is None: + _LOGGER.error("device not found") + sys.exit(1) + return d + + +async def probe_step(name: str, do_after_connect): + """Connect, run do_after_connect(client), then idle and time disconnect.""" + last_err = None + for attempt in range(1, 6): + try: + device = await find_device() + disc_event = asyncio.Event() + disc_at = [0.0] + + def on_disc(_): + disc_at[0] = time.time() + disc_event.set() + + client = BleakClient(device, disconnected_callback=on_disc) + await client.connect() + break + except Exception as err: + last_err = err + _LOGGER.warning("[%s] connect attempt %d failed: %s", name, attempt, err) + await asyncio.sleep(4) + else: + _LOGGER.error("[%s] all connect attempts failed: %s", name, last_err) + return f"connect-failed: {last_err}" + try: + await do_after_connect(client) + except Exception as err: + _LOGGER.warning("[%s] setup raised: %s", name, err) + last_action = time.time() + _LOGGER.info("[%s] setup complete, idling…", name) + + deadline = last_action + IDLE_OBSERVATION + while time.time() < deadline and not disc_event.is_set(): + await asyncio.sleep(0.2) + if disc_event.is_set(): + elapsed = disc_at[0] - last_action + _LOGGER.warning("[%s] DISCONNECTED after %.2fs idle", name, elapsed) + verdict = f"disc@{elapsed:.1f}s" + else: + _LOGGER.info("[%s] survived %.0fs", name, IDLE_OBSERVATION) + verdict = f"survived {IDLE_OBSERVATION:.0f}s" + + try: + if client.is_connected: + await client.disconnect() + except Exception: + pass + return verdict + + +async def main(): + cp_response = bytearray() + + async def s0_noop(client): + pass + + async def s1_discover(client): + # Already discovered by .connect(); just walk the tree. + for svc in client.services: + for ch in svc.characteristics: + pass + + async def s2_read_feature(client): + await s1_discover(client) + v = await client.read_gatt_char(FTMS_FEATURE) + _LOGGER.info(" read FTMS_FEATURE = %s", v.hex()) + + async def s3_read_speedrange(client): + await s2_read_feature(client) + v = await client.read_gatt_char(SUPPORTED_SPEED_RANGE) + _LOGGER.info(" read SUPPORTED_SPEED_RANGE = %s", v.hex()) + + async def s4_sub_treadmill(client): + await s3_read_speedrange(client) + await client.start_notify(TREADMILL_DATA, lambda *_: None) + + async def s5_sub_status(client): + await s4_sub_treadmill(client) + await client.start_notify(MACHINE_STATUS, lambda *_: None) + + async def s6_sub_cp(client): + await s5_sub_status(client) + await client.start_notify(CONTROL_POINT, lambda *_: None) + + async def s7_request_control(client): + await s6_sub_cp(client) + await client.write_gatt_char(CONTROL_POINT, b"\x00", response=True) + + steps = [ + ("S0 connect-only", s0_noop), + ("S1 +discover", s1_discover), + ("S2 +read feature", s2_read_feature), + ("S3 +read speedrange", s3_read_speedrange), + ("S4 +sub treadmill", s4_sub_treadmill), + ("S5 +sub status", s5_sub_status), + ("S6 +sub control-pt", s6_sub_cp), + ("S7 +REQUEST_CONTROL", s7_request_control), + ] + + results = [] + for name, fn in steps: + _LOGGER.info("=== %s ===", name) + v = await probe_step(name, fn) + results.append((name, v)) + await asyncio.sleep(INTER_STEP_GAP) + + _LOGGER.info("=" * 60) + _LOGGER.info("RESULTS") + _LOGGER.info("=" * 60) + for name, v in results: + _LOGGER.info(" %-25s %s", name, v) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/stress_dynamic.py b/tests/stress_dynamic.py new file mode 100644 index 0000000..b9e46a7 --- /dev/null +++ b/tests/stress_dynamic.py @@ -0,0 +1,398 @@ +"""Dynamic stress test driving the per-command scripts. + +Imports the command modules in tests/commands/ and runs them in random +or weighted sequences against a single live connection (with reconnect +on disconnect). Each command reports through the shared CommandResult +envelope; the orchestrator aggregates outcomes by command, by sequence +position, and by transition (cmd_a -> cmd_b) so we can spot patterns +like "set_speed after stop nearly always fails." + +Use cases: + - Random walk: pick commands at random for N iterations + - Scenario: predefined sequences (e.g. start->set_speed->stop) + repeated to surface intermittent failures + - Soak: hold a connection for a long time, occasionally + firing low-impact commands + +Output: a human summary on stderr/stdout, plus a per-step JSONL log if +--jsonl is given. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import json +import logging +import random +import sys +import time +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from commands._common import ( # noqa: E402 + DEFAULT_DEVICE_NAME, + CommandResult, + connect, + safe_disconnect, + setup_logging, + snapshot, +) + +from commands import test_connect # noqa: E402,F401 (registers command) +from commands import test_disconnect, test_pause, test_reset # noqa: E402 +from commands import ( # noqa: E402 + test_observe, + test_resume, + test_set_inclination, + test_set_speed, + test_start, + test_stop, + test_switch_mode, + test_update_state, +) + +from walkingpad_controller import BeltState # noqa: E402 + +_LOGGER = logging.getLogger("orchestrator") + + +# Map command name -> (module-level fn, default args namespace builder) +def _ns(**kw) -> argparse.Namespace: + return argparse.Namespace(name=DEFAULT_DEVICE_NAME, log="WARNING", json=False, **kw) + + +COMMANDS: dict[str, tuple[Callable, Callable[[], argparse.Namespace]]] = { + "start": (test_start.fn, lambda: _ns(settle=2.0)), + "resume": (test_resume.fn, lambda: _ns(timeout=10.0)), + "stop": (test_stop.fn, lambda: _ns(start_first=False, run_for=0.0)), + "set_speed": (test_set_speed.fn, lambda: _ns( + speed=random.choice([1.5, 2.0, 2.5, 3.0, 3.5, 4.0]), + sweep=None, start_first=False, settle=0, gap=1.0)), + "switch_mode": (test_switch_mode.fn, lambda: _ns( + mode=random.choice(["manual", "standby", "auto"]))), + "update_state": (test_update_state.fn, lambda: _ns()), + "pause": (test_pause.fn, lambda: _ns( + start_first=False, run_for=0.0, timeout=15.0)), + "reset": (test_reset.fn, lambda: _ns()), + "set_inclination": (test_set_inclination.fn, lambda: _ns(percent=0.0)), + "observe": (test_observe.fn, lambda: _ns(duration=3.0)), +} + + +# Default weights — bias toward common operations; expensive/rejection-prone +# commands (reset, pause, set_inclination) get lower weights. +DEFAULT_WEIGHTS = { + "start": 4, + "stop": 3, + "set_speed": 4, + "switch_mode": 1, + "update_state": 3, + "pause": 1, + "reset": 1, + "set_inclination": 1, +} + + +SCENARIOS: dict[str, list[str]] = { + "happy": ["start", "set_speed", "set_speed", "set_speed", "stop"], + "rapid_speed": ["start", "set_speed", "set_speed", "set_speed", "set_speed", "set_speed", "stop"], + "stop_start": ["start", "set_speed", "stop", "start", "set_speed", "stop"], + "pause_resume": ["start", "set_speed", "observe", "pause", "resume", + "observe", "set_speed", "stop"], + "pause_then_stop": ["start", "set_speed", "pause", "stop"], + "hard_stop_reset": ["start", "set_speed", "observe", "stop", "start", + "observe"], + "spam_state": ["start", "update_state", "update_state", "update_state", + "set_speed", "update_state", "stop"], + "ftms_corner": ["set_inclination", "reset", "switch_mode", "update_state"], +} + + +# Per-scenario semantic invariants — checked after the scenario runs. +# Each invariant takes the list of StepRecord and returns a list of +# violation messages (empty = passed). Run on a clean session against +# the live device. +def _check_pause_resume(records: list) -> list[str]: + """pause_resume: belt_state should reach PAUSED after a successful pause; + counters should monotonically increase across the resume (not regress + to 0). Pauses that failed at the BLE level (`ok = False`) are already + accounted for in PER-COMMAND OUTCOMES — don't double-flag them here. + """ + issues = [] + pause_recs = [r for r in records if r.cmd == "pause" and r.ok] + resume_recs = [r for r in records if r.cmd == "resume" and r.ok] + for r in pause_recs: + bs = r.extra.get("belt_state") + if bs != BeltState.PAUSED: + issues.append( + f"pause didn't land at PAUSED: belt_state={bs} " + f"({r.extra.get('belt_state_name', '?')})" + ) + for r in resume_recs: + pre = r.extra.get("pre_duration", 0) + post = r.extra.get("post_duration", 0) + if post < pre: + issues.append( + f"resume regressed duration counter: pre={pre} post={post} " + "(treated as fresh session, not a resume)" + ) + # Same check on distance + steps (pre-stop counters may be 0 + # because the device only increments on detected weight, but + # they shouldn't go backwards either) + if r.extra.get("post_distance", 0) < r.extra.get("pre_distance", 0): + issues.append("resume regressed distance counter") + if r.extra.get("post_steps", 0) < r.extra.get("pre_steps", 0): + issues.append("resume regressed steps counter") + return issues + + +def _check_hard_stop_reset(records: list) -> list[str]: + """hard_stop_reset: after stop+start, the second observe should show + counters near zero (session reset, not continuing).""" + issues = [] + observes = [r for r in records if r.cmd == "observe"] + if len(observes) < 2: + return issues + pre_stop = observes[0].extra.get("post", {}) + post_restart = observes[1].extra.get("post", {}) + pre_dur = pre_stop.get("duration", 0) + post_dur = post_restart.get("duration", 0) + if post_dur > pre_dur + 5: + # Allow a few seconds of slack — the new session has been + # running by the time the second observe happens + issues.append( + f"hard stop+start didn't reset duration: pre_stop={pre_dur}s, " + f"post_restart={post_dur}s — session continued instead of reset" + ) + return issues + + +SCENARIO_CHECKS: dict[str, Callable[[list], list[str]]] = { + "pause_resume": _check_pause_resume, + "pause_then_stop": _check_pause_resume, + "hard_stop_reset": _check_hard_stop_reset, +} + + +@dataclass +class StepRecord: + iter: int + cmd: str + ok: bool + latency_ms: float + error: str | None + before_connected: bool | None + after_connected: bool | None + before_speed: float | None + after_speed: float | None + extra: dict + + +async def _run_step(controller, cmd: str, args, iteration: int) -> tuple[StepRecord, bool]: + """Run one command on an existing controller. Returns (record, still_connected).""" + fn, _ = COMMANDS[cmd] + before = snapshot(controller) + t0 = time.time() + error: str | None = None + extra: dict = {} + try: + extra = await fn(controller, args) + ok = bool(extra.get("ok", True)) + except Exception as err: # noqa: BLE001 + ok = False + error = f"{type(err).__name__}: {err}" + latency = (time.time() - t0) * 1000.0 + after = snapshot(controller) + + rec = StepRecord( + iter=iteration, + cmd=cmd, + ok=ok, + latency_ms=latency, + error=error, + before_connected=before["connected"], + after_connected=after["connected"], + before_speed=before["speed"], + after_speed=after["speed"], + extra={k: v for k, v in extra.items() if k != "ok"}, + ) + return rec, after["connected"] + + +async def main(): + p = argparse.ArgumentParser(description="Dynamic stress orchestrator") + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--mode", choices=["random", "scenario", "soak"], default="random") + p.add_argument("--scenario", choices=list(SCENARIOS), default="happy") + p.add_argument("--iterations", type=int, default=20, + help="random mode: number of commands; scenario mode: scenario repetitions") + p.add_argument("--inter-cmd-gap", type=float, default=2.0, + help="seconds between commands") + p.add_argument("--soak-duration", type=float, default=120.0) + p.add_argument("--jsonl", type=Path, default=None, + help="write per-step JSONL log to this path") + p.add_argument("--seed", type=int, default=None) + p.add_argument("--log", default="INFO") + args = p.parse_args() + setup_logging(args.log) + if args.seed is not None: + random.seed(args.seed) + + jsonl = open(args.jsonl, "w") if args.jsonl else None + records: list[StepRecord] = [] + reconnect_count = 0 + + def disc_cb(): + _LOGGER.warning("DISCONNECT callback fired during run") + + controller = await connect(name=args.name, on_disconnect=disc_cb) + if controller is None: + _LOGGER.error("Initial connect failed; aborting") + return 2 + + # Build sequence + if args.mode == "random": + weights = DEFAULT_WEIGHTS + cmd_pool = list(weights.keys()) + cmd_w = list(weights.values()) + sequence = random.choices(cmd_pool, weights=cmd_w, k=args.iterations) + elif args.mode == "scenario": + scenario = SCENARIOS[args.scenario] + sequence = scenario * args.iterations + elif args.mode == "soak": + # Mostly update_state and short bursts + sequence = [] + sequence.append("start") + elapsed = 0.0 + while elapsed < args.soak_duration: + sequence.append("update_state") + elapsed += args.inter_cmd_gap + sequence.append("stop") + else: + return 1 + + _LOGGER.info("Running %d commands in mode=%s", len(sequence), args.mode) + + for i, cmd in enumerate(sequence): + if not controller.connected: + _LOGGER.warning("Not connected before step %d; reconnecting", i) + with contextlib.suppress(Exception): + await safe_disconnect(controller) + controller = await connect(name=args.name, on_disconnect=disc_cb) + reconnect_count += 1 + if controller is None: + _LOGGER.error("Reconnect failed; aborting at step %d", i) + break + + cmd_args_factory = COMMANDS[cmd][1] + cmd_args = cmd_args_factory() + rec, _ = await _run_step(controller, cmd, cmd_args, i) + records.append(rec) + _LOGGER.info( + "%3d/%3d %s ok=%-5s lat=%5.0fms speed %s->%s err=%s", + i + 1, len(sequence), cmd, rec.ok, rec.latency_ms, + rec.before_speed, rec.after_speed, rec.error, + ) + if jsonl: + jsonl.write(json.dumps(rec.__dict__, default=str) + "\n") + jsonl.flush() + + await asyncio.sleep(args.inter_cmd_gap) + + # Cleanup + if controller and controller.connected: + with contextlib.suppress(Exception): + await controller.stop() + await safe_disconnect(controller) + + if jsonl: + jsonl.close() + + invariant_issues: list[str] = [] + if args.mode == "scenario" and args.scenario in SCENARIO_CHECKS: + invariant_issues = SCENARIO_CHECKS[args.scenario](records) + + _summarise(records, reconnect_count, invariant_issues) + return 0 if not invariant_issues else 1 + + +def _summarise( + records: list[StepRecord], + reconnect_count: int, + invariant_issues: list[str] | None = None, +) -> None: + if not records: + print("no records") + return + + print() + print("=" * 70) + print("PER-COMMAND OUTCOMES") + print("=" * 70) + by_cmd: dict[str, list[StepRecord]] = defaultdict(list) + for r in records: + by_cmd[r.cmd].append(r) + for cmd in sorted(by_cmd): + rs = by_cmd[cmd] + ok = sum(1 for r in rs if r.ok) + lat = sum(r.latency_ms for r in rs) / len(rs) + print(f" {cmd:18s} {ok:3d}/{len(rs):3d} ok avg lat {lat:6.0f}ms") + + print() + print("=" * 70) + print("ERRORS") + print("=" * 70) + errs = Counter() + for r in records: + if r.error: + errs[(r.cmd, r.error[:80])] += 1 + if not errs: + print(" (none)") + else: + for (cmd, err), n in errs.most_common(): + print(f" {n:3d}× {cmd:18s} {err}") + + print() + print("=" * 70) + print("TRANSITION FAILURES (cmd_prev -> cmd_curr where curr failed)") + print("=" * 70) + transitions = Counter() + for prev, curr in zip(records, records[1:]): + if not curr.ok: + transitions[(prev.cmd, curr.cmd)] += 1 + for (a, b), n in transitions.most_common(15): + print(f" {n:3d}× {a:18s} -> {b}") + if not transitions: + print(" (no failures)") + + print() + print("=" * 70) + print("SCENARIO INVARIANTS") + print("=" * 70) + if invariant_issues is None: + print(" (no scenario-level checks for this run)") + elif not invariant_issues: + print(" (all invariants held)") + else: + for issue in invariant_issues: + print(f" - {issue}") + + print() + print("=" * 70) + print("RUN STATS") + print("=" * 70) + total = len(records) + total_ok = sum(1 for r in records if r.ok) + print(f" total commands : {total}") + print(f" total ok : {total_ok} ({total_ok / total * 100:.1f}%)") + print(f" reconnects : {reconnect_count}") + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/stress_real_device.py b/tests/stress_real_device.py new file mode 100644 index 0000000..f607ba7 --- /dev/null +++ b/tests/stress_real_device.py @@ -0,0 +1,207 @@ +"""Stress test — exercises the library against a real KS-HD-Z1D. + +Distinct from test_real_device.py (which is a single-path smoke test). +This script hammers start/stop cycles, rapid speed changes, and idle +windows to surface BLE flakiness or regressions in the new vendor +pre-amble + REQUEST_CONTROL-tolerance code paths. + +Verifies: + - Capability detection (KS-HD-Z1D has no vendor pre-amble char) + - Cold-start invariant (no SET_TARGET_SPEED before belt is moving) + - Speed sweep across the device's range + - Repeated start/stop/start cycles + - Idle persistence (no spurious disconnect over a 60s wait) + - No BLE disconnect during any of the above +""" + +import asyncio +import logging +import sys +import time + +from bleak import BleakScanner + +from walkingpad_controller import ProtocolType, TreadmillStatus, WalkingPadController + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +_LOGGER = logging.getLogger("stress") + +DEVICE_NAME = "KS-HD-Z1D" + +# Speed sequence: cold-start at min, then rapid changes. +SPEED_SWEEP = [1.5, 3.0, 2.0, 4.0, 1.0, 3.5, 2.5] +INTER_SPEED_DELAY = 4.0 # seconds between speed changes +START_STOP_CYCLES = 2 # how many full start/stop cycles to run +IDLE_OBSERVATION = 30.0 # seconds to sit idle and watch for spurious disconnect + + +async def find_device(): + _LOGGER.info("Scanning for %s...", DEVICE_NAME) + device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=15.0) + if device is None: + _LOGGER.error("Device %s not found.", DEVICE_NAME) + sys.exit(1) + _LOGGER.info("Found: %s (%s)", device.name, device.address) + return device + + +async def main() -> None: + device = await find_device() + + controller = WalkingPadController(ble_device=device) + assert controller.protocol == ProtocolType.FTMS, ( + f"Expected FTMS, got {controller.protocol}" + ) + + status_count = 0 + last_speed = 0.0 + + def on_status(status: TreadmillStatus) -> None: + nonlocal status_count, last_speed + status_count += 1 + last_speed = status.speed + if status_count % 10 == 1: + _LOGGER.info( + " status #%d: speed=%.2f km/h dist=%dm time=%ds steps=%d", + status_count, + status.speed, + status.distance, + status.duration, + status.steps, + ) + + disconnected = asyncio.Event() + + def on_disconnect() -> None: + _LOGGER.error("DISCONNECT callback fired!") + disconnected.set() + + controller.register_status_callback(on_status) + controller.register_disconnect_callback(on_disconnect) + + failures: list[str] = [] + + # --- Phase 1: connect & capability detection --- + _LOGGER.info("=== Phase 1: connect ===") + t0 = time.time() + await controller.connect() + _LOGGER.info("Connected in %.2fs (protocol=%s)", time.time() - t0, controller.protocol.value) + _LOGGER.info( + "Speed range %.1f–%.1f km/h step %.2f", + controller.min_speed, + controller.max_speed, + controller.speed_increment, + ) + + # KS-HD-Z1D should NOT have the MC-21 vendor pre-amble characteristic. + has_vendor = controller._ftms._capabilities.has_vendor_preamble + _LOGGER.info("has_vendor_preamble = %s (expected False on KS-HD-Z1D)", has_vendor) + if has_vendor: + failures.append("Unexpected: vendor pre-amble characteristic detected") + + await asyncio.sleep(2.0) + + # --- Phase 2: cold-start sequence --- + _LOGGER.info("=== Phase 2: cold-start ===") + t0 = time.time() + started = await controller.start() + _LOGGER.info("start() -> %s in %.2fs", started, time.time() - t0) + if not started or not controller.connected: + failures.append("start() failed or connection lost") + return await teardown(controller, failures, status_count) + _LOGGER.info("Belt now at %.2f km/h", last_speed) + + # --- Phase 3: speed sweep --- + _LOGGER.info("=== Phase 3: speed sweep ===") + for target in SPEED_SWEEP: + if disconnected.is_set(): + failures.append(f"Disconnected during speed sweep at target={target}") + break + t0 = time.time() + ok = await controller.set_speed(target) + _LOGGER.info(" set_speed(%.1f) -> %s in %.2fs", target, ok, time.time() - t0) + if not ok: + failures.append(f"set_speed({target}) returned False") + await asyncio.sleep(INTER_SPEED_DELAY) + if abs(last_speed - target) > 0.5: + _LOGGER.warning( + " observed speed %.2f differs from target %.2f (>0.5 km/h)", + last_speed, + target, + ) + + # --- Phase 4: stop / start cycles --- + _LOGGER.info("=== Phase 4: stop/start cycles (%d) ===", START_STOP_CYCLES) + for cycle in range(1, START_STOP_CYCLES + 1): + if disconnected.is_set(): + failures.append(f"Disconnected before cycle {cycle}") + break + _LOGGER.info(" cycle %d: stop", cycle) + await controller.stop() + await asyncio.sleep(5.0) + _LOGGER.info(" cycle %d: post-stop speed=%.2f", cycle, last_speed) + if disconnected.is_set(): + failures.append(f"Disconnected during cycle {cycle} stop") + break + _LOGGER.info(" cycle %d: start", cycle) + ok = await controller.start() + if not ok: + failures.append(f"cycle {cycle} restart failed") + break + await asyncio.sleep(2.0) + ok = await controller.set_speed(2.0) + if not ok: + failures.append(f"cycle {cycle} set_speed(2.0) failed") + await asyncio.sleep(3.0) + + # --- Phase 5: idle persistence --- + _LOGGER.info( + "=== Phase 5: idle %.0fs to check for spurious disconnect ===", + IDLE_OBSERVATION, + ) + deadline = time.time() + IDLE_OBSERVATION + pre_idle_count = status_count + while time.time() < deadline: + if disconnected.is_set(): + failures.append("Disconnected during idle observation") + break + await asyncio.sleep(1.0) + _LOGGER.info( + "Status updates during idle: %d (expect ~%.0f at 1Hz)", + status_count - pre_idle_count, + IDLE_OBSERVATION, + ) + + # --- Phase 6: final stop & disconnect --- + _LOGGER.info("=== Phase 6: final stop ===") + if controller.connected: + await controller.stop() + await asyncio.sleep(3.0) + + await teardown(controller, failures, status_count) + + +async def teardown(controller, failures: list[str], status_count: int) -> None: + _LOGGER.info("=== Teardown ===") + try: + if controller.connected: + await controller.disconnect() + except Exception: # noqa: BLE001 + _LOGGER.exception("Error during disconnect") + + _LOGGER.info( + "Total status updates: %d | failures: %d", status_count, len(failures) + ) + if failures: + _LOGGER.error("FAILED:") + for f in failures: + _LOGGER.error(" - %s", f) + sys.exit(1) + _LOGGER.info("STRESS TEST PASSED") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/stress_start_stop.py b/tests/stress_start_stop.py new file mode 100644 index 0000000..5022470 --- /dev/null +++ b/tests/stress_start_stop.py @@ -0,0 +1,269 @@ +"""Focused stress test for the 'can't restart after stop' failure mode. + +Run patterns: many start/stop cycles with varying gap lengths between +the stop and the next start, plus a "rapid burst" pattern. Logs every +control-point opcode, every result, every disconnect, every status +update — so when something fails we can correlate. + +If a start fails after a stop, the test attempts diagnostic recovery +to surface root cause: re-request control, reconnect, retry. +""" + +import asyncio +import logging +import sys +import time +from dataclasses import dataclass, field +from typing import Any + +from bleak import BleakScanner + +from walkingpad_controller import ProtocolType, TreadmillStatus, WalkingPadController + +# Verbose logging on the library so we see every CP write + result. +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +# Quiet bleak's chatter +logging.getLogger("bleak").setLevel(logging.INFO) +logging.getLogger("bleak.backends").setLevel(logging.INFO) +_LOGGER = logging.getLogger("stress") + +DEVICE_NAME = "KS-HD-Z1D" + +GAP_PATTERNS = [ + ("rapid", 0.5), + ("short", 2.0), + ("medium", 5.0), + ("long", 10.0), +] +CYCLES_PER_PATTERN = 3 +RUN_DURATION = 4.0 # seconds belt runs at speed before stop + + +@dataclass +class CycleResult: + cycle: int + pattern: str + gap_before_start: float + start_ok: bool = False + set_speed_ok: bool = False + stop_ok: bool = False + disconnected: bool = False + notes: list[str] = field(default_factory=list) + + +async def find_device(): + _LOGGER.info("Scanning for %s...", DEVICE_NAME) + device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=15.0) + if device is None: + _LOGGER.error("Device %s not found.", DEVICE_NAME) + sys.exit(1) + _LOGGER.info("Found: %s (%s)", device.name, device.address) + return device + + +class TestRig: + def __init__(self, device): + self.device = device + self.controller: WalkingPadController | None = None + self.disconnected = asyncio.Event() + self.status_count = 0 + self.last_status: TreadmillStatus | None = None + self.results: list[CycleResult] = [] + + def on_status(self, status: TreadmillStatus) -> None: + self.status_count += 1 + self.last_status = status + + def on_disconnect(self) -> None: + _LOGGER.warning("DISCONNECT callback fired") + self.disconnected.set() + + async def connect(self) -> bool: + self.disconnected.clear() + self.controller = WalkingPadController(ble_device=self.device) + self.controller.register_status_callback(self.on_status) + self.controller.register_disconnect_callback(self.on_disconnect) + try: + await self.controller.connect() + except Exception as err: # noqa: BLE001 + _LOGGER.exception("connect raised: %s", err) + return False + return self.controller.connected + + async def reconnect(self) -> bool: + _LOGGER.info("Attempting reconnect…") + try: + if self.controller and self.controller.connected: + await self.controller.disconnect() + except Exception: # noqa: BLE001 + pass + await asyncio.sleep(2.0) + # Re-discover (the BLEDevice address may still be valid but a fresh + # scan tends to be more reliable on flaky firmware) + device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=10.0) + if device is None: + _LOGGER.error("Reconnect: device not found in scan") + return False + self.device = device + return await self.connect() + + async def run_cycle(self, cycle_num: int, pattern: str, gap: float) -> CycleResult: + result = CycleResult(cycle=cycle_num, pattern=pattern, gap_before_start=gap) + + c = self.controller + if c is None or not c.connected: + result.notes.append("not-connected-before-start") + ok = await self.reconnect() + result.notes.append(f"reconnect-{'ok' if ok else 'fail'}") + if not ok: + return result + c = self.controller + assert c is not None + + _LOGGER.info( + "[cycle %d/%s gap=%.1fs] starting…", cycle_num, pattern, gap + ) + + # Start + t = time.time() + started = await c.start() + result.start_ok = started and c.connected + _LOGGER.info( + "[cycle %d] start() = %s in %.2fs (speed=%.2f)", + cycle_num, + started, + time.time() - t, + self.last_status.speed if self.last_status else -1, + ) + if not result.start_ok: + result.notes.append( + f"start-failed (returned={started}, connected={c.connected})" + ) + return result + + # Set a speed (simulates user slider) + t = time.time() + speed_ok = await c.set_speed(2.5) + result.set_speed_ok = speed_ok and c.connected + _LOGGER.info( + "[cycle %d] set_speed(2.5) = %s in %.2fs", + cycle_num, + speed_ok, + time.time() - t, + ) + if not result.set_speed_ok: + result.notes.append("set-speed-failed") + + # Run for a bit + await asyncio.sleep(RUN_DURATION) + + if self.disconnected.is_set(): + result.disconnected = True + result.notes.append("disconnect-during-run") + return result + + # Stop + t = time.time() + stopped = await c.stop() + result.stop_ok = stopped and c.connected + _LOGGER.info( + "[cycle %d] stop() = %s in %.2fs", + cycle_num, + stopped, + time.time() - t, + ) + if not result.stop_ok: + result.notes.append( + f"stop-failed (returned={stopped}, connected={c.connected})" + ) + + if self.disconnected.is_set(): + result.disconnected = True + result.notes.append("disconnect-after-stop") + + return result + + def report(self) -> int: + _LOGGER.info("=" * 60) + _LOGGER.info("RESULTS") + _LOGGER.info("=" * 60) + failed = 0 + for r in self.results: + ok = ( + r.start_ok + and r.set_speed_ok + and r.stop_ok + and not r.disconnected + and not r.notes + ) + tag = "PASS" if ok else "FAIL" + if not ok: + failed += 1 + _LOGGER.info( + " [%s] cycle %2d %-7s gap=%4.1fs start=%-5s speed=%-5s stop=%-5s disc=%-5s %s", + tag, + r.cycle, + r.pattern, + r.gap_before_start, + r.start_ok, + r.set_speed_ok, + r.stop_ok, + r.disconnected, + "; ".join(r.notes) if r.notes else "", + ) + _LOGGER.info("=" * 60) + _LOGGER.info("Total: %d cycles, %d failed", len(self.results), failed) + return failed + + +async def main() -> None: + device = await find_device() + rig = TestRig(device) + + if not await rig.connect(): + _LOGGER.error("Initial connect failed") + sys.exit(1) + + assert rig.controller is not None + assert rig.controller.protocol == ProtocolType.FTMS + _LOGGER.info( + "Connected. has_vendor_preamble=%s", + rig.controller._ftms._capabilities.has_vendor_preamble, + ) + await asyncio.sleep(2.0) + + cycle_n = 0 + for pattern, gap in GAP_PATTERNS: + for _ in range(CYCLES_PER_PATTERN): + cycle_n += 1 + r = await rig.run_cycle(cycle_n, pattern, gap) + rig.results.append(r) + # Apply gap before the NEXT cycle (i.e., gap between this stop + # and next start) + if r.disconnected or not rig.controller.connected: + _LOGGER.warning("Disconnected; reconnecting before next cycle") + ok = await rig.reconnect() + if not ok: + _LOGGER.error("Could not reconnect, aborting") + rig.report() + sys.exit(1) + await asyncio.sleep(gap) + + if rig.controller and rig.controller.connected: + try: + await rig.controller.stop() + await asyncio.sleep(2.0) + await rig.controller.disconnect() + except Exception: # noqa: BLE001 + pass + + failed = rig.report() + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/stress_traced.py b/tests/stress_traced.py new file mode 100644 index 0000000..7b386f3 --- /dev/null +++ b/tests/stress_traced.py @@ -0,0 +1,260 @@ +"""Stress test + integrated BLE trace, in one process. + +Single connection (the device only allows one client). The orchestrator +runs commands as before; in parallel we hook every notifiable char on the +same client and log every received frame with a timestamp aligned to the +command timeline. Output is interleaved chronologically so a failed +set_speed shows whether the device fired TARGET_SPEED_CHANGED behind +the scenes (indication channel issue) or fired nothing (command really +not accepted). +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import json +import logging +import struct +import sys +import time +from collections import deque +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from commands._common import ( # noqa: E402 + DEFAULT_DEVICE_NAME, + connect, + safe_disconnect, + setup_logging, + snapshot, +) +from commands import ( # noqa: E402 + test_set_speed, test_start, test_stop, test_switch_mode, test_update_state, + test_pause, test_reset, test_set_inclination, +) + + +_LOGGER = logging.getLogger("traced") + +CHAR_NAME = { + "00002acd-0000-1000-8000-00805f9b34fb": "Treadmill Data", + "00002ada-0000-1000-8000-00805f9b34fb": "FM Status", + "00002ad9-0000-1000-8000-00805f9b34fb": "CP Indication", + "00002ad3-0000-1000-8000-00805f9b34fb": "Training Status", +} + + +def decode_treadmill(d: bytes) -> str: + if len(d) < 4: + return "(short)" + flags = struct.unpack_from(" str: + if not d: + return "(empty)" + op = d[0] + NAMES = {1: "RESET", 2: "STOPPED", 3: "SAFETY_STOP", 4: "STARTED", + 5: "TARGET_SPEED", 6: "TARGET_INCLINE", 0x14: "SPIN_DOWN", + 0xFF: "CONTROL_LOST"} + name = NAMES.get(op, f"0x{op:02x}") + if op == 0x05 and len(d) >= 3: + v = struct.unpack_from("= 2: + return f"{name} param=0x{d[1]:02x}" + return name + + +def decode_cp(d: bytes) -> str: + if len(d) < 3 or d[0] != 0x80: + return d.hex() + return f"resp opc=0x{d[1]:02x} result=0x{d[2]:02x}" + + +def decode_training(d: bytes) -> str: + if len(d) < 2: + return d.hex() + NAMES = {0: "OTHER", 1: "IDLE", 2: "WARMING", 12: "MANUAL", + 13: "PRE_WORKOUT", 14: "POST_WORKOUT"} + return f"flags=0x{d[0]:02x} status={NAMES.get(d[1], hex(d[1]))}" + + +DECODERS = { + "00002acd-0000-1000-8000-00805f9b34fb": decode_treadmill, + "00002ada-0000-1000-8000-00805f9b34fb": decode_fm_status, + "00002ad9-0000-1000-8000-00805f9b34fb": decode_cp, + "00002ad3-0000-1000-8000-00805f9b34fb": decode_training, +} + + +COMMANDS = { + "start": (test_start.fn, lambda: argparse.Namespace(name="", log="W", json=False, settle=2.0)), + "stop": (test_stop.fn, lambda: argparse.Namespace(name="", log="W", json=False, start_first=False, run_for=0.0)), + "set_speed": (test_set_speed.fn, lambda v: argparse.Namespace(name="", log="W", json=False, speed=v, sweep=None, start_first=False, settle=0, gap=1.0)), + "switch_mode": (test_switch_mode.fn, lambda m: argparse.Namespace(name="", log="W", json=False, mode=m)), + "update_state": (test_update_state.fn, lambda: argparse.Namespace(name="", log="W", json=False)), + "pause": (test_pause.fn, lambda: argparse.Namespace(name="", log="W", json=False, start_first=False, run_for=0.0)), + "reset": (test_reset.fn, lambda: argparse.Namespace(name="", log="W", json=False)), + "set_inclination": (test_set_inclination.fn, lambda p: argparse.Namespace(name="", log="W", json=False, percent=p)), +} + + +# Predefined sequences focused on reproducing the set_speed-timeout pattern. +SEQUENCES: dict[str, list[tuple]] = { + "happy": [ + ("start", ()), ("set_speed", (2.5,)), ("set_speed", (3.0,)), ("stop", ()), + ], + "minspeed_setspeed": [ + # Repro target: cold-start leaves belt at 1.0 (min), then set_speed at min + ("start", ()), ("set_speed", (1.5,)), ("set_speed", (2.0,)), + ("set_speed", (2.5,)), ("stop", ()), + ], + "after_reset": [ + ("reset", ()), ("start", ()), ("set_speed", (2.0,)), ("stop", ()), + ], + "after_standby": [ + ("switch_mode", ("standby",)), ("start", ()), ("set_speed", (2.0,)), ("stop", ()), + ], + "stop_start_cycles": [ + ("start", ()), ("set_speed", (2.5,)), ("stop", ()), + ("start", ()), ("set_speed", (2.5,)), ("stop", ()), + ], +} + + +class TraceLog: + def __init__(self): + self.events: deque = deque() + self.t0 = time.time() + + def cmd(self, label: str): + self.events.append((time.time() - self.t0, "CMD", label)) + return time.time() - self.t0 + + def cmd_done(self, label: str, ok: bool, lat: float, extra: str = ""): + self.events.append((time.time() - self.t0, "DONE", f"{label} ok={ok} lat={lat:.0f}ms {extra}")) + + def notif(self, char_uuid: str, data: bytes): + decoder = DECODERS.get(char_uuid) + decoded = decoder(data) if decoder else data.hex() + name = CHAR_NAME.get(char_uuid, char_uuid[:8]) + self.events.append((time.time() - self.t0, "RX", f"{name}: {decoded}")) + + def dump(self, fp): + for ts, kind, msg in self.events: + fp.write(f" [{ts:8.3f}] {kind:4s} {msg}\n") + + +async def run_sequence(name: str, seq: list[tuple], inter_cmd_gap: float, trace: TraceLog): + controller = await connect(name=name) + if controller is None: + _LOGGER.error("connect failed") + return None + + # Hook training-status notifications too (lib doesn't subscribe to it by + # default but it's the smoking gun for the PRE_WORKOUT theory). + extras_subscribed = [] + client = controller._ftms._client # type: ignore[union-attr] + for uuid in ("00002ad3-0000-1000-8000-00805f9b34fb",): + try: + await client.start_notify( + uuid, lambda _h, d, u=uuid: trace.notif(u, bytes(d)) + ) + extras_subscribed.append(uuid) + except Exception as err: + _LOGGER.warning("subscribe %s failed: %s", uuid, err) + + # Re-route the lib's existing notification handlers so they also feed + # the trace. + orig_treadmill_handler = controller._ftms._on_treadmill_data # type: ignore[union-attr] + orig_status_handler = controller._ftms._on_machine_status # type: ignore[union-attr] + orig_cp_handler = controller._ftms._on_control_point_response # type: ignore[union-attr] + + TM_UUID = "00002acd-0000-1000-8000-00805f9b34fb" + FS_UUID = "00002ada-0000-1000-8000-00805f9b34fb" + CP_UUID = "00002ad9-0000-1000-8000-00805f9b34fb" + + def tap(uuid, orig): + def inner(sender, data): + trace.notif(uuid, bytes(data)) + return orig(sender, data) + return inner + + # Replace on the underlying client + with contextlib.suppress(Exception): + await client.stop_notify(TM_UUID) + await client.start_notify(TM_UUID, tap(TM_UUID, orig_treadmill_handler)) + with contextlib.suppress(Exception): + await client.stop_notify(FS_UUID) + await client.start_notify(FS_UUID, tap(FS_UUID, orig_status_handler)) + with contextlib.suppress(Exception): + await client.stop_notify(CP_UUID) + await client.start_notify(CP_UUID, tap(CP_UUID, orig_cp_handler)) + + try: + for cmd_name, args_tuple in seq: + fn, factory = COMMANDS[cmd_name] + label = f"{cmd_name}{args_tuple if args_tuple else ''}" + args = factory(*args_tuple) if args_tuple else factory() + t0 = trace.cmd(label) + t_real = time.time() + try: + extra = await fn(controller, args) + ok = bool(extra.get("ok", True)) + trace.cmd_done(label, ok, (time.time() - t_real) * 1000.0, str(extra)) + except Exception as err: # noqa: BLE001 + trace.cmd_done(label, False, (time.time() - t_real) * 1000.0, f"raised: {err}") + await asyncio.sleep(inter_cmd_gap) + finally: + with contextlib.suppress(Exception): + if controller.connected and controller.status.speed > 0: + await controller.stop() + await safe_disconnect(controller) + return trace + + +async def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--sequence", choices=list(SEQUENCES), default="minspeed_setspeed") + p.add_argument("--inter-cmd-gap", type=float, default=2.0) + p.add_argument("--out", type=Path, default=None, + help="write trace here (default: stdout)") + p.add_argument("--log", default="WARNING") + args = p.parse_args() + setup_logging(args.log) + + trace = TraceLog() + print(f"Running sequence {args.sequence!r} with notification trace…") + print(f" inter-cmd gap: {args.inter_cmd_gap}s") + print(f" steps: {' -> '.join(c for c, _ in SEQUENCES[args.sequence])}") + print() + + await run_sequence(args.name, SEQUENCES[args.sequence], args.inter_cmd_gap, trace) + + fp = open(args.out, "w") if args.out else sys.stdout + fp.write("\nTRACE (chronological):\n\n") + trace.dump(fp) + if args.out: + fp.close() + print(f"\ntrace written to {args.out}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_sperax_codec.py b/tests/test_sperax_codec.py index 53f5127..48f94be 100644 --- a/tests/test_sperax_codec.py +++ b/tests/test_sperax_codec.py @@ -86,3 +86,26 @@ def test_status_parse_running(): assert ctrl.status.speed == 3.0 assert ctrl.status.belt_state == BeltState.ACTIVE assert ctrl.vibration_level == 0 + + +def test_stop_and_pause_frames(): + # stop resets counters (state 0x00); pause keeps them (state 0x02). + assert encode(bytes([0x00, 0x15, 0x00, 0x00, 0x00])).hex() == "f50a0015000000d301fa" + # pause frame matches the real capture byte-for-byte. + assert encode(bytes([0x00, 0x15, 0x02, 0x00, 0x00])).hex() == "f50a0015020000c319fa" + + +def test_status_vibration_only_while_vibrating(): + from walkingpad_controller.sperax import SperaxController + + ctrl = SperaxController() + # Vibration mode (state byte at offset 4 = 0x50), level at offset 18 = 1. + vibrating = bytes([0x00, 0x19, 0, 0, 0x50, 0, 0, 0, 0x2f, 0, 3, 0, 2, 0, 0x44, 0, 0, 0, 1]) + ctrl._parse_status(vibrating) + assert ctrl.status.vibration_level == 1 + assert ctrl.status.speed == 0.0 + # Vibration off: device returns to idle (state 0x00) but offset 18 still + # holds the stale last level (4) — must be reported as 0. + idle_stale = bytes([0x00, 0x19, 0, 0, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4]) + ctrl._parse_status(idle_stale) + assert ctrl.status.vibration_level == 0 diff --git a/tests/watch_ble.py b/tests/watch_ble.py new file mode 100644 index 0000000..b82b6cf --- /dev/null +++ b/tests/watch_ble.py @@ -0,0 +1,221 @@ +"""Passive BLE watcher. + +Connects to the device, walks every characteristic in the GATT tree, and +subscribes to anything that supports notify or indicate. Every received +frame is timestamped, decoded if possible, and printed. + +Run alongside the per-command scripts (in another terminal) to correlate +which BLE traffic each command produces. + +Outputs to stdout. --csv writes a tab-separated event log instead. + +Note: this is *passive* on the GATT layer — we can't see what the central +*sends* without root-level btmon. It captures what the device emits. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import struct +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) + +from bleak import BleakClient, BleakScanner + +from commands._common import DEFAULT_DEVICE_NAME, setup_logging # noqa: E402 + +# Friendly names for chars we know. +KNOWN: dict[str, str] = { + "00002a05-0000-1000-8000-00805f9b34fb": "Service Changed", + "00002a19-0000-1000-8000-00805f9b34fb": "Battery Level", + "00002acc-0000-1000-8000-00805f9b34fb": "FTMS Feature", + "00002acd-0000-1000-8000-00805f9b34fb": "Treadmill Data", + "00002ad3-0000-1000-8000-00805f9b34fb": "Training Status", + "00002ad4-0000-1000-8000-00805f9b34fb": "Supported Speed Range", + "00002ad5-0000-1000-8000-00805f9b34fb": "Supported Inclination Range", + "00002ad9-0000-1000-8000-00805f9b34fb": "FTMS Control Point", + "00002ada-0000-1000-8000-00805f9b34fb": "Fitness Machine Status", + "24e2521c-f63b-48ed-85be-c5330b00fdf7": "Supplement Notify", + "0000fff1-0000-1000-8000-00805f9b34fb": "Vendor 0xFFF1 Notify", + "0000ffc1-0000-1000-8000-00805f9b34fb": "Vendor 0xFFC1 Notify", +} + + +def short(uuid: str) -> str: + """Render a 128-bit UUID compactly when it's a 16-bit alias.""" + if uuid.startswith("0000") and uuid.endswith("-0000-1000-8000-00805f9b34fb"): + return f"0x{uuid[4:8].upper()}" + return uuid[:8] + "…" + + +def decode_treadmill_data(data: bytes) -> str: + if len(data) < 4: + return "(short)" + flags = struct.unpack_from(" str: + if not data: + return "(empty)" + op = data[0] + NAME = { + 0x01: "RESET", 0x02: "STOPPED_OR_PAUSED", 0x03: "STOPPED_BY_SAFETY", + 0x04: "STARTED", 0x05: "TARGET_SPEED", 0x06: "TARGET_INCLINE", + 0x14: "SPIN_DOWN", 0xFF: "CONTROL_LOST", + } + name = NAME.get(op, f"0x{op:02x}") + if op == 0x05 and len(data) >= 3: + v = struct.unpack_from("= 2: + return f"{name} param={data[1]:#x}" + return name + + +def decode_cp_response(data: bytes) -> str: + if len(data) < 3 or data[0] != 0x80: + return data.hex() + return f"resp opcode=0x{data[1]:02x} result=0x{data[2]:02x}" + + +def decode_training_status(data: bytes) -> str: + if len(data) < 2: + return data.hex() + NAMES = { + 0: "OTHER", 1: "IDLE", 2: "WARMING_UP", + 3: "LOW_INT", 4: "HIGH_INT", 5: "RECOVERY", + 12: "MANUAL", 13: "PRE_WORKOUT", 14: "POST_WORKOUT", + } + flags, status = data[0], data[1] + return f"flags=0x{flags:02x} status={NAMES.get(status, hex(status))}" + + +_DECODERS = { + "00002acd-0000-1000-8000-00805f9b34fb": decode_treadmill_data, + "00002ada-0000-1000-8000-00805f9b34fb": decode_fm_status, + "00002ad9-0000-1000-8000-00805f9b34fb": decode_cp_response, + "00002ad3-0000-1000-8000-00805f9b34fb": decode_training_status, +} + + +async def main(): + p = argparse.ArgumentParser(description="Passive BLE watcher for KingSmith FTMS") + p.add_argument("--name", default=DEFAULT_DEVICE_NAME) + p.add_argument("--csv", type=Path, default=None, + help="if set, write tab-separated log here instead of stdout") + p.add_argument("--duration", type=float, default=300.0, + help="seconds to watch before exiting") + p.add_argument("--log", default="WARNING") + p.add_argument("--include-non-notifiable", action="store_true", + help="also log readable characteristics by polling once at start") + args = p.parse_args() + setup_logging(args.log) + + device = await BleakScanner.find_device_by_name(args.name, timeout=15) + if device is None: + print(f"device {args.name!r} not found", file=sys.stderr) + return 1 + + csv_handle = open(args.csv, "w") if args.csv else None + if csv_handle: + csv_handle.write("ts\thandle\tuuid\tname\tdir\tlen\thex\tdecoded\n") + + t0 = time.time() + + def emit(direction: str, char_uuid: str, data: bytes): + ts = time.time() - t0 + name = KNOWN.get(char_uuid, "?") + decoded = "" + if char_uuid in _DECODERS: + with contextlib.suppress(Exception): + decoded = _DECODERS[char_uuid](bytes(data)) + if csv_handle: + csv_handle.write( + f"{ts:.3f}\t-\t{char_uuid}\t{name}\t{direction}\t{len(data)}\t{bytes(data).hex()}\t{decoded}\n" + ) + csv_handle.flush() + else: + print( + f"[{ts:8.3f}] {direction:3s} {short(char_uuid):11s} {name:24s} " + f"len={len(data):3d} {bytes(data).hex():32s} {decoded}" + ) + + print(f"connecting to {device.address}…") + async with BleakClient(device) as client: + if not client.is_connected: + print("connect failed", file=sys.stderr) + return 1 + print("connected. walking GATT tree…") + + # Snapshot service tree + for svc in client.services: + print(f" service {short(svc.uuid):11s} {svc.uuid}") + for ch in svc.characteristics: + props = ",".join(ch.properties) + tag = KNOWN.get(ch.uuid.lower(), "") + print(f" char {ch.handle:3d} {short(ch.uuid):11s} {ch.uuid} ({props}) {tag}") + + # Subscribe to everything notifiable + sub_count = 0 + for svc in client.services: + for ch in svc.characteristics: + if "notify" in ch.properties or "indicate" in ch.properties: + try: + await client.start_notify( + ch.uuid, + lambda _h, d, u=ch.uuid: emit("rx", u, d), + ) + sub_count += 1 + except Exception as err: + print(f" !subscribe {ch.uuid}: {err}") + + # Optional: snapshot readable characteristics + if args.include_non_notifiable: + for svc in client.services: + for ch in svc.characteristics: + if "read" in ch.properties: + try: + v = await client.read_gatt_char(ch.uuid) + emit("rd", ch.uuid, v) + except Exception as err: + print(f" !read {ch.uuid}: {err}") + + print(f"subscribed to {sub_count} characteristics; observing for {args.duration:.0f}s…") + try: + await asyncio.sleep(args.duration) + except KeyboardInterrupt: + pass + + if csv_handle: + csv_handle.close() + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) From 937e581fdd648703953e5dbe19af9a88a4878581 Mon Sep 17 00:00:00 2001 From: mcdax Date: Tue, 21 Jul 2026 19:16:15 +0200 Subject: [PATCH 5/8] Sperax: tolerate transient poll-write failures The status poll loop tore down on the first failed write, so a single dropped packet over a marginal BT proxy turned into a full disconnect/reconnect cycle. Tolerate up to _MAX_POLL_FAILURES (3) consecutive failures, resetting on any success; only give up if the link is really gone (self.connected goes false). Co-Authored-By: Claude Opus 4.8 --- src/walkingpad_controller/sperax.py | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index ad91c48..fa44390 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -148,6 +148,11 @@ def decode(frame: bytes) -> bytes | None: # the status fresh without hammering the write characteristic. _POLL_INTERVAL = 0.5 +# Consecutive failed poll writes tolerated before giving up the poll loop. +# Absorbs the occasional dropped packet on a marginal BT-proxy link instead +# of tearing down on the first hiccup. +_MAX_POLL_FAILURES = 3 + # Speed capabilities. The P3 Max tops out at 12.0 km/h (confirmed by the # device owner in hass-walkingpad#3). The device is not known to expose a # readable speed-range characteristic, so these are fixed defaults. @@ -295,14 +300,36 @@ def _stop_poll(self) -> None: self._poll_task = None async def _poll_loop(self) -> None: - """Periodically poll the device so it keeps streaming status.""" + """Periodically poll the device so it keeps streaming status. + + A single failed poll write is tolerated: over an ESPHome BT proxy (or + any marginal link) the odd dropped packet is normal, and tearing the + loop down on the first error turns one hiccup into a full + disconnect/reconnect cycle. We only give up after + ``_MAX_POLL_FAILURES`` consecutive failures; a success resets the + counter. If the link is genuinely gone, ``self.connected`` goes false + and the loop exits on its own. + """ + failures = 0 try: while self.connected: try: await self._write(bytes([0x00, _CMD_STATUS])) + failures = 0 except BleakError as err: - _LOGGER.debug("Sperax: poll write failed: %s", err) - break + failures += 1 + _LOGGER.debug( + "Sperax: poll write failed (%d/%d): %s", + failures, + _MAX_POLL_FAILURES, + err, + ) + if failures >= _MAX_POLL_FAILURES: + _LOGGER.warning( + "Sperax: stopping poll after %d consecutive failures", + failures, + ) + break await asyncio.sleep(_POLL_INTERVAL) except asyncio.CancelledError: pass From a0964a34360471339aaf5f7cef280b0ff021cd93 Mon Sep 17 00:00:00 2001 From: mcdax Date: Wed, 22 Jul 2026 16:56:18 +0200 Subject: [PATCH 6/8] Sperax: stop() resets cached run targets; pause() keeps them When stay-connected keeps one controller instance alive, the cached run targets (speed + incline) define what the next start() sends. stop() is a full reset, so it now clears them (speed -> min, incline -> 0): the next start begins flat and slow. pause() leaves them intact, so the next start resumes the previous speed and incline. The incline byte in a run frame drives the bed, so the post-stop start actually levels it. Matches the pause=resume / stop=reset model requested in hass-walkingpad#3. (Connect-per-command / stay-connected-off recreates the controller each connect, so this cache only governs the stay-connected path.) Co-Authored-By: Claude Opus 4.8 --- src/walkingpad_controller/sperax.py | 13 +++++++++-- tests/test_sperax_codec.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index fa44390..e6f9aad 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -439,9 +439,17 @@ async def stop(self) -> bool: session counters (steps / distance / time) to 0 — matching the FTMS backend's stop() semantics. Use pause() to stop the belt while keeping the running totals. + + A stop is a full reset, so the cached run targets are cleared too: the + next start() begins flat (incline 0) and at the minimum speed rather + than resuming the previous run. (The incline byte in a *run* frame does + drive the bed, so the next start actually levels it — the stop frame + itself carries incline 0 but the device does not level from a stop.) """ try: await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_STOP, 0x00, 0x00])) + self._target_speed_tenths = int(round(self.min_speed * 10)) + self._target_incline = 0 return True except BleakError as err: _LOGGER.warning("Sperax: stop failed: %s", err) @@ -451,8 +459,9 @@ async def pause(self) -> bool: """Pause the belt, keeping the session (``15 02 00 00``). The belt stops but the device preserves its session counters - (steps / distance / time), so a subsequent start() continues the - session — matching the FTMS backend's pause() semantics. + (steps / distance / time), and the cached run targets (speed + incline) + are left intact, so a subsequent start() resumes the previous run — + matching the FTMS backend's pause() semantics. """ try: await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_PAUSE, 0x00, 0x00])) diff --git a/tests/test_sperax_codec.py b/tests/test_sperax_codec.py index 48f94be..2067761 100644 --- a/tests/test_sperax_codec.py +++ b/tests/test_sperax_codec.py @@ -95,6 +95,41 @@ def test_stop_and_pause_frames(): assert encode(bytes([0x00, 0x15, 0x02, 0x00, 0x00])).hex() == "f50a0015020000c319fa" +def test_stop_resets_targets_pause_keeps_them(): + import asyncio + + from walkingpad_controller.sperax import SperaxController + + async def run(): + ctrl = SperaxController() + sent = [] + + # Stub out the BLE write so we can drive the command methods offline. + async def awrite(inner): + sent.append(bytes(inner)) + + ctrl._write = awrite # noqa: SLF001 + + # Simulate a run at 5.0 km/h, incline 3. + await ctrl.set_target_speed(5.0) + ctrl._status.speed = 5.0 # belt moving, so incline applies # noqa: SLF001 + await ctrl.set_target_inclination(3) + assert ctrl._target_speed_tenths == 50 # noqa: SLF001 + assert ctrl._target_incline == 3 # noqa: SLF001 + + # Pause keeps the cached targets. + await ctrl.pause() + assert ctrl._target_speed_tenths == 50 # noqa: SLF001 + assert ctrl._target_incline == 3 # noqa: SLF001 + + # Stop resets them to flat + minimum. + await ctrl.stop() + assert ctrl._target_incline == 0 # noqa: SLF001 + assert ctrl._target_speed_tenths == int(round(ctrl.min_speed * 10)) # noqa: SLF001 + + asyncio.run(run()) + + def test_status_vibration_only_while_vibrating(): from walkingpad_controller.sperax import SperaxController From b3a3599b3e270a3dad97d91df8af11547cf17a23 Mon Sep 17 00:00:00 2001 From: mcdax Date: Wed, 22 Jul 2026 17:07:09 +0200 Subject: [PATCH 7/8] Sperax: sync run targets from device on (re)connect Treat the device as the source of truth. A fresh controller is created on every connect (including reconnect after a dropped link), so instead of starting from min/flat defaults we adopt the device's actual speed and incline from the first status frame after connecting. Fixes a latent bug: after a reconnect the targets reset to defaults, so an incline nudge would re-send speed=min and slow the belt to 0.5. Now the speed target already matches reality, so only the incline changes. An explicit command (set_speed/set_incline/start/stop/pause) marks state as synced, so a status frame arriving just after a user command can't clobber the user's intent. Co-Authored-By: Claude Opus 4.8 --- src/walkingpad_controller/sperax.py | 25 +++++++++++++++++++++++++ tests/test_sperax_codec.py | 17 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index e6f9aad..6d8cd0e 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -184,6 +184,14 @@ def __init__(self) -> None: self._target_incline = 0 self._vibration_level = 0 + # The device is the source of truth: on (re)connect we adopt its actual + # speed and incline from the first status frame, rather than assuming + # the defaults above. A new controller is created on every connect + # (including reconnect after a dropped link), so this re-syncs state + # each time and avoids, e.g., an incline nudge re-sending a stale + # minimum speed. Cleared on disconnect; set once per connection. + self._synced_from_device = False + self._poll_task: asyncio.Task | None = None # --- Properties --- @@ -287,6 +295,7 @@ async def disconnect(self) -> None: def _on_disconnect(self, client: BleakClient) -> None: _LOGGER.warning("Sperax: Device disconnected") self._connected = False + self._synced_from_device = False self._stop_poll() for cb in self._disconnect_callbacks: try: @@ -390,6 +399,16 @@ def _parse_status(self, inner: bytes) -> None: self._status.vibration_level = vib self._status.incline = inner[16] + # Adopt the device's actual speed/incline as our run targets on the + # first status frame after (re)connecting — the device is the source + # of truth. Without this, the freshly-created controller would keep its + # default targets (min speed, flat), so the next run command (e.g. from + # an incline nudge) would wrongly slow the belt to minimum. + if not self._synced_from_device: + self._target_speed_tenths = speed_raw + self._target_incline = inner[16] + self._synced_from_device = True + # Best-effort counters — NOT yet unit-verified. Exposed so callers have # *something* trending; treat with caution until confirmed on hardware. # counter D (offset 14) trends fastest (~steps/distance). @@ -404,6 +423,9 @@ def _parse_status(self, inner: bytes) -> None: async def _send_run(self) -> bool: """(Re)send the current run target (speed + incline).""" + # We now have explicit intent; don't let a passive status frame + # overwrite these targets via the on-connect device sync. + self._synced_from_device = True speed = max( 0, min(int(round(self._max_speed_tenths())), self._target_speed_tenths), @@ -450,6 +472,7 @@ async def stop(self) -> bool: await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_STOP, 0x00, 0x00])) self._target_speed_tenths = int(round(self.min_speed * 10)) self._target_incline = 0 + self._synced_from_device = True return True except BleakError as err: _LOGGER.warning("Sperax: stop failed: %s", err) @@ -465,6 +488,7 @@ async def pause(self) -> bool: """ try: await self._write(bytes([0x00, _CMD_RUN, _RUN_STATE_PAUSE, 0x00, 0x00])) + self._synced_from_device = True return True except BleakError as err: _LOGGER.warning("Sperax: pause failed: %s", err) @@ -488,6 +512,7 @@ async def set_target_inclination(self, incline_step: int) -> bool: just cache the target; it is applied on the next start()/set_speed(). """ self._target_incline = max(0, min(_MAX_INCLINE, int(incline_step))) + self._synced_from_device = True if self._status.speed > 0: return await self._send_run() return True diff --git a/tests/test_sperax_codec.py b/tests/test_sperax_codec.py index 2067761..8bb9fd5 100644 --- a/tests/test_sperax_codec.py +++ b/tests/test_sperax_codec.py @@ -130,6 +130,23 @@ async def awrite(inner): asyncio.run(run()) +def test_syncs_targets_from_device_on_first_status(): + from walkingpad_controller.sperax import SperaxController + + ctrl = SperaxController() + # First status frame after (re)connect: running at 3.0 km/h, incline 5. + frame = bytes([0x00, 0x19, 0, 0, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 5, 0, 0]) + ctrl._parse_status(frame) # noqa: SLF001 + # Targets adopted from the device rather than the min/flat defaults. + assert ctrl._target_speed_tenths == 30 # noqa: SLF001 + assert ctrl._target_incline == 5 # noqa: SLF001 + # A later frame does NOT re-seed (only the first one after connect does). + frame2 = bytes([0x00, 0x19, 0, 0, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 6, 0, 0]) + ctrl._parse_status(frame2) # noqa: SLF001 + assert ctrl._target_speed_tenths == 30 # noqa: SLF001 + assert ctrl._target_incline == 5 # noqa: SLF001 + + def test_status_vibration_only_while_vibrating(): from walkingpad_controller.sperax import SperaxController From 5ae39816b678a395246088f8652c7cabc60b4f90 Mon Sep 17 00:00:00 2001 From: mcdax Date: Wed, 22 Jul 2026 17:36:49 +0200 Subject: [PATCH 8/8] Sperax: decode distance (and full duration) from status frame Distance was never mapped, so the HA sensor stayed at 0 (reported in hass-walkingpad#3). Decoded the status counters against a real walk: - offset 8-9 (uint16 LE) = duration in seconds - offset 10-11 (uint16 LE) = distance in 10 m units (metres = value * 10) - offset 14 = steps (unchanged; owner-confirmed) Integrating speed over the session gives ~30 m and offset 10 reads 3 (x10), confirming the unit. Offset 12-13 is a slow calorie-like counter, left unmapped (the app's kcal is computed app-side). Also read duration as the full uint16 (was the low byte only). Co-Authored-By: Claude Opus 4.8 --- docs/sperax-p3max-protocol.md | 36 ++++++++++++++++------------- src/walkingpad_controller/sperax.py | 28 +++++++++++----------- tests/test_sperax_codec.py | 14 +++++++++++ 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md index 0972a59..769afd5 100644 --- a/docs/sperax-p3max-protocol.md +++ b/docs/sperax-p3max-protocol.md @@ -279,23 +279,24 @@ The core telemetry frame, streamed while polling. Inner body is fixed-layout | 2–3 | — | constant `00 00` in all samples | | **4** | **state** | `0x10` = running, `0x0F` = decelerating/stopping, `0x01` = stopped, `0x50` = vibration mode, `0x00` = idle/off | | 5–7 | — | constant `00 00 00` | -| **8** | **counter A** | monotonic while active (slow); resets on idle — elapsed-time-like | -| 9 | `0x00` | | -| **10** | **counter B** | slow monotonic 16-bit (with offset 9) — distance/calorie-like | -| 11 | `0x00` | | -| **12** | **counter C** | slow monotonic 16-bit (with offset 11) | -| 13 | `0x00` | | -| **14** | **counter D** | faster monotonic (steps/distance-like); resets on idle | +| **8–9** | **duration** | uint16 LE — elapsed seconds | +| **10–11** | **distance** | uint16 LE in **10 m units** → metres = value × 10 | +| **12–13** | calorie-like counter | uint16 LE; slow-moving. Left unmapped — the app's kcal is computed app-side and this field is only a coarse broadcast | +| **14** | **steps** | uint8 (device-confirmed) | | **15** | **speed** | **current speed in km/h × 10** (`0x1E` = 3.0; ramps down during stop) | -| **16** | **incline** | `0x00` / `0x01` / `0x02` | +| **16** | **incline** | `0x00`–`0x0A` (0 = flat, 10 = max) | | 17 | `0x00` | | -| **18** | **vibration level** | `0x00`–`0x04`; retains last level until reset | +| **18** | **vibration level** | `0x00`–`0x04`; retains last level, only meaningful while state (4) is `0x50` | -The **confirmed, directly usable** fields are **state (4)**, **speed (15)**, -**incline (16)** and **vibration level (18)** — enough for Home Assistant sensors and -state. Offsets 8/10/12/14 are cumulative counters (some combination of elapsed time, -distance, steps and calories); separating them precisely needs a longer capture with -known distance/time references. +Field units for duration and distance were derived from a real walk: integrating +speed over the session gives ~30 m and offset 10 reads `3` (× 10 = 30 m), while +offset 8 tracks the elapsed-second count. Steps (offset 14) were confirmed on the +physical console by the device owner (hass-walkingpad#3). The offset 12–13 counter +moves too slowly to be distance and is most likely a coarse calorie estimate; it is +left unmapped pending confirmation. + +> Note: steps (offset 14) is a single byte, so it wraps at 255 on this capture's +> layout. Not yet observed on a longer walk — flagged for confirmation. **Worked examples** (inner body, spaces added): @@ -351,8 +352,11 @@ device streams `0x19` status notifications. ## 7. Open items - Decode the hello-response (`0x01`) fields → device limits / units. -- Separate status counters at offsets 8 / 10 / 12 / 14 (time vs distance vs steps vs - calories) with a longer, instrumented capture. +- Status counters mostly resolved: offset 8–9 = duration (s), 10–11 = distance + (×10 m), 14 = steps. Offset 12–13 is an unmapped calorie-like counter — confirm + its unit (or that kcal is purely app-side) with a longer instrumented capture. +- Confirm whether steps (offset 14) is really a single byte (wraps at 255) or has a + high byte elsewhere — needs a >255-step walk to observe. - **Max speed is 12.0 km/h** (confirmed by the device owner in hass-walkingpad#3; the reference capture only reached 3.0 km/h). The `` byte still follows km/h × 10, so 12.0 km/h = `0x78`. diff --git a/src/walkingpad_controller/sperax.py b/src/walkingpad_controller/sperax.py index 6d8cd0e..d3c4058 100644 --- a/src/walkingpad_controller/sperax.py +++ b/src/walkingpad_controller/sperax.py @@ -371,14 +371,18 @@ def _parse_status(self, inner: bytes) -> None: """Parse a 0x19 status body ``[0x00, 0x19, ...]`` into TreadmillStatus. Byte offsets within ``inner`` (see docs/sperax-p3max-protocol.md §5.2): - 4 state (0x10 run, 0x0F decel, 0x01 stopped, 0x50 vibration, 0x00 idle) - 8 counter A (elapsed-time-like) 14 counter D (steps/distance-like) - 15 speed x10 16 incline 18 vibration level - - Only speed / belt-state / vibration are confidently decoded; the - cumulative counters are not yet separated (time vs distance vs steps - vs calories), so distance/duration/steps are left at their captured - raw counters as a best-effort and clearly flagged here. + 4 state (0x10 run, 0x0F decel, 0x01 stopped, 0x50 vibration, 0x00 idle) + 8-9 duration, seconds (uint16 LE) + 10-11 distance, in 10 m units (uint16 LE) — so metres = value * 10 + 12-13 calorie-like counter (uint16 LE); left unmapped (the kcal the + app shows is computed app-side and only coarsely broadcast) + 14 steps (uint8) + 15 speed x10 16 incline 18 vibration level + + Distance/duration decoded against a real walk: integrating speed over + the session gives ~30 m, and offset 10 reaches 3 (x10 = 30 m); offset 8 + reaches the elapsed-second count. Steps were confirmed on-device by the + owner (hass-walkingpad#3). """ if len(inner) < 19: return @@ -409,12 +413,10 @@ def _parse_status(self, inner: bytes) -> None: self._target_incline = inner[16] self._synced_from_device = True - # Best-effort counters — NOT yet unit-verified. Exposed so callers have - # *something* trending; treat with caution until confirmed on hardware. - # counter D (offset 14) trends fastest (~steps/distance). + # Session counters. + self._status.duration = inner[8] | (inner[9] << 8) # seconds + self._status.distance = (inner[10] | (inner[11] << 8)) * 10 # metres (10 m units) self._status.steps = inner[14] - # counter A (offset 8) trends ~1/s (~elapsed seconds). - self._status.duration = inner[8] self._status.timestamp = time.time() self._notify_status() diff --git a/tests/test_sperax_codec.py b/tests/test_sperax_codec.py index 8bb9fd5..89a5bd7 100644 --- a/tests/test_sperax_codec.py +++ b/tests/test_sperax_codec.py @@ -130,6 +130,20 @@ async def awrite(inner): asyncio.run(run()) +def test_status_parses_distance_and_duration(): + from walkingpad_controller.sperax import SperaxController + + ctrl = SperaxController() + # duration=47s (0x2f) at 8-9, distance=3 units (30 m) at 10-11, steps=68 at 14. + frame = bytes( + [0x00, 0x19, 0, 0, 0x10, 0, 0, 0, 0x2F, 0, 0x03, 0, 0x02, 0, 68, 30, 0, 0, 0] + ) + ctrl._parse_status(frame) # noqa: SLF001 + assert ctrl.status.duration == 47 + assert ctrl.status.distance == 30 # 3 * 10 m + assert ctrl.status.steps == 68 + + def test_syncs_targets_from_device_on_first_status(): from walkingpad_controller.sperax import SperaxController