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

13 KiB
Raw Blame History

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.

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).
  • 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); 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:

    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 below — minimal one-AP2, full one-AP2, or two-AP2. For a complete, ready-to-run file just copy 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

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):

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:

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 (the canonical two-slot config):

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) — 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 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:

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. Not affiliated with or endorsed by xTool.