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:
@@ -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 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.
|
||||
Reference in New Issue
Block a user