Skip to content

Add Sperax P3 Max (wi-linktech WLT6200) protocol support#4

Open
mcdax wants to merge 8 commits into
mainfrom
add-sperax-p3max
Open

Add Sperax P3 Max (wi-linktech WLT6200) protocol support#4
mcdax wants to merge 8 commits into
mainfrom
add-sperax-p3max

Conversation

@mcdax

@mcdax mcdax commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Adds a third protocol backend (alongside FTMS and WiLink) for the Sperax P3 Max walking pad (BLE name SPERAX_P3MAX). The device uses a wi-linktech WLT6200 module speaking a custom framed protocol on vendor service 0xFFF0 — it is not FTMS (0x1826) and not the legacy KingSmith WiLink protocol (0xFE00).

Requested in hass-walkingpad#3.

Protocol

Fully documented in docs/sperax-p3max-protocol.md.

F5 | LEN | 00 | CMD | ARGS | CRC16 | FA
  • Byte-stuffing: body bytes 0xF00xFF are sent as F0 (byte & 0x0F)
  • CRC-16: poly 0xA327, init 0xFFFF, reflected, little-endian
  • Commands: hello 0x01, run 0x15 (speed = km/h×10, incline 0–2), vibration 0x16 (level 1–4), status poll 0x19
  • The device only streams status while polled, so the backend runs a keep-alive poll loop.

Reverse-engineered from a full HCI snoop capture of a real P3 Max session.

Changes

  • sperax.py (new)SperaxController backend + frame codec (crc16/encode/decode) + 0x19 status parser + poll loop
  • const.py — Sperax UUIDs, SPERAX_NAME_PREFIXES, ProtocolType.SPERAX
  • controller.py — name/service auto-detection + dispatch; new set_incline() / set_vibration() (Sperax-only)
  • __init__.py — export SperaxController and Sperax constants
  • tests/test_sperax_codec.py (new) — hardware-free codec/parser tests
  • docs/sperax-p3max-protocol.md (new) — full protocol reference

The unified WalkingPadController API (connect/start/set_speed/stop/…) works unchanged, so downstream consumers (e.g. hass-walkingpad) need no changes.

Verification

  • 7/7 unit tests pass (pytest tests/test_sperax_codec.py)
  • decode() validates 146/146 real captured frames; encode() round-trips every captured command frame byte-for-byte (incl. stuffing)
  • ruff clean on all added/changed files

Not yet confirmed on hardware

  • Speed range assumed 0.5–6.0 km/h (capture only reached 3.0; no readable range characteristic on this device)
  • Status distance/duration/steps map to raw counters that aren't unit-verified yet — speed, belt state and vibration level are verified

🤖 Generated with Claude Code

mcdax and others added 8 commits July 18, 2026 10:40
Adds a third protocol backend alongside FTMS and WiLink for the Sperax
P3 Max walking pad (BLE name SPERAX_P3MAX). The device uses a wi-linktech
WLT6200 module with a custom framed protocol on vendor service 0xFFF0 —
not FTMS (0x1826) and not the legacy KingSmith WiLink protocol (0xFE00).

Protocol (fully documented in docs/sperax-p3max-protocol.md):
  F5 | LEN | 00 | CMD | ARGS | CRC16 | FA
  - byte-stuffing: body bytes 0xF0-0xFF sent as F0 (byte & 0x0F)
  - CRC-16 poly 0xA327, init 0xFFFF, reflected, little-endian
  - commands: hello 0x01, run 0x15 (speed = km/h x10, incline 0-2),
    vibration 0x16 (level 1-4), status poll 0x19
  - device only streams status while polled -> keep-alive poll loop

Reverse-engineered from a full HCI snoop capture of a real P3 Max
session; the codec is verified against 146/146 distinct captured frames
and encode() reproduces every captured command frame byte-for-byte.

Changes:
  - sperax.py: SperaxController backend + frame codec + status parser
  - const.py: Sperax UUIDs, name prefixes, ProtocolType.SPERAX
  - controller.py: auto-detection + dispatch; set_incline/set_vibration
  - __init__.py: export SperaxController and Sperax constants
  - tests/test_sperax_codec.py: hardware-free codec/parser tests
  - docs/sperax-p3max-protocol.md: full protocol reference

Speed range (0.5-6.0 km/h) and status counters (distance/duration/steps)
are best-effort pending confirmation on hardware; speed, belt state and
vibration level are verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- P3 Max tops out at 12.0 km/h (confirmed by device owner, hass-walkingpad#3),
  not the previously-assumed 6.0. The speed byte still follows km/h x10.
- Record the vibration level (status offset 18) in TreadmillStatus.vibration_level
  so consumers can surface it.
- Doc: update open items (confirmed max speed; incline/decline capture still needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ile running

- Incline range confirmed 0-10 (flat..max), no decline (full-sweep capture,
  hass-walkingpad#3). Raise _MAX_INCLINE 2 -> 10.
- Expose TreadmillStatus.incline (status offset 16).
- set_target_inclination only re-sends the run command while the belt is
  moving; when stopped it caches the target (applied on next start/set_speed)
  so adjusting incline can't start a stopped belt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Vibration level (status offset 18) is a "last selected" field the device
  does not clear on stop; it signals vibration-active via the state byte
  (offset 4 == 0x50). Report the level only while vibrating, else 0 — fixes
  the HA value staying stuck at the last level after turning vibration off.
- stop() now sends 15 00 00 00 (full stop: belt stops AND session counters
  reset to 0), matching FTMS stop semantics. pause() sends 15 02 00 00 (belt
  stops, counters kept) instead of falling back to stop(). Both confirmed by
  capture in hass-walkingpad#3.
- Tests + protocol doc updated for the three run states and the vibration rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status poll loop tore down on the first failed write, so a single dropped
packet over a marginal BT proxy turned into a full disconnect/reconnect cycle.
Tolerate up to _MAX_POLL_FAILURES (3) consecutive failures, resetting on any
success; only give up if the link is really gone (self.connected goes false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When stay-connected keeps one controller instance alive, the cached run
targets (speed + incline) define what the next start() sends. stop() is a
full reset, so it now clears them (speed -> min, incline -> 0): the next
start begins flat and slow. pause() leaves them intact, so the next start
resumes the previous speed and incline. The incline byte in a run frame
drives the bed, so the post-stop start actually levels it.

Matches the pause=resume / stop=reset model requested in hass-walkingpad#3.
(Connect-per-command / stay-connected-off recreates the controller each
connect, so this cache only governs the stay-connected path.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Treat the device as the source of truth. A fresh controller is created on
every connect (including reconnect after a dropped link), so instead of
starting from min/flat defaults we adopt the device's actual speed and incline
from the first status frame after connecting.

Fixes a latent bug: after a reconnect the targets reset to defaults, so an
incline nudge would re-send speed=min and slow the belt to 0.5. Now the speed
target already matches reality, so only the incline changes.

An explicit command (set_speed/set_incline/start/stop/pause) marks state as
synced, so a status frame arriving just after a user command can't clobber the
user's intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Distance was never mapped, so the HA sensor stayed at 0 (reported in
hass-walkingpad#3). Decoded the status counters against a real walk:
  - offset 8-9  (uint16 LE) = duration in seconds
  - offset 10-11 (uint16 LE) = distance in 10 m units (metres = value * 10)
  - offset 14 = steps (unchanged; owner-confirmed)
Integrating speed over the session gives ~30 m and offset 10 reads 3 (x10),
confirming the unit. Offset 12-13 is a slow calorie-like counter, left
unmapped (the app's kcal is computed app-side).

Also read duration as the full uint16 (was the low byte only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant