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:
mrambossek
2026-06-22 14:45:11 +02:00
co-authored by Claude Opus 4.8
commit 878f7c9c27
23 changed files with 2523 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Normalize line endings to LF in the repo (this component runs on Linux / HA).
* text=auto eol=lf
+9
View File
@@ -0,0 +1,9 @@
.claude/settings.local.json
__pycache__/
*.pyc
secrets.yaml
.env
.esphome/
.pioenvs/
build/
logs/
+1
View File
@@ -0,0 +1 @@
keep SPEC.md and README.md in sync when making changes
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 netplanet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+340
View File
@@ -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 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](#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 (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](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.
+125
View File
@@ -0,0 +1,125 @@
# xTool SafetyPro AP2 — BLE control protocol
Reverse-engineered protocol for controlling an **xTool SafetyPro AP2** air purifier
directly over Bluetooth LE, without an xTool laser. Verified end-to-end against a real
AP2 (firmware `40.212.003.4528.01`).
> This documents the **AP2 V2** (`PURIFIER_V2`). The AP2 Max / V3 uses the same framing
> with a different device id and a different speed parameter (see notes).
## Transport / BLE
- The AP2 is a **BLE peripheral** exposing the **Nordic UART Service (NUS)**:
| Role | UUID |
|---|---|
| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` |
| RX — write commands here | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` |
| TX — subscribe for replies | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` |
- It advertises connectably **only in pairing mode** (the 5-second power-button hold, link
LED blinking) — **not** in normal standby. Once a central has **bonded** with it (during
pairing mode), the AP2 remembers that central and **re-advertises on every power-up**, so the
bonded peer reconnects automatically with no button again. This is how the laser — and this
project — achieve persistent reconnection. _(Verified end-to-end on real hardware.)_ In
practice this is **unreliable**: the AP2 sometimes drops its half of the bond — notably after
the central reboots — and goes silent again (its link LED won't stay solid), at which point a
~6 s power-button hold (pairing mode) restores it. The central's bond is unaffected.
- **Bonding works and is required for auto-reconnect** (Just Works pairing — IO-capability
none, no MITM): initiate pairing from the central while the AP2 is in pairing mode and it
accepts and stores the bond. ⚠️ **Never re-pair an already-bonded peer** — a fresh pairing
request to a bonded AP2 makes it *delete* the bond and stop auto-advertising. (An earlier
revision claimed the AP2 "rejects SMP pairing"; that was a host-stack artifact — a proper
ESP32 bond is accepted.)
- For control itself **no encryption is required** — the AP2 accepts M-code commands on the
open connection; bonding matters only for the advertise-on-power-up reconnection behavior.
- The advertisement carries **no name and no service data** — the only way to recognise the
AP2 is its **BLE address** (device-specific) or by connecting and finding the NUS service.
Note: the address is flagged **public** on-air even though the value looks random.
(The on-device scanner in the `ap2_hub` component does the latter, confirming a candidate
with an `M99` reply — see [README.md](README.md).)
## Frame format (F0F7)
Every command/reply is an F0F7 frame written to (or notified from) the NUS:
```
0xF0 | id0 id1 id2 | 0x01 | seq | <ASCII M-code> | 0x0A | (sum(body) & 0x7F) | 0xF7
\________________________ body ________________________/
```
- **`0xF0`** start byte (NOT included in the checksum).
- **`id0 id1 id2`** — 3-byte device id / prefix:
- AP2 (PURIFIER_V2) = **`45 73 60`**
- AP2 Max (PURIFIER_V3) = `4C 73 6B`
- **`0x01`** constant.
- **`seq`** — 1-byte sequence counter: starts at `0`, `+1` per frame sent, wraps `0x7E → 1`.
- **`<ASCII M-code>`** — e.g. `M99`, `M9039 A4`.
- **`0x0A`** delimiter (newline).
- **checksum** = `sum(body) & 0x7F` over `id0 … 0x0A` (everything except `0xF0`).
- **`0xF7`** end byte.
## Commands
| M-code | Meaning | Reply |
| ----------------- | -------------------------------------------------------- | --------------------------------------------------------------- |
| `M99` | identify / handshake | `M99 V<firmware> B<batch>` (e.g. `M99 V40.212.003.4528.01 B01`) |
| `M9039 A<n>` | **set speed/gear** (V2): `A0`=off, `A1`..`A4`=speeds 14 | status echo `M9039 A<n> D0 …` |
| `M9033` | full status query (gear, filter life, serial) | see below |
| `M9046 F0` / `F1` | buzzer off / on | — |
### `M9033` status reply (V2, decoded on real hardware)
```
M9033 V<firmware> B<batch> A<gear> D<mode> H<%> I<%> J<%> K<%> L<%> S<%> E:"<serial>"
```
e.g. `M9033 V40.212.003.4528.01 B01 A1 D0 H0 I0 J54 K0 L0 S0 E:"MXAS300000000000H000000"`
- **`A`** — current gear (04). **Reflects the physical button**, so polling `M9033`
(~10 s) is how you keep an external UI in sync with the real fan speed. (V2 uses `A`; the
earlier community tool reports speed in a `V`/`W` field on V3/Max — `V` here is firmware.)
- **`D`** — mode (`0` = manual).
- **`H I J K L S`** — filter life %, in order: pre-filter, medium, activated carbon, carbon
cloth, formaldehyde (Max only), HEPA. `0` or `-1` = not present / no RFID; `≤10` = low.
- **`E`** — unit serial number (quoted).
- **No buzzer field** — the V2 reply does not echo buzzer state, so it can only be _set_
(`M9046`), not read back.
- The AP2 does **not** push unsolicited notifications on a physical change; you must poll.
Notes:
- For the AP2 **Max/V3**, speed uses `M9039 W<0-4>` (and `M9039 D1 C<n>` for auto mode) per
the community work below; the V2 uses **`A<n>`**.
- Send no faster than ~1 frame / 300 ms (the firmware uses a 300 ms accessory send delay).
## Measured speed → power (this unit)
| Command | Fan | Mains power (measured) |
| ---------- | ------- | ---------------------- |
| `M9039 A0` | off | ~2 W |
| `M9039 A1` | speed 1 | ~46 W |
| `M9039 A2` | speed 2 | ~85 W |
| `M9039 A3` | speed 3 | ~110 W |
| `M9039 A4` | max | ~150 W |
## Worked example frames (id `45 73 60`, seq shown)
```
M99 seq0 : f0 45 73 60 01 00 4d 39 39 0a 62 f7
M9039 A0 seq0 : f0 45 73 60 01 00 4d 39 30 33 39 20 41 30 0a 56 f7
M9039 A4 seq0 : f0 45 73 60 01 00 4d 39 30 33 39 20 41 34 0a 5a f7
```
## Typical control sequence
1. Connect to the AP2 (by address; no pairing).
2. Discover NUS, subscribe to notifications on `6e400003` (write CCC `0x0001`).
3. Write `M99`; expect the `M99 V…` reply (confirms the link).
4. Write `M9039 A<n>` to set the speed. Re-connect automatically if dropped — the AP2
re-advertises after any power-cycle, so no button is ever needed.
## Credits
- Independent reverse-engineering of the xTool laser firmware (accessory protocol),
the AP2's GATT, and live validation with a power meter.
- The `M9039` family and the V3 `W`/`D C` scheme were corroborated by
<https://github.com/mneuhaus/xtool-ap2-status>.
+252
View File
@@ -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);"
+61
View File
@@ -0,0 +1,61 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import esp32_ble_client, esp32_ble_tracker
from esphome.const import CONF_ID
CODEOWNERS = ["@netplanet"]
DEPENDENCIES = ["esp32_ble_tracker"]
# esp32_ble_client gives us BLEClientBase; the hub publishes connection state to
# binary_sensor/text_sensor. The fan/text platforms load when the user adds them.
AUTO_LOAD = ["esp32_ble_client", "binary_sensor", "text_sensor", "sensor"]
# Number of AP2 slots one ESP32 manages (control connections + 1 transient
# probe in Phase 2 must stay within esp32_ble_tracker's max_connections).
NUM_SLOTS = 2
ap2_hub_ns = cg.esphome_ns.namespace("ap2_hub")
AP2Hub = ap2_hub_ns.class_(
"AP2Hub", cg.Component, esp32_ble_tracker.ESPBTDeviceListener
)
AP2Slot = ap2_hub_ns.class_("AP2Slot", esp32_ble_client.BLEClientBase)
AP2ProbeClient = ap2_hub_ns.class_("AP2ProbeClient", esp32_ble_client.BLEClientBase)
# Hidden auto-generated IDs for the slot + prober sub-objects. Declaring them here
# (rather than fabricating IDs in to_code) is what registers them in
# CORE.component_ids, so cg.register_component accepts them.
SLOT_ID_KEYS = [f"slot_{i}_id" for i in range(NUM_SLOTS)]
PROBE_ID_KEY = "probe_id"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AP2Hub),
cv.GenerateID(PROBE_ID_KEY): cv.declare_id(AP2ProbeClient),
**{cv.GenerateID(k): cv.declare_id(AP2Slot) for k in SLOT_ID_KEYS},
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
hub = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(hub, config)
# The hub listens to advertisements to collect scan candidates.
await esp32_ble_tracker.register_ble_device(hub, config)
for i, key in enumerate(SLOT_ID_KEYS):
slot = cg.new_Pvariable(config[key], i)
await cg.register_component(slot, config)
# Register each slot as a BLE client of the esp32_ble tracker (uses esp32_ble_id).
await esp32_ble_tracker.register_client(slot, config)
cg.add(hub.add_slot(slot))
# Transient scan prober — a third BLE client, only ever connects while the
# slots are suspended for a scan.
probe = cg.new_Pvariable(config[PROBE_ID_KEY])
await cg.register_component(probe, config)
await esp32_ble_tracker.register_client(probe, config)
cg.add(probe.set_hub(hub))
cg.add(hub.set_prober(probe))
+34
View File
@@ -0,0 +1,34 @@
#include "ap2_fan.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome {
namespace ap2_hub {
static const char *const TAG = "ap2_hub.fan";
void AP2Fan::control(const fan::FanCall &call) {
if (this->hub_ == nullptr || !this->hub_->slot_ready(this->slot_)) {
ESP_LOGD(TAG, "slot %u not ready; command ignored", (unsigned) this->slot_);
return; // leave published state unchanged
}
bool had_state = call.get_state().has_value();
bool had_speed = call.get_speed().has_value();
if (had_state)
this->state = *call.get_state();
if (had_speed)
this->speed = *call.get_speed();
// A speed-only change (e.g. the web_server slider) of 0 means "off", any
// other speed means "on" — keeps the slider's 0 position consistent with the
// on/off toggle. An explicit state in the call always wins.
if (had_speed && !had_state)
this->state = this->speed > 0;
uint8_t gear = this->state ? (uint8_t) std::max(1, this->speed) : 0;
this->hub_->set_gear(this->slot_, gear);
this->publish_state();
}
} // namespace ap2_hub
} // namespace esphome
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/fan/fan.h"
#include "ap2_hub.h"
namespace esphome {
namespace ap2_hub {
/* Per-slot fan: off + speeds 1..4 -> gear A0..A4. Commands are silently dropped
* while the slot is not ready (unset / disconnected / scanning). */
class AP2Fan : public fan::Fan, public Component {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
fan::FanTraits get_traits() override {
auto t = fan::FanTraits();
t.set_speed(true);
t.set_supported_speed_count(4);
return t;
}
/* Reflect the AP2's actual gear (from an M9033 poll) into this entity WITHOUT
* sending a command back — keeps the UI in sync with the physical button. */
void update_from_device(uint8_t gear) {
bool st = gear > 0;
int sp = gear > 0 ? (int) gear : this->speed;
if (this->state == st && (!st || this->speed == sp))
return; // no change
this->state = st;
if (st)
this->speed = sp;
this->publish_state();
}
protected:
void control(const fan::FanCall &call) override;
AP2Hub *hub_{nullptr};
uint8_t slot_{0};
};
} // namespace ap2_hub
} // namespace esphome
+660
View File
@@ -0,0 +1,660 @@
#include "ap2_hub.h"
#include "ap2_probe.h"
#include "ap2_fan.h"
#include "esphome/core/hal.h" // millis()
#include "esphome/core/log.h"
#include <esp_gap_ble_api.h> // BLE bond list
#include <algorithm>
#include <cctype>
#include <cstring>
namespace esphome {
namespace ap2_hub {
static const char *const TAG = "ap2_hub";
/* Scan tuning. */
static const size_t MAX_CANDS = 16; // distinct nameless devices we remember
static const size_t PROBE_CAP = 10; // strongest-RSSI candidates we actually probe
static const uint32_t COLLECT_MS = 8000; // advertisement-collection window
static const uint32_t PROBE_TIMEOUT_MS = 6000; // per-candidate connect+M99 budget
/* "AA:BB:CC:DD:EE:FF" -> 0xAABBCCDDEEFF. Empty/whitespace -> 0 (clear).
* Returns false on malformed input (caller leaves the slot unchanged). */
static bool parse_mac(const std::string &in, uint64_t *out) {
std::string s;
for (char c : in)
if (!std::isspace((unsigned char) c))
s += c;
if (s.empty()) {
*out = 0;
return true;
}
uint64_t v = 0;
int bytes = 0, nib = 0;
uint8_t cur = 0;
for (char c : s) {
if (c == ':' || c == '-') {
if (nib == 0)
return false;
v = (v << 8) | cur;
cur = 0;
nib = 0;
bytes++;
continue;
}
int h;
if (c >= '0' && c <= '9')
h = c - '0';
else if (c >= 'a' && c <= 'f')
h = c - 'a' + 10;
else if (c >= 'A' && c <= 'F')
h = c - 'A' + 10;
else
return false;
cur = (cur << 4) | h;
if (++nib > 2)
return false;
}
if (nib == 0)
return false;
v = (v << 8) | cur;
if (++bytes != 6)
return false;
*out = v;
return true;
}
/* Is this peer address already in the BLE bond store (NVS)? */
static bool addr_in_bond_list(const uint8_t *bda) {
int n = esp_ble_get_bond_device_num();
if (n <= 0)
return false;
std::vector<esp_ble_bond_dev_t> list(n);
esp_ble_get_bond_device_list(&n, list.data());
for (int i = 0; i < n; i++)
if (memcmp(list[i].bd_addr, bda, sizeof(esp_bd_addr_t)) == 0)
return true;
return false;
}
static void mac_to_bda(uint64_t mac, esp_bd_addr_t bda) {
for (int i = 0; i < 6; i++)
bda[i] = (mac >> (8 * (5 - i))) & 0xFF;
}
/* Same, but for a uint64 MAC (works while disconnected — queries NVS). */
static bool mac_in_bond_list(uint64_t mac) {
if (mac == 0)
return false;
esp_bd_addr_t bda;
mac_to_bda(mac, bda);
return addr_in_bond_list(bda);
}
/* Forget any stored bond for this MAC (e.g. when a slot is cleared/reassigned). */
static void remove_bond_for_mac(uint64_t mac) {
if (mac == 0)
return;
esp_bd_addr_t bda;
mac_to_bda(mac, bda);
if (addr_in_bond_list(bda))
esp_ble_remove_bond_device(bda);
}
// ===================== AP2Slot =====================
void AP2Slot::setup() {
BLEClientBase::setup();
this->mac_pref_ = global_preferences->make_preference<uint64_t>(fnv1_hash("ap2_hub_slot_mac") ^
(uint32_t) (this->index_ + 1));
uint64_t saved = 0;
if (this->mac_pref_.load(&saved) && saved != 0) {
ESP_LOGI(TAG, "slot %u: restoring MAC from flash", this->index_);
this->apply_mac_(saved, /*persist=*/false);
} else {
this->set_auto_connect(false);
this->publish_status_();
}
}
void AP2Slot::loop() {
BLEClientBase::loop(); // NOTE: disables this loop while idle (see hub loop for bond check)
// Poll the AP2's live status (M9033) — the reply is logged by the NOTIFY handler.
uint32_t now = millis();
if (this->ready_ && now - this->last_status_poll_ms_ >= 10000) {
this->last_status_poll_ms_ = now;
this->send_cmd_("M9033");
}
}
void AP2Slot::update_bonded() {
// NVS-backed, valid even while disconnected (bond persists when powered off).
bool bonded = mac_in_bond_list(this->mac_);
if (!this->bonded_inited_ || bonded != this->last_bonded_) {
this->bonded_inited_ = true;
this->last_bonded_ = bonded;
if (this->bonded_sensor_ != nullptr)
this->bonded_sensor_->publish_state(bonded);
}
}
void AP2Slot::dump_config() {
ESP_LOGCONFIG(TAG, "AP2 slot %u: MAC %s", this->index_,
this->mac_ == 0 ? "(unset)" : this->mac_str().c_str());
}
void AP2Slot::apply_mac_(uint64_t mac, bool persist) {
uint64_t old = this->mac_;
this->mac_ = mac;
if (persist) {
this->mac_pref_.save(&this->mac_);
// ESPHome's save() only queues to RAM and commits on the next sync (e.g. a
// clean OTA reboot). Flush now so the MAC also survives a hard reset/power-cut.
global_preferences->sync();
}
if (this->connected())
this->disconnect();
// If the slot's device changed (cleared or reassigned), forget its old bond.
if (old != 0 && old != mac) {
ESP_LOGI(TAG, "slot %u: forgetting bond for %s", this->index_, ap2_format_mac(old).c_str());
remove_bond_for_mac(old);
}
if (mac != 0) {
this->set_address(mac);
this->set_auto_connect(!this->scanning_);
} else {
this->set_address(0);
this->set_auto_connect(false);
this->ready_ = false;
this->fw_.clear();
}
this->publish_status_();
}
void AP2Slot::set_mac_str(const std::string &mac) {
uint64_t parsed;
if (!parse_mac(mac, &parsed)) {
ESP_LOGW(TAG, "slot %u: invalid MAC '%s' (ignored)", this->index_, mac.c_str());
return;
}
ESP_LOGI(TAG, "slot %u: set MAC '%s'", this->index_, parsed == 0 ? "(clear)" : mac.c_str());
this->apply_mac_(parsed, /*persist=*/true);
}
std::string AP2Slot::mac_str() const { return ap2_format_mac(this->mac_); }
void AP2Slot::set_scanning(bool scanning) {
if (scanning == this->scanning_)
return;
this->scanning_ = scanning;
if (scanning) {
if (this->connected())
this->disconnect();
this->set_auto_connect(false);
this->ready_ = false;
} else if (this->mac_ != 0) {
this->set_auto_connect(true);
}
this->publish_status_();
}
void AP2Slot::try_reconnect() {
if (this->mac_ == 0 || this->scanning_)
return;
if (this->state() != espbt::ClientState::IDLE)
return; // already connecting / connected / busy
// Active direct connect: listens ~continuously for ~20s for the AP2 to
// advertise. (A background connect with is_direct=false just errors instantly
// on this stack, so this is the best we can do — it can only succeed when the
// AP2 actually advertises, e.g. in pairing mode or a cold boot that re-adverts.)
ESP_LOGI(TAG, "slot %u: reconnect attempt", this->index_);
this->connect();
}
void AP2Slot::set_state(espbt::ClientState st) {
BLEClientBase::set_state(st);
if (st != espbt::ClientState::ESTABLISHED && (this->ready_ || this->rx_handle_ != 0)) {
this->ready_ = false;
this->rx_handle_ = 0;
this->tx_handle_ = 0;
this->ccc_handle_ = 0;
this->bond_tried_ = false;
}
this->publish_status_();
}
bool AP2Slot::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (!BLEClientBase::gattc_event_handler(event, gattc_if, param))
return false;
switch (event) {
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *rx = this->get_characteristic(nus_svc(), nus_rx());
auto *tx = this->get_characteristic(nus_svc(), nus_tx());
if (rx == nullptr || tx == nullptr) {
ESP_LOGW(TAG, "slot %u: Nordic UART not found on peer", this->index_);
break;
}
this->rx_handle_ = rx->handle;
this->tx_handle_ = tx->handle;
auto *ccc = this->get_config_descriptor(this->tx_handle_);
this->ccc_handle_ = (ccc != nullptr) ? ccc->handle : 0;
ESP_LOGD(TAG, "slot %u: NUS found (rx=0x%04x tx=0x%04x) — subscribing", this->index_,
this->rx_handle_, this->tx_handle_);
esp_ble_gattc_register_for_notify(gattc_if, this->get_remote_bda(), this->tx_handle_);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
if (this->ccc_handle_ != 0) {
uint16_t notify_en = 0x0001;
esp_ble_gattc_write_char_descr(gattc_if, this->get_conn_id(), this->ccc_handle_,
sizeof(notify_en), (uint8_t *) &notify_en,
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
}
this->ready_ = true;
ESP_LOGD(TAG, "slot %u ready", this->index_);
this->send_cmd_("M99"); // identify/handshake
// Bond ONCE so the AP2 remembers us and advertises for us on future
// power-ups. Never re-pair an already-bonded peer — a fresh pairing request
// to a bonded device makes it delete the bond (and stop auto-advertising).
if (!this->bond_tried_) {
this->bond_tried_ = true;
if (addr_in_bond_list(this->get_remote_bda())) {
ESP_LOGD(TAG, "slot %u: already bonded; not re-pairing", this->index_);
} else {
ESP_LOGI(TAG, "slot %u: not bonded yet — requesting bond", this->index_);
this->pair();
}
}
this->publish_status_();
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle == this->tx_handle_) {
std::string s = ap2_ascii(param->notify.value, param->notify.value_len);
ESP_LOGD(TAG, "slot %u <- %s", this->index_, s.c_str());
std::string fw = ap2_parse_m99_fw(s);
if (!fw.empty() && fw != this->fw_) {
this->fw_ = fw;
if (this->firmware_sensor_ != nullptr)
this->firmware_sensor_->publish_state(fw);
}
if (s.find("M9033") != std::string::npos)
this->parse_m9033_(s);
}
break;
}
default:
break;
}
return true;
}
void AP2Slot::send_cmd_(const char *cmd) {
if (!this->ready_ || this->rx_handle_ == 0) {
ESP_LOGW(TAG, "slot %u not ready, dropping '%s'", this->index_, cmd);
return;
}
uint8_t f[64];
uint8_t n = ap2_build_frame(cmd, this->seq_, f);
this->seq_ = (this->seq_ < 0x7E) ? (this->seq_ + 1) : 1;
ESP_LOGD(TAG, "slot %u -> %s", this->index_, cmd);
esp_ble_gattc_write_char((esp_gatt_if_t) this->get_gattc_if(), this->get_conn_id(),
this->rx_handle_, n, f, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
}
void AP2Slot::set_gear(uint8_t gear) {
if (!this->is_ready()) {
ESP_LOGW(TAG, "slot %u not ready, ignoring gear A%u", this->index_, (unsigned) gear);
return;
}
if (gear > 4)
gear = 4;
char b[16];
snprintf(b, sizeof(b), "M9039 A%u", (unsigned) gear);
ESP_LOGD(TAG, "slot %u: set gear A%u", this->index_, (unsigned) gear);
this->send_cmd_(b);
}
void AP2Slot::publish_status_() {
bool controllable = this->ready_ && !this->scanning_;
std::string st;
if (this->scanning_)
st = "scanning";
else if (this->mac_ == 0)
st = "unset";
else if (this->ready_)
st = "connected";
else if (this->connected())
st = "connecting";
else
st = "disconnected";
if (!this->status_inited_ || st != this->last_status_) {
if (this->status_inited_)
ESP_LOGI(TAG, "slot %u: %s", this->index_, st.c_str()); // connect/disconnect visibility
this->last_status_ = st;
if (this->status_sensor_ != nullptr)
this->status_sensor_->publish_state(st);
}
if (!this->status_inited_ || controllable != this->last_connected_) {
this->last_connected_ = controllable;
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_state(controllable);
}
this->status_inited_ = true;
}
/* Parse an M9033 status reply:
* M9033 V<fw> B<batch> A<speed> D<mode> H.. I.. J.. K.. L.. S.. E:"<serial>"
* Space-separated <letter><number> fields; E:"..." is the serial. */
void AP2Slot::parse_m9033_(const std::string &s) {
auto p = s.find("M9033");
std::string body = s.substr(p + 5);
size_t i = 0;
while (i < body.size()) {
while (i < body.size() && body[i] == ' ')
i++;
size_t j = i;
while (j < body.size() && body[j] != ' ')
j++;
std::string tok = body.substr(i, j - i);
i = j;
if (tok.size() < 2)
continue;
char f = tok[0];
if (f == 'E') { // serial: E:"...."
auto q1 = tok.find('"');
auto q2 = tok.rfind('"');
if (q1 != std::string::npos && q2 > q1) {
std::string serial = tok.substr(q1 + 1, q2 - q1 - 1);
if (serial != this->last_serial_) {
this->last_serial_ = serial;
if (this->serial_sensor_ != nullptr)
this->serial_sensor_->publish_state(serial);
}
}
continue;
}
int v = atoi(tok.c_str() + 1); // value after the field letter
int fi = -1;
switch (f) {
case 'A': // fan speed/gear -> reflect into the fan entity
if (this->fan_ != nullptr)
this->fan_->update_from_device((uint8_t) (v < 0 ? 0 : v));
break;
case 'H': fi = FILT_PRE; break;
case 'I': fi = FILT_MEDIUM; break;
case 'J': fi = FILT_CARBON; break;
case 'K': fi = FILT_CARBON_CLOTH; break;
case 'L': fi = FILT_FORMALDEHYDE; break;
case 'S': fi = FILT_HEPA; break;
default: break;
}
if (fi >= 0 && this->filter_sensors_[fi] != nullptr)
this->filter_sensors_[fi]->publish_state((float) v);
}
}
void AP2Slot::toggle_buzzer() {
if (!this->is_ready()) {
ESP_LOGW(TAG, "slot %u: buzzer toggle ignored (not ready)", this->index_);
return;
}
this->buzzer_on_ = !this->buzzer_on_;
ESP_LOGI(TAG, "slot %u: buzzer %s", this->index_, this->buzzer_on_ ? "on" : "off");
this->send_cmd_(this->buzzer_on_ ? "M9046 F1" : "M9046 F0");
}
// ===================== AP2Hub =====================
void AP2Hub::loop() {
// The hub's loop always runs (unlike the slots', which BLEClientBase disables
// while idle). Use it to refresh each slot's bonded indicator even when the
// AP2 is disconnected / powered off.
uint32_t now = millis();
if (now - this->last_bond_poll_ms_ >= 2000) {
this->last_bond_poll_ms_ = now;
for (auto *s : this->slots_)
s->update_bonded();
}
// Actively retry reconnection while disconnected (more reliable than passive
// auto-connect for a bonded AP2 that uses directed advertising).
if (now - this->last_reconnect_ms_ >= 20000) {
this->last_reconnect_ms_ = now;
for (auto *s : this->slots_)
s->try_reconnect();
}
}
void AP2Hub::reconnect_all() {
for (auto *s : this->slots_)
s->try_reconnect();
}
void AP2Hub::dump_config() {
ESP_LOGCONFIG(TAG, "AP2 hub: %u slot(s), scanner %s", (unsigned) this->slots_.size(),
this->prober_ != nullptr ? "ready" : "DISABLED (no prober)");
int n = esp_ble_get_bond_device_num();
ESP_LOGCONFIG(TAG, " BLE bonds stored: %d", n);
if (n > 0) {
std::vector<esp_ble_bond_dev_t> list(n);
esp_ble_get_bond_device_list(&n, list.data());
for (int i = 0; i < n; i++) {
const uint8_t *a = list[i].bd_addr;
const uint8_t *id = list[i].bond_key.pid_key.static_addr; // identity address
ESP_LOGCONFIG(TAG, " bond %d: bonded=%02X:%02X:%02X:%02X:%02X:%02X identity=%02X:%02X:%02X:%02X:%02X:%02X",
i, a[0], a[1], a[2], a[3], a[4], a[5], id[0], id[1], id[2], id[3], id[4], id[5]);
}
}
}
void AP2Hub::clear_bonds() {
int n = esp_ble_get_bond_device_num();
ESP_LOGI(TAG, "clearing %d BLE bond(s)", n);
if (n <= 0)
return;
std::vector<esp_ble_bond_dev_t> list(n);
esp_ble_get_bond_device_list(&n, list.data());
for (int i = 0; i < n; i++)
esp_ble_remove_bond_device(list[i].bd_addr);
}
void AP2Hub::toggle_scan() {
if (this->is_scanning()) {
ESP_LOGI(TAG, "scan: cancelled by user");
this->finish_scan_("stopped");
} else {
this->start_scan();
}
}
void AP2Hub::start_scan() {
if (this->scan_state_ != ScanState::IDLE) {
ESP_LOGW(TAG, "scan: already running");
return;
}
if (this->prober_ == nullptr) {
ESP_LOGE(TAG, "scan: no prober configured");
return;
}
// The AP2 only advertises in pairing mode — remind the user, then start now.
ESP_LOGI(TAG,
"scan: make sure your AP2 is in Pairing Mode (hold the Power Button ~5s until the "
"Link LED blinks) — it only advertises in that state. Scanning now (%us budget)…",
(unsigned) this->scan_duration_s_);
this->cands_.clear();
this->found_.clear();
this->probe_idx_ = 0;
this->scan_start_ms_ = millis();
for (auto *s : this->slots_)
s->set_scanning(true); // suspend control, free the radio
this->scan_state_ = ScanState::COLLECTING;
this->publish_scan_active_(true);
this->set_scan_status_("scanning…");
uint32_t budget_ms = (uint32_t) this->scan_duration_s_ * 1000;
uint32_t collect_ms = std::min(COLLECT_MS, budget_ms / 2);
this->set_timeout("collect", collect_ms, [this]() { this->begin_probing_(); });
this->set_timeout("watchdog", budget_ms, [this]() {
ESP_LOGW(TAG, "scan: time budget reached");
this->finish_scan_();
});
}
bool AP2Hub::parse_device(const espbt::ESPBTDevice &device) {
if (this->scan_state_ != ScanState::COLLECTING)
return false;
// AP2s advertise nameless and with no service data; filter to those candidates.
if (!device.get_name().empty() || !device.get_service_uuids().empty())
return false;
uint64_t mac = device.address_uint64();
for (auto &c : this->cands_)
if (c.mac == mac)
return false; // already known
if (this->cands_.size() >= MAX_CANDS)
return false;
this->cands_.push_back({mac, device.get_address_type(), device.get_rssi()});
return false;
}
void AP2Hub::begin_probing_() {
if (this->scan_state_ != ScanState::COLLECTING)
return;
std::sort(this->cands_.begin(), this->cands_.end(),
[](const Cand &a, const Cand &b) { return a.rssi > b.rssi; });
if (this->cands_.size() > PROBE_CAP) {
ESP_LOGI(TAG, "scan: %u candidates, probing strongest %u", (unsigned) this->cands_.size(),
(unsigned) PROBE_CAP);
this->cands_.resize(PROBE_CAP);
}
this->scan_state_ = ScanState::PROBING;
ESP_LOGI(TAG, "scan: probing %u candidate(s)", (unsigned) this->cands_.size());
this->probe_next_();
}
void AP2Hub::probe_next_() {
if (this->scan_state_ != ScanState::PROBING)
return;
uint32_t elapsed = millis() - this->scan_start_ms_;
if (this->probe_idx_ >= this->cands_.size() ||
elapsed >= (uint32_t) this->scan_duration_s_ * 1000) {
this->finish_scan_();
return;
}
auto &c = this->cands_[this->probe_idx_];
ESP_LOGD(TAG, "scan: probe %u/%u %s (rssi %d)", (unsigned) (this->probe_idx_ + 1),
(unsigned) this->cands_.size(), ap2_format_mac(c.mac).c_str(), c.rssi);
this->set_scan_status_(str_sprintf("probing %u/%u — %u found", (unsigned) (this->probe_idx_ + 1),
(unsigned) this->cands_.size(), (unsigned) this->found_.size()));
this->prober_->start_probe(c.mac, c.type);
this->set_timeout("probe", PROBE_TIMEOUT_MS, [this]() {
ESP_LOGD(TAG, "scan: probe timed out");
this->prober_->abort_probe(); // -> IDLE -> probe_finished()
});
}
void AP2Hub::probe_finished(uint64_t mac, bool confirmed, const std::string &fw) {
if (this->scan_state_ != ScanState::PROBING)
return; // stray IDLE (e.g. teardown during finish_scan_)
this->cancel_timeout("probe");
if (confirmed) {
int rssi = 0;
for (auto &c : this->cands_)
if (c.mac == mac) {
rssi = c.rssi;
break;
}
this->found_.push_back({mac, fw, rssi});
ESP_LOGI(TAG, "scan: FOUND AP2 %s (V%s, rssi %d)", ap2_format_mac(mac).c_str(), fw.c_str(),
rssi);
this->publish_devices_();
this->try_adopt_(mac);
}
this->probe_idx_++;
this->set_scan_status_(str_sprintf(
"probing %u/%u — %u found",
(unsigned) std::min((size_t) (this->probe_idx_ + 1), this->cands_.size()),
(unsigned) this->cands_.size(), (unsigned) this->found_.size()));
this->defer([this]() { this->probe_next_(); });
}
void AP2Hub::finish_scan_(const char *verb) {
if (this->scan_state_ == ScanState::IDLE)
return;
this->cancel_timeout("collect");
this->cancel_timeout("probe");
this->cancel_timeout("watchdog");
this->scan_state_ = ScanState::IDLE; // set before aborting so the prober's IDLE is ignored
if (this->prober_ != nullptr)
this->prober_->abort_probe();
for (auto *s : this->slots_)
s->set_scanning(false); // restore control
this->publish_devices_();
this->set_scan_status_(str_sprintf("%s — %u found", verb, (unsigned) this->found_.size()));
this->publish_scan_active_(false);
ESP_LOGI(TAG, "scan: %s, %u AP2(s) found", verb, (unsigned) this->found_.size());
}
void AP2Hub::try_adopt_(uint64_t mac) {
std::string ms = ap2_format_mac(mac);
for (auto *s : this->slots_)
if (s->mac_str() == ms)
return; // already assigned somewhere
for (auto *s : this->slots_)
if (s->mac_str().empty()) {
ESP_LOGI(TAG, "scan: adopting %s into a free slot", ms.c_str());
s->set_mac_str(ms);
return;
}
}
void AP2Hub::publish_devices_() {
std::string j = "[";
for (size_t i = 0; i < this->found_.size(); i++) {
if (i)
j += ",";
j += str_sprintf("{\"mac\":\"%s\",\"fw\":\"%s\",\"rssi\":%d}",
ap2_format_mac(this->found_[i].mac).c_str(), this->found_[i].fw.c_str(),
this->found_[i].rssi);
}
j += "]";
this->devices_json_ = j;
if (this->devices_found_sensor_ != nullptr)
this->devices_found_sensor_->publish_state(j);
}
void AP2Hub::set_scan_status_(const std::string &s) {
this->scan_status_ = s;
if (this->scan_status_sensor_ != nullptr)
this->scan_status_sensor_->publish_state(s);
}
void AP2Hub::publish_scan_active_(bool active) {
if (this->scan_active_sensor_ != nullptr)
this->scan_active_sensor_->publish_state(active);
}
} // namespace ap2_hub
} // namespace esphome
+225
View File
@@ -0,0 +1,225 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h" // fnv1_hash
#include "esphome/core/preferences.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/components/sensor/sensor.h"
#include "ap2_protocol.h"
#include <esp_gattc_api.h>
#include <string>
#include <vector>
namespace esphome {
namespace ap2_hub {
class AP2ProbeClient;
class AP2Fan;
/* M9033 filter-life fields, in reply order: H I J K L S. */
enum AP2Filter { FILT_PRE = 0, FILT_MEDIUM, FILT_CARBON, FILT_CARBON_CLOTH, FILT_FORMALDEHYDE, FILT_HEPA, FILT_COUNT };
/* One AP2 connection slot: a self-managed BLE client targeting a runtime-settable,
* flash-persisted MAC. On connect it discovers the Nordic-UART service, subscribes,
* handshakes with M99, and accepts M9039 gear commands. Publishes its connection
* state to an optional `connected` binary_sensor and `status` text_sensor. */
class AP2Slot : public esp32_ble_client::BLEClientBase {
public:
explicit AP2Slot(uint8_t index) { this->index_ = index; }
void setup() override;
void loop() override;
void dump_config() override;
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void set_state(espbt::ClientState st) override;
/* runtime MAC: "" or invalid clears the slot; valid 6-byte MAC retargets + persists */
void set_mac_str(const std::string &mac);
std::string mac_str() const;
/* gear: 0 = off, 1..4 = speed. Ignored unless the slot is ready. */
void set_gear(uint8_t gear);
bool is_ready() const { return this->ready_ && !this->scanning_; }
/* Refresh the bonded indicator. Driven by the hub's loop (the slot's own loop
* is disabled by BLEClientBase while idle, so it can't do this itself). */
void update_bonded();
/* Suspend control while a scan is running (frees the radio for the prober). */
void set_scanning(bool scanning);
/* Actively attempt a (re)connection if idle — a direct connect, which catches
* directed advertising from a bonded AP2 that passive auto-connect can miss. */
void try_reconnect();
void set_connected_sensor(binary_sensor::BinarySensor *b) { this->connected_sensor_ = b; }
void set_status_sensor(text_sensor::TextSensor *t) { this->status_sensor_ = t; }
void set_bonded_sensor(binary_sensor::BinarySensor *b) { this->bonded_sensor_ = b; }
void set_filter_sensor(uint8_t which, sensor::Sensor *s) {
if (which < FILT_COUNT) this->filter_sensors_[which] = s;
}
void set_serial_sensor(text_sensor::TextSensor *t) { this->serial_sensor_ = t; }
void set_firmware_sensor(text_sensor::TextSensor *t) { this->firmware_sensor_ = t; }
void set_fan(AP2Fan *f) { this->fan_ = f; }
void toggle_buzzer(); // flip the (write-only) buzzer state and send M9046
protected:
void send_cmd_(const char *cmd);
void apply_mac_(uint64_t mac, bool persist);
void publish_status_();
void parse_m9033_(const std::string &s);
uint8_t index_{0};
uint64_t mac_{0};
ESPPreferenceObject mac_pref_;
std::string fw_;
uint16_t rx_handle_{0}; // 6e400002 (write)
uint16_t tx_handle_{0}; // 6e400003 (notify)
uint16_t ccc_handle_{0}; // TX CCC descriptor (0x2902)
uint8_t seq_{0};
bool ready_{false};
bool scanning_{false};
bool bond_tried_{false}; // request bonding once per connection
binary_sensor::BinarySensor *connected_sensor_{nullptr};
text_sensor::TextSensor *status_sensor_{nullptr};
binary_sensor::BinarySensor *bonded_sensor_{nullptr};
sensor::Sensor *filter_sensors_[FILT_COUNT]{};
text_sensor::TextSensor *serial_sensor_{nullptr};
text_sensor::TextSensor *firmware_sensor_{nullptr};
AP2Fan *fan_{nullptr};
bool buzzer_on_{false};
std::string last_serial_;
std::string last_status_;
bool last_connected_{false};
bool status_inited_{false};
bool last_bonded_{false};
bool bonded_inited_{false};
uint32_t last_status_poll_ms_{0}; // M9033 status poll
};
/* Owns the slots and the scan prober. Acts as a BLE advertisement listener so it
* can collect candidate devices, then drives the prober through them one at a
* time to find AP2s (nameless on-air — only an M99 round-trip confirms them). */
class AP2Hub : public Component, public espbt::ESPBTDeviceListener {
public:
void dump_config() override;
void loop() override;
void add_slot(AP2Slot *s) { this->slots_.push_back(s); }
AP2Slot *get_slot(uint8_t i) { return i < this->slots_.size() ? this->slots_[i] : nullptr; }
void set_prober(AP2ProbeClient *p) { this->prober_ = p; }
/* Remove all stored BLE bonds (forces a fresh pair next time). */
void clear_bonds();
/* Force an immediate reconnect attempt on every slot (manual trigger). */
void reconnect_all();
/* --- slot entity binders / control (used by the platform files) --- */
void set_connected_sensor(uint8_t i, binary_sensor::BinarySensor *b) {
if (auto *s = this->get_slot(i)) s->set_connected_sensor(b);
}
void set_status_sensor(uint8_t i, text_sensor::TextSensor *t) {
if (auto *s = this->get_slot(i)) s->set_status_sensor(t);
}
void set_bonded_sensor(uint8_t i, binary_sensor::BinarySensor *b) {
if (auto *s = this->get_slot(i)) s->set_bonded_sensor(b);
}
void set_filter_sensor(uint8_t i, uint8_t which, sensor::Sensor *s) {
if (auto *sl = this->get_slot(i)) sl->set_filter_sensor(which, s);
}
void set_serial_sensor(uint8_t i, text_sensor::TextSensor *t) {
if (auto *s = this->get_slot(i)) s->set_serial_sensor(t);
}
void set_firmware_sensor(uint8_t i, text_sensor::TextSensor *t) {
if (auto *s = this->get_slot(i)) s->set_firmware_sensor(t);
}
void set_fan(uint8_t i, AP2Fan *f) {
if (auto *s = this->get_slot(i)) s->set_fan(f);
}
void buzzer_toggle(uint8_t i) {
if (auto *s = this->get_slot(i)) s->toggle_buzzer();
}
void set_slot_mac(uint8_t i, const std::string &m) {
if (auto *s = this->get_slot(i)) s->set_mac_str(m);
}
std::string slot_mac_str(uint8_t i) {
auto *s = this->get_slot(i);
return s != nullptr ? s->mac_str() : std::string();
}
void set_gear(uint8_t i, uint8_t g) {
if (auto *s = this->get_slot(i)) s->set_gear(g);
}
bool slot_ready(uint8_t i) {
auto *s = this->get_slot(i);
return s != nullptr && s->is_ready();
}
/* --- scan API (start_scan / duration are called from YAML lambdas) --- */
void start_scan();
void toggle_scan(); // start, or cancel if already running
void set_scan_duration(uint16_t seconds) { this->scan_duration_s_ = seconds; }
bool is_scanning() const { return this->scan_state_ != ScanState::IDLE; }
std::string scan_status() const { return this->scan_status_; }
std::string devices_found_json() const { return this->devices_json_; }
/* called by the prober when a probe attempt completes */
void probe_finished(uint64_t mac, bool confirmed, const std::string &fw);
/* ESPBTDeviceListener: collect candidates while scanning */
bool parse_device(const espbt::ESPBTDevice &device) override;
void set_scan_active_sensor(binary_sensor::BinarySensor *b) { this->scan_active_sensor_ = b; }
void set_scan_status_sensor(text_sensor::TextSensor *t) { this->scan_status_sensor_ = t; }
void set_devices_found_sensor(text_sensor::TextSensor *t) { this->devices_found_sensor_ = t; }
protected:
enum class ScanState { IDLE, COLLECTING, PROBING };
void begin_probing_();
void probe_next_();
void finish_scan_(const char *verb = "done");
void try_adopt_(uint64_t mac);
void publish_devices_();
void set_scan_status_(const std::string &s);
void publish_scan_active_(bool active);
std::vector<AP2Slot *> slots_;
AP2ProbeClient *prober_{nullptr};
uint32_t last_bond_poll_ms_{0};
uint32_t last_reconnect_ms_{0};
ScanState scan_state_{ScanState::IDLE};
uint16_t scan_duration_s_{90};
uint32_t scan_start_ms_{0};
struct Cand {
uint64_t mac;
esp_ble_addr_type_t type;
int rssi;
};
struct Found {
uint64_t mac;
std::string fw;
int rssi;
};
std::vector<Cand> cands_;
std::vector<Found> found_;
size_t probe_idx_{0};
std::string scan_status_;
std::string devices_json_{"[]"};
binary_sensor::BinarySensor *scan_active_sensor_{nullptr};
text_sensor::TextSensor *scan_status_sensor_{nullptr};
text_sensor::TextSensor *devices_found_sensor_{nullptr};
};
} // namespace ap2_hub
} // namespace esphome
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text/text.h"
#include "ap2_hub.h"
namespace esphome {
namespace ap2_hub {
/* HA text input for a slot's MAC. Writing a value retargets+persists the slot;
* an empty value clears it. Publishes the restored MAC back to HA on boot. */
class AP2MacText : public text::Text, public Component {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
void loop() override {
if (this->published_ || this->hub_ == nullptr || this->hub_->get_slot(this->slot_) == nullptr)
return;
this->publish_state(this->hub_->slot_mac_str(this->slot_));
this->published_ = true;
}
protected:
void control(const std::string &value) override {
if (this->hub_ != nullptr) {
this->hub_->set_slot_mac(this->slot_, value);
this->publish_state(this->hub_->slot_mac_str(this->slot_)); // normalized / cleared
} else {
this->publish_state(value);
}
}
AP2Hub *hub_{nullptr};
uint8_t slot_{0};
bool published_{false};
};
} // namespace ap2_hub
} // namespace esphome
+96
View File
@@ -0,0 +1,96 @@
#include "ap2_probe.h"
#include "ap2_hub.h"
#include "esphome/core/log.h"
namespace esphome {
namespace ap2_hub {
static const char *const TAG = "ap2_hub.probe";
void AP2ProbeClient::start_probe(uint64_t mac, esp_ble_addr_type_t type) {
this->active_ = true;
this->confirmed_ = false;
this->fw_.clear();
this->rx_handle_ = this->tx_handle_ = this->ccc_handle_ = 0;
this->set_address(mac);
this->set_remote_addr_type(type);
this->connect();
}
void AP2ProbeClient::abort_probe() {
if (!this->active_)
return;
this->disconnect(); // -> ClientState::IDLE -> set_state() -> hub->probe_finished()
}
void AP2ProbeClient::set_state(espbt::ClientState st) {
BLEClientBase::set_state(st);
// IDLE while a probe is active means the attempt is over (confirmed, no-NUS,
// failed connect, or aborted) and the client is free again.
if (st == espbt::ClientState::IDLE && this->active_) {
this->active_ = false;
if (this->hub_ != nullptr)
this->hub_->probe_finished(this->get_address(), this->confirmed_, this->fw_);
}
}
bool AP2ProbeClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (!BLEClientBase::gattc_event_handler(event, gattc_if, param))
return false;
if (!this->active_)
return true;
switch (event) {
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *rx = this->get_characteristic(nus_svc(), nus_rx());
auto *tx = this->get_characteristic(nus_svc(), nus_tx());
if (rx == nullptr || tx == nullptr) {
ESP_LOGD(TAG, "%s: no Nordic UART — not an AP2", this->address_str());
this->disconnect();
break;
}
this->rx_handle_ = rx->handle;
this->tx_handle_ = tx->handle;
auto *ccc = this->get_config_descriptor(this->tx_handle_);
this->ccc_handle_ = (ccc != nullptr) ? ccc->handle : 0;
esp_ble_gattc_register_for_notify(gattc_if, this->get_remote_bda(), this->tx_handle_);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
if (this->ccc_handle_ != 0) {
uint16_t notify_en = 0x0001;
esp_ble_gattc_write_char_descr(gattc_if, this->get_conn_id(), this->ccc_handle_,
sizeof(notify_en), (uint8_t *) &notify_en,
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
}
uint8_t f[64];
uint8_t n = ap2_build_frame("M99", 0, f);
esp_ble_gattc_write_char((esp_gatt_if_t) this->get_gattc_if(), this->get_conn_id(),
this->rx_handle_, n, f, ESP_GATT_WRITE_TYPE_RSP,
ESP_GATT_AUTH_REQ_NONE);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle == this->tx_handle_) {
std::string fw = ap2_parse_m99_fw(ap2_ascii(param->notify.value, param->notify.value_len));
if (!fw.empty()) {
this->confirmed_ = true;
this->fw_ = fw;
ESP_LOGD(TAG, "%s: M99 confirmed V%s", this->address_str(), fw.c_str());
this->disconnect();
}
}
break;
}
default:
break;
}
return true;
}
} // namespace ap2_hub
} // namespace esphome
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "ap2_protocol.h"
#include <esp_gattc_api.h>
#include <string>
namespace esphome {
namespace ap2_hub {
class AP2Hub;
/* Transient BLE client the hub uses during a scan: connect to one candidate,
* look for the Nordic-UART service, send M99, and confirm an AP2 by its reply.
* Every probe ends at ClientState::IDLE, which is the single signal back to the
* hub that the prober is free for the next candidate. */
class AP2ProbeClient : public esp32_ble_client::BLEClientBase {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void start_probe(uint64_t mac, esp_ble_addr_type_t type);
void abort_probe();
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void set_state(espbt::ClientState st) override;
protected:
AP2Hub *hub_{nullptr};
uint16_t rx_handle_{0};
uint16_t tx_handle_{0};
uint16_t ccc_handle_{0};
bool active_{false};
bool confirmed_{false};
std::string fw_;
};
} // namespace ap2_hub
} // namespace esphome
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <cstdint>
#include <cstdio>
#include <string>
namespace esphome {
namespace ap2_hub {
namespace espbt = esphome::esp32_ble_tracker;
/* xTool AP2 Nordic-UART service + the F0F7 / M-code framing, shared by the
* control slots and the scan prober. See SPEC.md for the full protocol. */
inline espbt::ESPBTUUID nus_svc() {
return espbt::ESPBTUUID::from_raw("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
}
inline espbt::ESPBTUUID nus_rx() {
return espbt::ESPBTUUID::from_raw("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
}
inline espbt::ESPBTUUID nus_tx() {
return espbt::ESPBTUUID::from_raw("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
}
/* Build an F0F7 frame for an ASCII M-code into f (>= 64 bytes). Returns length. */
inline uint8_t ap2_build_frame(const char *cmd, uint8_t seq, uint8_t *f) {
static const uint8_t AP2_ID[3] = {0x45, 0x73, 0x60}; // PURIFIER_V2 device id/prefix
uint8_t i = 0, sum = 0, start;
f[i++] = 0xF0;
start = i;
f[i++] = AP2_ID[0];
f[i++] = AP2_ID[1];
f[i++] = AP2_ID[2];
f[i++] = 0x01;
f[i++] = seq;
for (const char *p = cmd; *p != '\0'; ++p)
f[i++] = (uint8_t) *p;
f[i++] = 0x0A;
for (uint8_t j = start; j < i; j++)
sum += f[j];
f[i++] = sum & 0x7F;
f[i++] = 0xF7;
return i;
}
/* Printable-ASCII view of a notification payload. */
inline std::string ap2_ascii(const uint8_t *data, uint16_t len) {
std::string s;
s.reserve(len);
for (uint16_t k = 0; k < len; k++) {
uint8_t c = data[k];
if (c >= 32 && c < 127)
s += (char) c;
}
return s;
}
/* If s is an M99 reply, return the firmware string (after 'V', up to a space),
* else "". A non-empty result is the definitive "this is an AP2" fingerprint. */
inline std::string ap2_parse_m99_fw(const std::string &s) {
auto p = s.find("M99");
if (p == std::string::npos)
return "";
auto v = s.find('V', p);
if (v == std::string::npos)
return "";
auto e = s.find(' ', v);
return s.substr(v + 1, e == std::string::npos ? std::string::npos : e - (v + 1));
}
/* 0xAABBCCDDEEFF -> "AA:BB:CC:DD:EE:FF"; 0 -> "". */
inline std::string ap2_format_mac(uint64_t mac) {
if (mac == 0)
return "";
char b[18];
snprintf(b, sizeof(b), "%02X:%02X:%02X:%02X:%02X:%02X", (unsigned) ((mac >> 40) & 0xFF),
(unsigned) ((mac >> 32) & 0xFF), (unsigned) ((mac >> 24) & 0xFF),
(unsigned) ((mac >> 16) & 0xFF), (unsigned) ((mac >> 8) & 0xFF), (unsigned) (mac & 0xFF));
return b;
}
} // namespace ap2_hub
} // namespace esphome
+54
View File
@@ -0,0 +1,54 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import binary_sensor
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# "connected" / "bonded" are per-slot; "scan_active" is a hub-level flag.
TYPES = ["connected", "bonded", "scan_active"]
_SLOT_TYPES = ("connected", "bonded")
def _validate(config):
has_slot = CONF_SLOT in config
t = config.get(CONF_TYPE)
if t is None:
t = "connected" if has_slot else None
if t is None:
raise cv.Invalid(
"set 'type: scan_active', or give a 'slot' for a connected/bonded sensor"
)
config[CONF_TYPE] = t
if t in _SLOT_TYPES and not has_slot:
raise cv.Invalid(f"type '{t}' requires a 'slot'")
if t == "scan_active" and has_slot:
raise cv.Invalid("type 'scan_active' must not have a 'slot'")
return config
CONFIG_SCHEMA = cv.All(
binary_sensor.binary_sensor_schema().extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Optional(CONF_TYPE): cv.one_of(*TYPES, lower=True),
cv.Optional(CONF_SLOT): cv.int_range(min=0, max=1),
}
),
_validate,
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
t = config[CONF_TYPE]
if t == "connected":
cg.add(parent.set_connected_sensor(config[CONF_SLOT], var))
elif t == "bonded":
cg.add(parent.set_bonded_sensor(config[CONF_SLOT], var))
else:
cg.add(parent.set_scan_active_sensor(var))
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import fan
from . import AP2Hub, ap2_hub_ns
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
AP2Fan = ap2_hub_ns.class_("AP2Fan", fan.Fan, cg.Component)
CONFIG_SCHEMA = fan.fan_schema(AP2Fan).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = await fan.new_fan(config)
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(var.set_hub(parent))
cg.add(var.set_slot(config[CONF_SLOT]))
# Let the slot push live gear updates (from M9033) into this fan.
cg.add(parent.set_fan(config[CONF_SLOT], var))
+38
View File
@@ -0,0 +1,38 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor
from esphome.const import UNIT_PERCENT
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# Filter-life types -> index into AP2Filter (M9033 fields H I J K L S).
FILTERS = {
"pre_filter": 0,
"medium_filter": 1,
"activated_carbon": 2,
"carbon_cloth": 3,
"formaldehyde": 4,
"hepa": 5,
}
CONFIG_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
icon="mdi:air-filter",
).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
cv.Required(CONF_TYPE): cv.one_of(*FILTERS, lower=True),
}
)
async def to_code(config):
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(parent.set_filter_sensor(config[CONF_SLOT], FILTERS[config[CONF_TYPE]], var))
+26
View File
@@ -0,0 +1,26 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text
from . import AP2Hub, ap2_hub_ns
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
AP2MacText = ap2_hub_ns.class_("AP2MacText", text.Text, cg.Component)
# "AA:BB:CC:DD:EE:FF" is 17 chars; allow empty to clear.
CONFIG_SCHEMA = text.text_schema(AP2MacText).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = await text.new_text(config, min_length=0, max_length=17)
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(var.set_hub(parent))
cg.add(var.set_slot(config[CONF_SLOT]))
+58
View File
@@ -0,0 +1,58 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text_sensor
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# "status" / "serial" / "firmware" are per-slot; "scan_status" and "devices_found" are hub-level.
TYPES = ["status", "serial", "firmware", "scan_status", "devices_found"]
_SLOT_TYPES = ("status", "serial", "firmware")
def _validate(config):
has_slot = CONF_SLOT in config
t = config.get(CONF_TYPE)
if t is None:
t = "status" if has_slot else None
if t is None:
raise cv.Invalid(
"set 'type: scan_status'/'devices_found', or give a 'slot' for a status/serial sensor"
)
config[CONF_TYPE] = t
if t in _SLOT_TYPES and not has_slot:
raise cv.Invalid(f"type '{t}' requires a 'slot'")
if t not in _SLOT_TYPES and has_slot:
raise cv.Invalid(f"type '{t}' must not have a 'slot'")
return config
CONFIG_SCHEMA = cv.All(
text_sensor.text_sensor_schema().extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Optional(CONF_TYPE): cv.one_of(*TYPES, lower=True),
cv.Optional(CONF_SLOT): cv.int_range(min=0, max=1),
}
),
_validate,
)
async def to_code(config):
var = await text_sensor.new_text_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
t = config[CONF_TYPE]
if t == "status":
cg.add(parent.set_status_sensor(config[CONF_SLOT], var))
elif t == "serial":
cg.add(parent.set_serial_sensor(config[CONF_SLOT], var))
elif t == "firmware":
cg.add(parent.set_firmware_sensor(config[CONF_SLOT], var))
elif t == "scan_status":
cg.add(parent.set_scan_status_sensor(var))
else:
cg.add(parent.set_devices_found_sensor(var))
+3
View File
@@ -0,0 +1,3 @@
# Copy this to `secrets.yaml` (which is gitignored) and fill in your values.
wifi_ssid: "YourWiFiSSID"
wifi_password: "YourWiFiPassword"
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
xTool SafetyPro AP2 — BLE control (F0F7 / M-code protocol).
Frame: 0xF0 | id0 id1 id2 | 0x01 | seq | <ASCII M-code> | 0x0A | (sum(body)&0x7F) | 0xF7
body = id[3] + 0x01 + seq + cmd + 0x0A ; checksum over body (0xF0 excluded)
seq = 1-byte counter from 0, +1 per frame.
AP2 V2 id = 45 73 60 (V3/Max = 4C 73 6B).
CONTROL COMMANDS (AP2 V2 / PURIFIER_V2), confirmed by firmware RE + live power meter:
set speed/gear : M9039 A<n> n=0 off, 1..4 = speeds (A1~46W, A2~85W, A3~110W, A4~150W max)
identify : M99 status : M9033 buzzer : M9046 F0 / F1
(auto mode M9039 D1 C<n> exists per the V3 community repo; untested on V2)
Transport: AP2 advertises connectably whenever powered (no pairing/bonding needed);
NUS RX 6e400002 write, TX 6e400003 notify.
Modes:
frames print example frames (offline)
set <n> / fan <n> set gear A<n> (0=off..4=max), watch power
sweep step A1..A4 then off, watch power curve
off M9039 A0
status M99 + M9033
"""
import asyncio
import json
import os
import sys
import time
import urllib.request
from bleak import BleakClient, BleakScanner
# Set these for your setup via env vars (or a .env you source); the committed
# defaults are dummies. AP2_ADDR is your AP2's BLE MAC (run `find` to discover it).
SHELLY = os.environ.get("AP2_SHELLY_IP", "192.168.1.50")
AP2_ADDR = os.environ.get("AP2_ADDR", "AA:BB:CC:DD:EE:FF")
NUS = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
PREFIX = bytes([0x45, 0x73, 0x60]) # PURIFIER_V2 (AP2)
def build_frame(cmd: str, seq: int = 0, prefix: bytes = PREFIX) -> bytes:
body = bytearray(prefix)
body += bytes([0x01, seq & 0xFF])
body += cmd.encode("ascii")
body.append(0x0A)
chk = sum(body) & 0x7F
return bytes([0xF0]) + bytes(body) + bytes([chk, 0xF7])
def asc(b):
return "".join(chr(c) if 32 <= c < 127 else "." for c in b)
def shelly_power():
try:
d = json.loads(urllib.request.urlopen(f"http://{SHELLY}/sensor/power", timeout=4).read())
return float(d.get("value", 0.0) or 0.0)
except Exception:
return -1.0
def shelly_switch(on: bool):
a = "turn_on" if on else "turn_off"
try:
req = urllib.request.Request(f"http://{SHELLY}/switch/Relay/{a}", data=b"", method="POST")
urllib.request.urlopen(req, timeout=5).read()
return True
except Exception as e: # noqa: BLE001
print(f" shelly {a} failed: {e}"); return False
async def watch_power(label, secs=24.0):
print(f" [power] watching {secs:.0f}s ({label}); fan ON ~= >100W")
t0 = time.time(); hi = 0.0
while time.time() - t0 < secs:
p = shelly_power(); hi = max(hi, p)
print(f" +{time.time()-t0:4.0f}s {p:6.1f} W")
await asyncio.sleep(2.5)
print(f" [power] peak {hi:.1f} W")
return hi
async def find_ap2():
dev = await BleakScanner.find_device_by_address(AP2_ADDR, timeout=10.0)
if dev:
return dev
found = await BleakScanner.discover(timeout=8.0, return_adv=True)
for d, adv in sorted(found.values(), key=lambda kv: kv[1].rssi or -999, reverse=True):
if not (adv.local_name or d.name):
return d
return None
def _mk_send(client, notifs):
st = {"seq": 0}
async def send(cmd, wait=1.5):
f = build_frame(cmd, st["seq"])
nb = len(notifs)
try:
await client.write_gatt_char(RX, f, response=True)
tag = "ACK"
except Exception as e: # noqa: BLE001
tag = f"ERR {type(e).__name__}"
print(f" -> seq{st['seq']:<3} {cmd:<12} {f.hex(' ')} [{tag}]")
st["seq"] = st["seq"] + 1 if st["seq"] < 0x7E else 1
await asyncio.sleep(wait)
return len(notifs) > nb
return send
def _mk_cb(notifs):
def cb(_, data):
b = bytes(data)
notifs.append((time.time(), b))
print(f" <- NOTIFY {b.hex(' ')} |{asc(b)}|")
return cb
async def persist_test():
print("=== PERSISTENCE TEST (bond -> power-cycle -> reconnect w/o button) ===")
print("Phase 1: connect in pairing mode and BOND")
dev = await find_ap2()
if not dev:
print("!! AP2 not found. Hold power 5s (link LED blinking) first."); return 1
notifs = []
async with BleakClient(dev, timeout=15.0) as c:
if NUS not in [s.uuid.lower() for s in c.services]:
print("!! not the AP2."); return 1
await c.start_notify(TX, _mk_cb(notifs))
try:
await c.pair()
print(" pair() returned (WinRT reports paired/bonded)")
except Exception as e: # noqa: BLE001
print(f" pair() failed: {type(e).__name__}: {e}")
send = _mk_send(c, notifs)
await send("M99")
await send("M9039 A1"); await send("M9039 W2") # brief on, proves bonded control
await asyncio.sleep(2)
await send("M9039 W0"); await send("M9039 A0")
await c.stop_notify(TX)
print(" Phase 1 done (disconnected). Bond should be saved on the AP2.\n")
print("Phase 2: power-cycle the AP2 via Shelly (simulates normal power-up, NO button)")
shelly_switch(False); print(" power OFF"); await asyncio.sleep(8)
shelly_switch(True); print(" power ON; waiting ~16s for boot..."); await asyncio.sleep(16)
print("\nPhase 3: try to reconnect WITHOUT pressing the pairing button")
dev2 = await BleakScanner.find_device_by_address(AP2_ADDR, timeout=25.0)
if not dev2:
print(" >> AP2 is NOT advertising after power-cycle (no button).")
print(" => WinRT bond did not enable auto-advertise. We'll need a proper bond")
print(" from a real BLE stack (nRF5340 DK / ESP32) or directed-adv reconnect.")
return 0
print(f" >> AP2 IS advertising without the button! ({dev2.address}) connecting...")
notifs2 = []
async with BleakClient(dev2, timeout=15.0) as c2:
await c2.start_notify(TX, _mk_cb(notifs2))
s2 = _mk_send(c2, notifs2)
got = await s2("M99")
await s2("M9039 A1"); await s2("M9039 W3")
await watch_power("reconnect W3", 18)
await s2("M9039 W0"); await s2("M9039 A0")
await c2.stop_notify(TX)
print("\n *** PERSISTENCE CONFIRMED: controlled the AP2 after power-cycle, no button! ***"
if notifs2 else "\n reconnected but no telemetry — check power trace above.")
return 0
async def find_mode():
"""Discover the AP2's MAC: it's a blank-name device exposing Nordic UART."""
print("Scanning for the AP2 (blank device exposing Nordic UART)...")
found = await BleakScanner.discover(timeout=8.0, return_adv=True)
cands = [(d, a) for d, a in found.values() if not (a.local_name or d.name)]
cands.sort(key=lambda x: x[1].rssi if x[1].rssi is not None else -999, reverse=True)
for d, a in cands[:10]:
try:
async with BleakClient(d, timeout=8.0) as c:
if NUS in [s.uuid.lower() for s in c.services]:
print(f"\n FOUND AP2: {d.address} (RSSI {a.rssi})")
print(" -> use this as ble_client mac_address in your ESPHome YAML")
return 0
except Exception:
continue
print(" AP2 not found — make sure it is powered and within range.")
return 1
async def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "set"
if mode == "find":
return await find_mode()
if mode == "persist":
return await persist_test()
arg = sys.argv[2] if len(sys.argv) > 2 else None
if mode == "frames":
for c in ["M99", "M9033", "M9039 A0", "M9039 A1", "M9039 A2",
"M9039 A3", "M9039 A4"]:
print(f"{c:<12}: {build_frame(c).hex(' ')}")
return
print("Finding AP2 (must be in pairing mode)...")
dev = await find_ap2()
if not dev:
print("!! AP2 not found. Hold power 5s (link LED blinking) and retry.")
return 1
notifs = []
def cb(_, data):
b = bytes(data)
notifs.append((time.time(), b))
print(f" <- NOTIFY {b.hex(' ')} |{asc(b)}|")
async with BleakClient(dev, timeout=15.0) as c:
print(f"connected {c.is_connected}")
if NUS not in [s.uuid.lower() for s in c.services]:
print("!! no Nordic UART — not the AP2."); return 1
await c.start_notify(TX, cb)
print(" notify enabled")
seq = 0
async def send(cmd, wait=1.5):
nonlocal seq
f = build_frame(cmd, seq)
nb = len(notifs)
try:
await c.write_gatt_char(RX, f, response=True)
print(f" -> seq{seq:<3} {cmd:<12} {f.hex(' ')} [ACK]")
except Exception as e: # noqa: BLE001
print(f" -> seq{seq:<3} {cmd:<12} {f.hex(' ')} WRITE-ERR {type(e).__name__}: {e}")
seq = (seq + 1) if seq < 0x7E else 1
await asyncio.sleep(wait)
return len(notifs) > nb
# handshake (resend M99 until reply)
for _ in range(3):
if await send("M99", 1.5):
print(" >> M99 reply — online"); break
else:
print(" !! no M99 reply")
n = (arg or "4")
if mode in ("fan", "set"): # set gear A<n> (0=off, 1..4)
await send(f"M9039 A{n}")
await watch_power(f"gear A{n}", 26)
elif mode == "sweep": # step through all speeds
for g in [1, 2, 3, 4]:
await send(f"M9039 A{g}")
await watch_power(f"gear A{g}", 16)
await send("M9039 A0")
elif mode == "off":
await send("M9039 A0")
await watch_power("off", 14)
elif mode == "status":
await send("M9033", 2.0)
await asyncio.sleep(1.0)
await c.stop_notify(TX)
print(f"\nDone. {len(notifs)} notification(s).")
return 0
class _Tee:
def __init__(self, *s): self.s = s
def write(self, x):
for st in self.s: st.write(x); st.flush()
def flush(self):
for st in self.s: st.flush()
if __name__ == "__main__":
os.makedirs("logs", exist_ok=True)
_logf = open(os.path.join("logs", "ap2_ble.log"), "a", encoding="utf-8")
_logf.write(f"\n===== run {time.strftime('%Y-%m-%d %H:%M:%S')} args={sys.argv[1:]} =====\n")
sys.stdout = _Tee(sys.__stdout__, _logf)
sys.exit(asyncio.run(main()) or 0)