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>
6.6 KiB
6.6 KiB
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-e50e24dcca9eRX — write commands here 6e400002-b5a3-f393-e0a9-e50e24dcca9eTX — 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_hubcomponent does the latter, confirming a candidate with anM99reply — see 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 ________________________/
0xF0start 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
- AP2 (PURIFIER_V2) =
0x01constant.seq— 1-byte sequence counter: starts at0,+1per frame sent, wraps0x7E → 1.<ASCII M-code>— e.g.M99,M9039 A4.0x0Adelimiter (newline).- checksum =
sum(body) & 0x7Foverid0 … 0x0A(everything except0xF0). 0xF7end 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 pollingM9033(~10 s) is how you keep an external UI in sync with the real fan speed. (V2 usesA; the earlier community tool reports speed in aV/Wfield on V3/Max —Vhere 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.0or-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>(andM9039 D1 C<n>for auto mode) per the community work below; the V2 usesA<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
- Connect to the AP2 (by address; no pairing).
- Discover NUS, subscribe to notifications on
6e400003(write CCC0x0001). - Write
M99; expect theM99 V…reply (confirms the link). - 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
M9039family and the V3W/D Cscheme were corroborated by https://github.com/mneuhaus/xtool-ap2-status.