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>
341 lines
13 KiB
Markdown
341 lines
13 KiB
Markdown
# 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](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](#quick-start-esp32--esphome)).
|
||
- **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](SPEC.md)); adapting the component is trivial.
|
||
|
||
## Quick start (ESP32 + ESPHome)
|
||
|
||
1. Put `wifi_ssid` / `wifi_password` in your ESPHome `secrets.yaml`.
|
||
2. 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:
|
||
```yaml
|
||
external_components:
|
||
- source: github://YOURUSER/esphome-xtool-ap2@main
|
||
components: [ap2_hub]
|
||
```
|
||
3. **Add the config.** Drop in the BLE **base** plus one block from
|
||
[Configuration](#configuration) below — *minimal one-AP2*, *full one-AP2*, or *two-AP2*. For
|
||
a complete, ready-to-run file just copy [`ap2-hub.example.yaml`](ap2-hub.example.yaml).
|
||
4. `esphome run your-device.yaml` and adopt it in Home Assistant.
|
||
5. **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 Scan`** in HA — it discovers the AP2 and drops it into a free slot; or
|
||
- paste a MAC into **`Purifier 1 MAC`** / **`Purifier 2 MAC`** by 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 Status`** reads `connected`.
|
||
|
||
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 Reconnect`** button 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
|
||
|
||
```yaml
|
||
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):
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```yaml
|
||
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`](ap2-hub.example.yaml) (the canonical two-slot config):
|
||
|
||
```yaml
|
||
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](SPEC.md)) — it can't be told apart from any
|
||
other unnamed BLE device by passive scanning alone. The scan therefore:
|
||
|
||
1. **Collects** nameless, no-service-data candidates for a few seconds (strongest RSSI first).
|
||
2. **Probes** each one in turn — connects, looks for the Nordic-UART service, sends `M99`, and
|
||
treats a valid `M99 V…` reply as the definitive "this is an AP2" fingerprint (which also
|
||
yields the firmware version).
|
||
3. **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](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:
|
||
|
||
```bash
|
||
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](https://github.com/mneuhaus/xtool-ap2-status). Not affiliated
|
||
with or endorsed by xTool.
|