Files
esphome-xtool-ap2/README.md
T
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

341 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.