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>
13 KiB
esphome-xtool-ap2
Control one or two xTool SafetyPro AP2 air purifiers over Bluetooth LE — no xTool laser required — and expose each to Home Assistant as a fan (off + 4 speeds) via a single ESP32 running ESPHome. Includes an on-device scanner that finds AP2s for you, so you never need a PC to set things up.
The AP2's BLE protocol was reverse-engineered from scratch (xTool laser firmware + the AP2's GATT, validated with a power meter). Full details in SPEC.md.
What you get
- Up to two AP2s on one ESP32, each a Home Assistant fan: on/off + speeds 1–4
(
A1≈46 W …A4≈150 W). - Runtime-settable MACs — type each AP2's address into HA (or let the scanner fill it in); it's persisted to flash, so no reflash and no recompile to assign a unit. Clearing the field unassigns the slot.
- On-device "Scan for AP2" — discovers AP2s in pairing mode and auto-adopts each into a free slot. Reports every find (MAC + firmware + RSSI) to HA live.
- Pair once, then mostly hands-off. The AP2 only advertises in pairing mode, so the ESP32 bonds with it on the first pairing; thereafter it normally re-advertises on power-up and each slot auto-reconnects after a power-cycle — no button again (occasionally a quick re-pair is needed; see Quick start).
- Live status — fan gear, six filter-life %s, serial and firmware, polled from the AP2.
- One ESPHome external component (
ap2_hub) — drop-in for any ESP32.
Works with the AP2 V2 (
PURIFIER_V2). The AP2 Max / V3 uses a slightly different speed command (see SPEC.md); adapting the component is trivial.
Quick start (ESP32 + ESPHome)
-
Put
wifi_ssid/wifi_passwordin your ESPHomesecrets.yaml. -
Make the component available in your device YAML — either keep this repo's
components/next to your YAML (type: local), or pull it from git:external_components: - source: github://YOURUSER/esphome-xtool-ap2@main components: [ap2_hub] -
Add the config. Drop in the BLE base plus one block from Configuration below — minimal one-AP2, full one-AP2, or two-AP2. For a complete, ready-to-run file just copy
ap2-hub.example.yaml. -
esphome run your-device.yamland adopt it in Home Assistant. -
Assign + bond your AP2(s). Put the AP2 in pairing mode (hold its power button ~5 s, link LED blinking) — it only advertises in this state. Then either:
- press
Purifier Scanin HA — it discovers the AP2 and drops it into a free slot; or - paste a MAC into
Purifier 1 MAC/Purifier 2 MACby hand.
The ESP32 connects and bonds automatically. After this one-time pairing, the AP2 normally re-advertises on power-up and the slot reconnects on its own — no button again (occasionally the AP2 drops its bond and needs a quick re-pair; see below). Each slot's fan becomes controllable once
Purifier N BLE Statusreadsconnected. - press
That's it — you'll have Purifier 1 (and optionally Purifier 2) fans in HA. To forget a
bond and re-pair from scratch, press Purifier Clear Bonds.
If a purifier stops reconnecting on its own, the AP2 has dropped its half of the bond (a quirk of xTool's firmware, especially after the ESP32 itself reboots). You can spot it at the unit: the link LED no longer stays solid. Just hold the power button ~6 s (back into pairing mode) and it re-pairs immediately — the ESP32 reconnects on its own, and the
Purifier Reconnectbutton speeds it up. The bond/MAC stored on the ESP32 aren't lost, so there's nothing to reconfigure.
Configuration
Each setup is the base (BLE + hub) plus one option block. Paste the base, then one
option, into a normal ESPHome device config (the usual esp32: with framework: type: esp-idf,
wifi:, api:, ota:, logger: …) — all entity names are yours to change. Leaving unused
slot: 1 entities in is harmless; the second slot simply stays unset and idle.
Base — required by all three
esp32_ble: # bond with the AP2 so it reconnects after a power-cycle
io_capability: none
auth_req_mode: bond
esp32_ble_tracker: # required — slots use it to find + connect to the AP2
ap2_hub:
id: ap2
Option 1 — minimal: one AP2 + a usable scanner
The smallest config that controls one purifier and makes the scanner usable (without the status/results entities you'd press Scan blind):
text:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 MAC"
mode: text
fan:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1"
binary_sensor:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 BLE Connected"
device_class: connectivity
text_sensor:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 BLE Status"
- platform: ap2_hub # scan progress
ap2_hub_id: ap2
type: scan_status
name: "Purifier Scan Status"
- platform: ap2_hub # discovered AP2s, JSON [{mac,fw,rssi}]
ap2_hub_id: ap2
type: devices_found
name: "Purifier Devices Found"
button:
- platform: template
name: "Purifier Scan" # press with the AP2 in pairing mode
on_press:
- lambda: "id(ap2).toggle_scan();"
Option 2 — full: one AP2, every feature
Option 1 plus the six filter-life sensors, serial/firmware, bond indicator, buzzer, and the reconnect / clear-bonds / scan-duration controls:
text:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 MAC"
mode: text
fan:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1"
binary_sensor:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 BLE Connected"
device_class: connectivity
- platform: ap2_hub
ap2_hub_id: ap2
type: bonded
slot: 0
name: "Purifier 1 BLE Bonded"
- platform: ap2_hub
ap2_hub_id: ap2
type: scan_active
name: "Purifier Scan Active"
device_class: running
text_sensor:
- platform: ap2_hub
ap2_hub_id: ap2
slot: 0
name: "Purifier 1 BLE Status"
- platform: ap2_hub
ap2_hub_id: ap2
type: serial
slot: 0
name: "Purifier 1 Serial"
- platform: ap2_hub
ap2_hub_id: ap2
type: firmware
slot: 0
name: "Purifier 1 Firmware"
- platform: ap2_hub
ap2_hub_id: ap2
type: scan_status
name: "Purifier Scan Status"
- platform: ap2_hub
ap2_hub_id: ap2
type: devices_found
name: "Purifier Devices Found"
# Filter life % — drop any layer your unit doesn't have
sensor:
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: pre_filter, name: "Purifier 1 Filter Pre" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: medium_filter, name: "Purifier 1 Filter Medium" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: activated_carbon, name: "Purifier 1 Filter Activated Carbon" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: carbon_cloth, name: "Purifier 1 Filter Carbon Cloth" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: formaldehyde, name: "Purifier 1 Filter Formaldehyde" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: hepa, name: "Purifier 1 Filter HEPA" }
button:
- platform: template
name: "Purifier Scan"
on_press:
- lambda: "id(ap2).toggle_scan();"
- platform: template
name: "Purifier Reconnect"
on_press:
- lambda: "id(ap2).reconnect_all();"
- platform: template
name: "Purifier Clear Bonds"
entity_category: config
on_press:
- lambda: "id(ap2).clear_bonds();"
- platform: template
name: "Purifier 1 Buzzer Toggle"
on_press:
- lambda: "id(ap2).buzzer_toggle(0);"
number:
- platform: template
name: "Purifier Scan Duration"
optimistic: true
restore_value: true
initial_value: 90
min_value: 30
max_value: 180
step: 10
unit_of_measurement: s
mode: box
on_value:
- lambda: "id(ap2).set_scan_duration((uint16_t) x);"
Option 3 — two AP2s (simple)
Two purifiers with the essentials + a shared scanner. For the full feature set on two units,
copy ap2-hub.example.yaml (the canonical two-slot config):
text:
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 MAC", mode: text }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 MAC", mode: text }
fan:
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2" }
binary_sensor:
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 BLE Connected", device_class: connectivity }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 BLE Connected", device_class: connectivity }
text_sensor:
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 BLE Status" }
- { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 BLE Status" }
- { platform: ap2_hub, ap2_hub_id: ap2, type: scan_status, name: "Purifier Scan Status" }
- { platform: ap2_hub, ap2_hub_id: ap2, type: devices_found, name: "Purifier Devices Found" }
button:
- platform: template
name: "Purifier Scan"
on_press:
- lambda: "id(ap2).toggle_scan();"
How the scanner works
The AP2 only advertises while in pairing mode (so put it there first), and even then it's nameless, with no service data (see SPEC.md) — it can't be told apart from any other unnamed BLE device by passive scanning alone. The scan therefore:
- Collects nameless, no-service-data candidates for a few seconds (strongest RSSI first).
- Probes each one in turn — connects, looks for the Nordic-UART service, sends
M99, and treats a validM99 V…reply as the definitive "this is an AP2" fingerprint (which also yields the firmware version). - Reports & adopts — confirmed AP2s stream into
Purifier Devices Found, and any not already assigned drop into the first empty slot.
While a scan runs, the slots' control is suspended (freeing the radio) and the fans read
scanning; a hard Purifier Scan Duration cap (30–180 s, default 90 s) guarantees it
always finishes and restores control.
Entities
Per slot (×2):
| Entity | Type | Notes |
|---|---|---|
Purifier N |
fan | on/off + speeds 1–4 → gear A0..A4; kept in sync with the AP2's real gear (incl. the physical button) via M9033 polling |
Purifier N MAC |
text | settable + flash-persisted; empty clears the slot (and its bond) |
Purifier N BLE Status |
text_sensor | unset / connecting / connected / disconnected / scanning |
Purifier N BLE Connected |
binary_sensor | connectivity |
Purifier N BLE Bonded |
binary_sensor | a BLE bond is stored for the slot's MAC (true even while powered off) |
Purifier N Filter Pre / … Medium / … Activated Carbon / … Carbon Cloth / … Formaldehyde / … HEPA |
sensor | filter life % (M9033 H I J K L S); 0/absent if no filter. Shared Filter prefix groups them in the UI |
Purifier N Serial |
text_sensor | unit serial number |
Purifier N Firmware |
text_sensor | firmware version (from M99) |
Purifier N Buzzer Toggle |
button | toggle the buzzer (M9046); write-only — the V2 doesn't report buzzer state |
Hub-level (scanning):
| Entity | Type | Notes |
|---|---|---|
Purifier Scan |
button | start a scan, or cancel a running one (AP2 must be in pairing mode to be found) |
Purifier Reconnect |
button | force an active reconnect attempt on all slots |
Purifier Clear Bonds |
button | forget all BLE bonds → re-pair from scratch |
Purifier Scan Duration |
number | overall scan cap, 30–180 s (default 90) |
Purifier Scan Active |
binary_sensor | running |
Purifier Scan Status |
text_sensor | live progress (probing 3/8 — 1 found) |
Purifier Devices Found |
text_sensor | JSON [{mac,fw,rssi}] of confirmed AP2s |
The component polls each connected AP2 with M9033 every ~10 s to refresh the fan gear,
filter percentages, and serial — so the UI tracks the device even when it's changed at the
unit's own button (see SPEC.md for the reply format).
Repo layout
components/ap2_hub/ ESPHome external component: control slots + BLE scanner (F0F7/M9039)
ap2-hub.example.yaml Example ESPHome device config (two slots + scan UI)
SPEC.md Full reverse-engineered BLE protocol spec
tools/ap2_ble.py PC control/debug tool (Python + bleak): find / set / sweep / status
PC tool (optional fallback)
The ESP32 scanner replaces the need for this, but the Python tool is still handy for testing without an ESP32, or to find a MAC by hand:
pip install bleak
export AP2_ADDR=AA:BB:CC:DD:EE:FF # your AP2's MAC (or run `find` to discover it)
python tools/ap2_ble.py find # discover the AP2's MAC
python tools/ap2_ble.py set 4 # max speed
python tools/ap2_ble.py set 0 # off
python tools/ap2_ble.py sweep # step through speeds
Credits
Reverse-engineering by the project authors; the M9039 command family was corroborated by
mneuhaus/xtool-ap2-status. Not affiliated
with or endorsed by xTool.