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>
253 lines
6.3 KiB
YAML
253 lines
6.3 KiB
YAML
# Example ESPHome config: TWO xTool SafetyPro AP2 purifiers on ONE ESP32,
|
|
# each exposed to Home Assistant as a fan. MACs are set at RUNTIME from HA
|
|
# (no reflash) and persisted to flash — leave them blank, flash once, then
|
|
# either type each AP2's MAC into the "Purifier N MAC" text entity, or press
|
|
# "Purifier Scan" to discover powered AP2s on-device and auto-fill any free slot.
|
|
|
|
esphome:
|
|
name: ap2-hub
|
|
|
|
esp32:
|
|
board: esp32dev
|
|
framework:
|
|
type: esp-idf
|
|
|
|
logger:
|
|
# version 2 + local embeds the web UI in flash (no internet CDN) — required on
|
|
# isolated networks where the device can't reach oi.esphome.io.
|
|
web_server:
|
|
version: 2
|
|
local: true
|
|
api:
|
|
ota:
|
|
- platform: esphome
|
|
|
|
wifi:
|
|
ssid: !secret wifi_ssid
|
|
password: !secret wifi_password
|
|
|
|
external_components:
|
|
- source:
|
|
type: local
|
|
path: components
|
|
components: [ap2_hub]
|
|
# Or from GitHub:
|
|
# - source: github://YOURUSER/esphome-xtool-ap2@main
|
|
# components: [ap2_hub]
|
|
|
|
# BLE security: bond with the AP2 (Just Works, no MITM) so it remembers this ESP32
|
|
esp32_ble:
|
|
io_capability: none
|
|
auth_req_mode: bond
|
|
|
|
# The hub needs the BLE tracker scanning so slots can auto-connect to their MAC.
|
|
esp32_ble_tracker:
|
|
|
|
ap2_hub:
|
|
id: ap2
|
|
|
|
# --- Per-slot MAC inputs (settable from HA; empty clears the slot) ---
|
|
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
|
|
|
|
# --- Per-slot fan: on/off + speeds 1..4 (gear A0..A4) ---
|
|
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"
|
|
|
|
# --- Per-slot connection state + a hub-level "scan running" flag ---
|
|
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
|
|
# Bonded = a BLE bond is stored for this slot's MAC (true even while powered off).
|
|
# This is what makes auto-reconnect-on-power-up work; a MAC alone is not enough.
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: bonded
|
|
slot: 0
|
|
name: "Purifier 1 BLE Bonded"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: bonded
|
|
slot: 1
|
|
name: "Purifier 2 BLE Bonded"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: scan_active
|
|
name: "Purifier Scan Active"
|
|
device_class: running
|
|
|
|
# --- Per-slot link status + serial + firmware; hub-level scan status / devices ---
|
|
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: serial
|
|
slot: 0
|
|
name: "Purifier 1 Serial"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: serial
|
|
slot: 1
|
|
name: "Purifier 2 Serial"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: firmware
|
|
slot: 0
|
|
name: "Purifier 1 Firmware"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
type: firmware
|
|
slot: 1
|
|
name: "Purifier 2 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"
|
|
|
|
# --- Per-slot filter life % (from M9033: H I J K L S). Omit any you don'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"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: pre_filter
|
|
name: "Purifier 2 Filter Pre"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: medium_filter
|
|
name: "Purifier 2 Filter Medium"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: activated_carbon
|
|
name: "Purifier 2 Filter Activated Carbon"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: carbon_cloth
|
|
name: "Purifier 2 Filter Carbon Cloth"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: formaldehyde
|
|
name: "Purifier 2 Filter Formaldehyde"
|
|
- platform: ap2_hub
|
|
ap2_hub_id: ap2
|
|
slot: 1
|
|
type: hepa
|
|
name: "Purifier 2 Filter HEPA"
|
|
|
|
# --- Scan trigger + duration (built-in template platforms drive the hub) ---
|
|
button:
|
|
# Start a scan, or cancel one that's already running (start/stop toggle).
|
|
- platform: template
|
|
name: "Purifier Scan"
|
|
icon: mdi:radar
|
|
on_press:
|
|
- lambda: "id(ap2).toggle_scan();"
|
|
# Force an immediate (active) reconnect attempt on all slots.
|
|
- platform: template
|
|
name: "Purifier Reconnect"
|
|
icon: mdi:bluetooth-connect
|
|
on_press:
|
|
- lambda: "id(ap2).reconnect_all();"
|
|
# Wipe stored BLE bonds — forces a fresh pair on the next pairing-mode connect.
|
|
- platform: template
|
|
name: "Purifier Clear Bonds"
|
|
icon: mdi:bluetooth-off
|
|
entity_category: config
|
|
on_press:
|
|
- lambda: "id(ap2).clear_bonds();"
|
|
# Toggle each AP2's buzzer (M9046). State shown by the "… Buzzer" binary_sensor.
|
|
- platform: template
|
|
name: "Purifier 1 Buzzer Toggle"
|
|
icon: mdi:volume-high
|
|
on_press:
|
|
- lambda: "id(ap2).buzzer_toggle(0);"
|
|
- platform: template
|
|
name: "Purifier 2 Buzzer Toggle"
|
|
icon: mdi:volume-high
|
|
on_press:
|
|
- lambda: "id(ap2).buzzer_toggle(1);"
|
|
|
|
number:
|
|
- platform: template
|
|
name: "Purifier Scan Duration"
|
|
id: ap2_scan_duration
|
|
optimistic: true
|
|
restore_value: true
|
|
initial_value: 90
|
|
min_value: 30
|
|
max_value: 180
|
|
step: 10
|
|
unit_of_measurement: s
|
|
mode: box
|
|
# Applies on user change AND on restore at boot.
|
|
on_value:
|
|
- lambda: "id(ap2).set_scan_duration((uint16_t) x);"
|