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>
This commit is contained in:
mrambossek
2026-06-22 14:45:11 +02:00
co-authored by Claude Opus 4.8
commit 878f7c9c27
23 changed files with 2523 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
# 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 14
(`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 (30180 s, default 90 s) guarantees it
always finishes and restores control.
## Entities
Per slot (×2):
| Entity | Type | Notes |
|---|---|---|
| `Purifier N` | fan | on/off + speeds 14 → 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, 30180 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.