# 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 | | 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`. - **``** — 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 B` (e.g. `M99 V40.212.003.4528.01 B01`) | | `M9039 A` | **set speed/gear** (V2): `A0`=off, `A1`..`A4`=speeds 1–4 | status echo `M9039 A 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 B A D H<%> I<%> J<%> K<%> L<%> S<%> E:"" ``` 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` for auto mode) per the community work below; the V2 uses **`A`**. - 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` 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 .