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,252 @@
|
||||
# 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);"
|
||||
Reference in New Issue
Block a user