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:
mrambossek
2026-06-22 14:45:11 +02:00
co-authored by Claude Opus 4.8
commit 878f7c9c27
23 changed files with 2523 additions and 0 deletions
+125
View File
@@ -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 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>.