Files
esphome-xtool-ap2/SPEC.md
T
mrambossekandClaude Opus 4.8 878f7c9c27 Initial commit: esphome-xtool-ap2 ESPHome component
Control one or two xTool SafetyPro AP2 air purifiers over BLE and expose them to Home Assistant via an ESP32 (the ap2_hub external component): per-slot fan control with live gear sync, runtime-settable flash-persisted MACs, an on-device scanner, M9033 status polling (filter life, serial, firmware), buzzer control, and BLE bonding. Includes the PC debug tool (tools/ap2_ble.py) and the reverse-engineered protocol spec (SPEC.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:45:11 +02:00

126 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# xTool SafetyPro AP2 — BLE control protocol
Reverse-engineered protocol for controlling an **xTool SafetyPro AP2** air purifier
directly over Bluetooth LE, without an xTool laser. Verified end-to-end against a real
AP2 (firmware `40.212.003.4528.01`).
> This documents the **AP2 V2** (`PURIFIER_V2`). The AP2 Max / V3 uses the same framing
> with a different device id and a different speed parameter (see notes).
## Transport / BLE
- The AP2 is a **BLE peripheral** exposing the **Nordic UART Service (NUS)**:
| Role | UUID |
|---|---|
| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` |
| RX — write commands here | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` |
| TX — subscribe for replies | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` |
- It advertises connectably **only in pairing mode** (the 5-second power-button hold, link
LED blinking) — **not** in normal standby. Once a central has **bonded** with it (during
pairing mode), the AP2 remembers that central and **re-advertises on every power-up**, so the
bonded peer reconnects automatically with no button again. This is how the laser — and this
project — achieve persistent reconnection. _(Verified end-to-end on real hardware.)_ In
practice this is **unreliable**: the AP2 sometimes drops its half of the bond — notably after
the central reboots — and goes silent again (its link LED won't stay solid), at which point a
~6 s power-button hold (pairing mode) restores it. The central's bond is unaffected.
- **Bonding works and is required for auto-reconnect** (Just Works pairing — IO-capability
none, no MITM): initiate pairing from the central while the AP2 is in pairing mode and it
accepts and stores the bond. ⚠️ **Never re-pair an already-bonded peer** — a fresh pairing
request to a bonded AP2 makes it *delete* the bond and stop auto-advertising. (An earlier
revision claimed the AP2 "rejects SMP pairing"; that was a host-stack artifact — a proper
ESP32 bond is accepted.)
- For control itself **no encryption is required** — the AP2 accepts M-code commands on the
open connection; bonding matters only for the advertise-on-power-up reconnection behavior.
- The advertisement carries **no name and no service data** — the only way to recognise the
AP2 is its **BLE address** (device-specific) or by connecting and finding the NUS service.
Note: the address is flagged **public** on-air even though the value looks random.
(The on-device scanner in the `ap2_hub` component does the latter, confirming a candidate
with an `M99` reply — see [README.md](README.md).)
## Frame format (F0F7)
Every command/reply is an F0F7 frame written to (or notified from) the NUS:
```
0xF0 | id0 id1 id2 | 0x01 | seq | <ASCII M-code> | 0x0A | (sum(body) & 0x7F) | 0xF7
\________________________ body ________________________/
```
- **`0xF0`** start byte (NOT included in the checksum).
- **`id0 id1 id2`** — 3-byte device id / prefix:
- AP2 (PURIFIER_V2) = **`45 73 60`**
- AP2 Max (PURIFIER_V3) = `4C 73 6B`
- **`0x01`** constant.
- **`seq`** — 1-byte sequence counter: starts at `0`, `+1` per frame sent, wraps `0x7E → 1`.
- **`<ASCII M-code>`** — e.g. `M99`, `M9039 A4`.
- **`0x0A`** delimiter (newline).
- **checksum** = `sum(body) & 0x7F` over `id0 … 0x0A` (everything except `0xF0`).
- **`0xF7`** end byte.
## Commands
| M-code | Meaning | Reply |
| ----------------- | -------------------------------------------------------- | --------------------------------------------------------------- |
| `M99` | identify / handshake | `M99 V<firmware> B<batch>` (e.g. `M99 V40.212.003.4528.01 B01`) |
| `M9039 A<n>` | **set speed/gear** (V2): `A0`=off, `A1`..`A4`=speeds 14 | status echo `M9039 A<n> D0 …` |
| `M9033` | full status query (gear, filter life, serial) | see below |
| `M9046 F0` / `F1` | buzzer off / on | — |
### `M9033` status reply (V2, decoded on real hardware)
```
M9033 V<firmware> B<batch> A<gear> D<mode> H<%> I<%> J<%> K<%> L<%> S<%> E:"<serial>"
```
e.g. `M9033 V40.212.003.4528.01 B01 A1 D0 H0 I0 J54 K0 L0 S0 E:"MXAS300000000000H000000"`
- **`A`** — current gear (04). **Reflects the physical button**, so polling `M9033`
(~10 s) is how you keep an external UI in sync with the real fan speed. (V2 uses `A`; the
earlier community tool reports speed in a `V`/`W` field on V3/Max — `V` here is firmware.)
- **`D`** — mode (`0` = manual).
- **`H I J K L S`** — filter life %, in order: pre-filter, medium, activated carbon, carbon
cloth, formaldehyde (Max only), HEPA. `0` or `-1` = not present / no RFID; `≤10` = low.
- **`E`** — unit serial number (quoted).
- **No buzzer field** — the V2 reply does not echo buzzer state, so it can only be _set_
(`M9046`), not read back.
- The AP2 does **not** push unsolicited notifications on a physical change; you must poll.
Notes:
- For the AP2 **Max/V3**, speed uses `M9039 W<0-4>` (and `M9039 D1 C<n>` for auto mode) per
the community work below; the V2 uses **`A<n>`**.
- Send no faster than ~1 frame / 300 ms (the firmware uses a 300 ms accessory send delay).
## Measured speed → power (this unit)
| Command | Fan | Mains power (measured) |
| ---------- | ------- | ---------------------- |
| `M9039 A0` | off | ~2 W |
| `M9039 A1` | speed 1 | ~46 W |
| `M9039 A2` | speed 2 | ~85 W |
| `M9039 A3` | speed 3 | ~110 W |
| `M9039 A4` | max | ~150 W |
## Worked example frames (id `45 73 60`, seq shown)
```
M99 seq0 : f0 45 73 60 01 00 4d 39 39 0a 62 f7
M9039 A0 seq0 : f0 45 73 60 01 00 4d 39 30 33 39 20 41 30 0a 56 f7
M9039 A4 seq0 : f0 45 73 60 01 00 4d 39 30 33 39 20 41 34 0a 5a f7
```
## Typical control sequence
1. Connect to the AP2 (by address; no pairing).
2. Discover NUS, subscribe to notifications on `6e400003` (write CCC `0x0001`).
3. Write `M99`; expect the `M99 V…` reply (confirms the link).
4. Write `M9039 A<n>` to set the speed. Re-connect automatically if dropped — the AP2
re-advertises after any power-cycle, so no button is ever needed.
## Credits
- Independent reverse-engineering of the xTool laser firmware (accessory protocol),
the AP2's GATT, and live validation with a power meter.
- The `M9039` family and the V3 `W`/`D C` scheme were corroborated by
<https://github.com/mneuhaus/xtool-ap2-status>.