# 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.