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>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# 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 1–4 | 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 (0–4). **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>.
|
||||
Reference in New Issue
Block a user