diff --git a/docs/sperax-p3max-protocol.md b/docs/sperax-p3max-protocol.md new file mode 100644 index 0000000..769afd5 --- /dev/null +++ b/docs/sperax-p3max-protocol.md @@ -0,0 +1,371 @@ +# 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` = 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`–`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 **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): + +| 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–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`–`0x0A` (0 = flat, 10 = max) | +| 17 | `0x00` | | +| **18** | **vibration level** | `0x00`–`0x04`; retains last level, only meaningful while state (4) is `0x50` | + +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): + +``` +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. +- 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`. +- **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. + +--- + +*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/models.py b/src/walkingpad_controller/models.py index 856fab0..bdecf6c 100644 --- a/src/walkingpad_controller/models.py +++ b/src/walkingpad_controller/models.py @@ -53,6 +53,14 @@ 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.""" + + 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 new file mode 100644 index 0000000..d3c4058 --- /dev/null +++ b/src/walkingpad_controller/sperax.py @@ -0,0 +1,549 @@ +"""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=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`` + +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-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_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. +_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. +_MIN_SPEED = 0.5 +_MAX_SPEED = 12.0 +_SPEED_INCREMENT = 0.1 +_MAX_INCLINE = 10 +_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 + + # 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 --- + + @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._synced_from_device = 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. + + 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: + 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 + + # --- 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-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 + + 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 + + # 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] + + # 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 + + # 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] + + self._status.timestamp = time.time() + self._notify_status() + + # --- Commands --- + + 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), + ) + 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 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. + + 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 + self._synced_from_device = True + return True + except BleakError as err: + _LOGGER.warning("Sperax: stop failed: %s", err) + return False + + 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), 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])) + self._synced_from_device = True + 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.""" + 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 = flat .. 10 = max). + + Unlike FTMS (which uses a percentage), the P3 Max exposes incline as + 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))) + self._synced_from_device = True + 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). + + 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/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 new file mode 100644 index 0000000..89a5bd7 --- /dev/null +++ b/tests/test_sperax_codec.py @@ -0,0 +1,177 @@ +"""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 + + +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_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_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 + + 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 + + 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()))