Files
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

6.6 KiB
Raw Permalink Blame History

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.)

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.