From 878f7c9c27b6b0f44b2c152ccd005497c40ae998 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Mon, 22 Jun 2026 14:45:11 +0200 Subject: [PATCH] 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 --- .gitattributes | 2 + .gitignore | 9 + CLAUDE.md | 1 + LICENSE | 21 + README.md | 340 ++++++++++++++ SPEC.md | 125 ++++++ ap2-hub.example.yaml | 252 +++++++++++ components/ap2_hub/__init__.py | 61 +++ components/ap2_hub/ap2_fan.cpp | 34 ++ components/ap2_hub/ap2_fan.h | 45 ++ components/ap2_hub/ap2_hub.cpp | 660 ++++++++++++++++++++++++++++ components/ap2_hub/ap2_hub.h | 225 ++++++++++ components/ap2_hub/ap2_mac_text.h | 40 ++ components/ap2_hub/ap2_probe.cpp | 96 ++++ components/ap2_hub/ap2_probe.h | 40 ++ components/ap2_hub/ap2_protocol.h | 85 ++++ components/ap2_hub/binary_sensor.py | 54 +++ components/ap2_hub/fan.py | 27 ++ components/ap2_hub/sensor.py | 38 ++ components/ap2_hub/text.py | 26 ++ components/ap2_hub/text_sensor.py | 58 +++ secrets.yaml.example | 3 + tools/ap2_ble.py | 281 ++++++++++++ 23 files changed, 2523 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 ap2-hub.example.yaml create mode 100644 components/ap2_hub/__init__.py create mode 100644 components/ap2_hub/ap2_fan.cpp create mode 100644 components/ap2_hub/ap2_fan.h create mode 100644 components/ap2_hub/ap2_hub.cpp create mode 100644 components/ap2_hub/ap2_hub.h create mode 100644 components/ap2_hub/ap2_mac_text.h create mode 100644 components/ap2_hub/ap2_probe.cpp create mode 100644 components/ap2_hub/ap2_probe.h create mode 100644 components/ap2_hub/ap2_protocol.h create mode 100644 components/ap2_hub/binary_sensor.py create mode 100644 components/ap2_hub/fan.py create mode 100644 components/ap2_hub/sensor.py create mode 100644 components/ap2_hub/text.py create mode 100644 components/ap2_hub/text_sensor.py create mode 100644 secrets.yaml.example create mode 100644 tools/ap2_ble.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b18437 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize line endings to LF in the repo (this component runs on Linux / HA). +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e91cec4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.claude/settings.local.json +__pycache__/ +*.pyc +secrets.yaml +.env +.esphome/ +.pioenvs/ +build/ +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0c01dca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +keep SPEC.md and README.md in sync when making changes \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..17eb613 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb40ca4 --- /dev/null +++ b/README.md @@ -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 1–4 + (`A1`≈46 W … `A4`≈150 W). +- **Runtime-settable MACs** — type each AP2's address into HA (or let the scanner fill it + in); it's **persisted to flash**, so no reflash and no recompile to assign a unit. Clearing + the field unassigns the slot. +- **On-device "Scan for AP2"** — discovers AP2s in pairing mode and **auto-adopts** each into + a free slot. Reports every find (MAC + firmware + RSSI) to HA live. +- **Pair once, then mostly hands-off.** The AP2 only advertises in pairing mode, so the ESP32 + **bonds** with it on the first pairing; thereafter it normally re-advertises on power-up and + each slot **auto-reconnects** after a power-cycle — no button again (occasionally a quick + re-pair is needed; see [Quick start](#quick-start-esp32--esphome)). +- **Live status** — fan gear, six filter-life %s, serial and firmware, polled from the AP2. +- One ESPHome external component (`ap2_hub`) — drop-in for any ESP32. + +> Works with the **AP2 V2** (`PURIFIER_V2`). The AP2 **Max / V3** uses a slightly different +> speed command (see [SPEC.md](SPEC.md)); adapting the component is trivial. + +## Quick start (ESP32 + ESPHome) + +1. Put `wifi_ssid` / `wifi_password` in your ESPHome `secrets.yaml`. +2. Make the component available in your device YAML — either keep this repo's `components/` + next to your YAML (`type: local`), or pull it from git: + ```yaml + external_components: + - source: github://YOURUSER/esphome-xtool-ap2@main + components: [ap2_hub] + ``` +3. **Add the config.** Drop in the BLE **base** plus one block from + [Configuration](#configuration) below — *minimal one-AP2*, *full one-AP2*, or *two-AP2*. For + a complete, ready-to-run file just copy [`ap2-hub.example.yaml`](ap2-hub.example.yaml). +4. `esphome run your-device.yaml` and adopt it in Home Assistant. +5. **Assign + bond your AP2(s).** Put the AP2 in **pairing mode** (hold its power button ~5 s, + link LED blinking) — it only advertises in this state. Then either: + - press **`Purifier Scan`** in HA — it discovers the AP2 and drops it into a free slot; or + - paste a MAC into **`Purifier 1 MAC`** / **`Purifier 2 MAC`** by hand. + + The ESP32 connects and **bonds** automatically. After this one-time pairing, the AP2 + normally re-advertises on power-up and the slot reconnects on its own — no button again + (occasionally the AP2 drops its bond and needs a quick re-pair; see below). Each slot's fan + becomes controllable once **`Purifier N BLE Status`** reads `connected`. + +That's it — you'll have `Purifier 1` (and optionally `Purifier 2`) fans in HA. To forget a +bond and re-pair from scratch, press **`Purifier Clear Bonds`**. + +> **If a purifier stops reconnecting on its own**, the AP2 has dropped _its_ half of the bond +> (a quirk of xTool's firmware, especially after the ESP32 itself reboots). You can spot it at +> the unit: the **link LED no longer stays solid**. Just hold the **power button ~6 s** (back +> into pairing mode) and it re-pairs immediately — the ESP32 reconnects on its own, and the +> **`Purifier Reconnect`** button speeds it up. The bond/MAC stored on the ESP32 aren't lost, +> so there's nothing to reconfigure. + +## Configuration + +Each setup is the **base** (BLE + hub) plus **one** option block. Paste the base, then one +option, into a normal ESPHome device config (the usual `esp32:` with `framework: type: esp-idf`, +`wifi:`, `api:`, `ota:`, `logger:` …) — all entity names are yours to change. Leaving unused +`slot: 1` entities in is harmless; the second slot simply stays `unset` and idle. + +### Base — required by all three + +```yaml +esp32_ble: # bond with the AP2 so it reconnects after a power-cycle + io_capability: none + auth_req_mode: bond +esp32_ble_tracker: # required — slots use it to find + connect to the AP2 + +ap2_hub: + id: ap2 +``` + +### Option 1 — minimal: one AP2 + a usable scanner + +The smallest config that controls one purifier **and** makes the scanner usable (without the +status/results entities you'd press Scan blind): + +```yaml +text: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 MAC" + mode: text + +fan: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1" + +binary_sensor: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 BLE Connected" + device_class: connectivity + +text_sensor: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 BLE Status" + - platform: ap2_hub # scan progress + ap2_hub_id: ap2 + type: scan_status + name: "Purifier Scan Status" + - platform: ap2_hub # discovered AP2s, JSON [{mac,fw,rssi}] + ap2_hub_id: ap2 + type: devices_found + name: "Purifier Devices Found" + +button: + - platform: template + name: "Purifier Scan" # press with the AP2 in pairing mode + on_press: + - lambda: "id(ap2).toggle_scan();" +``` + +### Option 2 — full: one AP2, every feature + +Option 1 plus the six filter-life sensors, serial/firmware, bond indicator, buzzer, and the +reconnect / clear-bonds / scan-duration controls: + +```yaml +text: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 MAC" + mode: text + +fan: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1" + +binary_sensor: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 BLE Connected" + device_class: connectivity + - platform: ap2_hub + ap2_hub_id: ap2 + type: bonded + slot: 0 + name: "Purifier 1 BLE Bonded" + - platform: ap2_hub + ap2_hub_id: ap2 + type: scan_active + name: "Purifier Scan Active" + device_class: running + +text_sensor: + - platform: ap2_hub + ap2_hub_id: ap2 + slot: 0 + name: "Purifier 1 BLE Status" + - platform: ap2_hub + ap2_hub_id: ap2 + type: serial + slot: 0 + name: "Purifier 1 Serial" + - platform: ap2_hub + ap2_hub_id: ap2 + type: firmware + slot: 0 + name: "Purifier 1 Firmware" + - platform: ap2_hub + ap2_hub_id: ap2 + type: scan_status + name: "Purifier Scan Status" + - platform: ap2_hub + ap2_hub_id: ap2 + type: devices_found + name: "Purifier Devices Found" + +# Filter life % — drop any layer your unit doesn't have +sensor: + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: pre_filter, name: "Purifier 1 Filter Pre" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: medium_filter, name: "Purifier 1 Filter Medium" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: activated_carbon, name: "Purifier 1 Filter Activated Carbon" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: carbon_cloth, name: "Purifier 1 Filter Carbon Cloth" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: formaldehyde, name: "Purifier 1 Filter Formaldehyde" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, type: hepa, name: "Purifier 1 Filter HEPA" } + +button: + - platform: template + name: "Purifier Scan" + on_press: + - lambda: "id(ap2).toggle_scan();" + - platform: template + name: "Purifier Reconnect" + on_press: + - lambda: "id(ap2).reconnect_all();" + - platform: template + name: "Purifier Clear Bonds" + entity_category: config + on_press: + - lambda: "id(ap2).clear_bonds();" + - platform: template + name: "Purifier 1 Buzzer Toggle" + on_press: + - lambda: "id(ap2).buzzer_toggle(0);" + +number: + - platform: template + name: "Purifier Scan Duration" + optimistic: true + restore_value: true + initial_value: 90 + min_value: 30 + max_value: 180 + step: 10 + unit_of_measurement: s + mode: box + on_value: + - lambda: "id(ap2).set_scan_duration((uint16_t) x);" +``` + +### Option 3 — two AP2s (simple) + +Two purifiers with the essentials + a shared scanner. For the **full** feature set on two units, +copy [`ap2-hub.example.yaml`](ap2-hub.example.yaml) (the canonical two-slot config): + +```yaml +text: + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 MAC", mode: text } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 MAC", mode: text } + +fan: + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2" } + +binary_sensor: + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 BLE Connected", device_class: connectivity } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 BLE Connected", device_class: connectivity } + +text_sensor: + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 0, name: "Purifier 1 BLE Status" } + - { platform: ap2_hub, ap2_hub_id: ap2, slot: 1, name: "Purifier 2 BLE Status" } + - { platform: ap2_hub, ap2_hub_id: ap2, type: scan_status, name: "Purifier Scan Status" } + - { platform: ap2_hub, ap2_hub_id: ap2, type: devices_found, name: "Purifier Devices Found" } + +button: + - platform: template + name: "Purifier Scan" + on_press: + - lambda: "id(ap2).toggle_scan();" +``` + +## How the scanner works + +The AP2 only advertises **while in pairing mode** (so put it there first), and even then it's +**nameless, with no service data** (see [SPEC.md](SPEC.md)) — it can't be told apart from any +other unnamed BLE device by passive scanning alone. The scan therefore: + +1. **Collects** nameless, no-service-data candidates for a few seconds (strongest RSSI first). +2. **Probes** each one in turn — connects, looks for the Nordic-UART service, sends `M99`, and + treats a valid `M99 V…` reply as the definitive "this is an AP2" fingerprint (which also + yields the firmware version). +3. **Reports & adopts** — confirmed AP2s stream into `Purifier Devices Found`, and any not + already assigned drop into the first empty slot. + +While a scan runs, the slots' control is **suspended** (freeing the radio) and the fans read +`scanning`; a hard **`Purifier Scan Duration`** cap (30–180 s, default 90 s) guarantees it +always finishes and restores control. + +## Entities + +Per slot (×2): + +| Entity | Type | Notes | +|---|---|---| +| `Purifier N` | fan | on/off + speeds 1–4 → gear A0..A4; **kept in sync with the AP2's real gear** (incl. the physical button) via `M9033` polling | +| `Purifier N MAC` | text | settable + flash-persisted; empty clears the slot (and its bond) | +| `Purifier N BLE Status` | text_sensor | `unset` / `connecting` / `connected` / `disconnected` / `scanning` | +| `Purifier N BLE Connected` | binary_sensor | connectivity | +| `Purifier N BLE Bonded` | binary_sensor | a BLE bond is stored for the slot's MAC (true even while powered off) | +| `Purifier N Filter Pre` / `… Medium` / `… Activated Carbon` / `… Carbon Cloth` / `… Formaldehyde` / `… HEPA` | sensor | filter life % (`M9033` `H I J K L S`); `0`/absent if no filter. Shared `Filter` prefix groups them in the UI | +| `Purifier N Serial` | text_sensor | unit serial number | +| `Purifier N Firmware` | text_sensor | firmware version (from `M99`) | +| `Purifier N Buzzer Toggle` | button | toggle the buzzer (`M9046`); **write-only** — the V2 doesn't report buzzer state | + +Hub-level (scanning): + +| Entity | Type | Notes | +|---|---|---| +| `Purifier Scan` | button | start a scan, or cancel a running one (AP2 must be in pairing mode to be found) | +| `Purifier Reconnect` | button | force an active reconnect attempt on all slots | +| `Purifier Clear Bonds` | button | forget all BLE bonds → re-pair from scratch | +| `Purifier Scan Duration` | number | overall scan cap, 30–180 s (default 90) | +| `Purifier Scan Active` | binary_sensor | running | +| `Purifier Scan Status` | text_sensor | live progress (`probing 3/8 — 1 found`) | +| `Purifier Devices Found` | text_sensor | JSON `[{mac,fw,rssi}]` of confirmed AP2s | + +The component polls each connected AP2 with `M9033` every ~10 s to refresh the fan gear, +filter percentages, and serial — so the UI tracks the device even when it's changed at the +unit's own button (see [SPEC.md](SPEC.md) for the reply format). + +## Repo layout + +``` +components/ap2_hub/ ESPHome external component: control slots + BLE scanner (F0F7/M9039) +ap2-hub.example.yaml Example ESPHome device config (two slots + scan UI) +SPEC.md Full reverse-engineered BLE protocol spec +tools/ap2_ble.py PC control/debug tool (Python + bleak): find / set / sweep / status +``` + +## PC tool (optional fallback) + +The ESP32 scanner replaces the need for this, but the Python tool is still handy for testing +without an ESP32, or to find a MAC by hand: + +```bash +pip install bleak +export AP2_ADDR=AA:BB:CC:DD:EE:FF # your AP2's MAC (or run `find` to discover it) +python tools/ap2_ble.py find # discover the AP2's MAC +python tools/ap2_ble.py set 4 # max speed +python tools/ap2_ble.py set 0 # off +python tools/ap2_ble.py sweep # step through speeds +``` + +## Credits + +Reverse-engineering by the project authors; the `M9039` command family was corroborated by +[mneuhaus/xtool-ap2-status](https://github.com/mneuhaus/xtool-ap2-status). Not affiliated +with or endorsed by xTool. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..016b6a5 --- /dev/null +++ b/SPEC.md @@ -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 | | 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`. +- **``** — 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 B` (e.g. `M99 V40.212.003.4528.01 B01`) | +| `M9039 A` | **set speed/gear** (V2): `A0`=off, `A1`..`A4`=speeds 1–4 | status echo `M9039 A 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 B A D H<%> I<%> J<%> K<%> L<%> S<%> E:"" +``` + +e.g. `M9033 V40.212.003.4528.01 B01 A1 D0 H0 I0 J54 K0 L0 S0 E:"MXAS300000000000H000000"` + +- **`A`** — current gear (0–4). **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` for auto mode) per + the community work below; the V2 uses **`A`**. +- 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` 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 + . diff --git a/ap2-hub.example.yaml b/ap2-hub.example.yaml new file mode 100644 index 0000000..6816a92 --- /dev/null +++ b/ap2-hub.example.yaml @@ -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);" diff --git a/components/ap2_hub/__init__.py b/components/ap2_hub/__init__.py new file mode 100644 index 0000000..3e0a56a --- /dev/null +++ b/components/ap2_hub/__init__.py @@ -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)) diff --git a/components/ap2_hub/ap2_fan.cpp b/components/ap2_hub/ap2_fan.cpp new file mode 100644 index 0000000..0e4f5f5 --- /dev/null +++ b/components/ap2_hub/ap2_fan.cpp @@ -0,0 +1,34 @@ +#include "ap2_fan.h" +#include "esphome/core/log.h" +#include + +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 diff --git a/components/ap2_hub/ap2_fan.h b/components/ap2_hub/ap2_fan.h new file mode 100644 index 0000000..18bf0fc --- /dev/null +++ b/components/ap2_hub/ap2_fan.h @@ -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 diff --git a/components/ap2_hub/ap2_hub.cpp b/components/ap2_hub/ap2_hub.cpp new file mode 100644 index 0000000..f3c9569 --- /dev/null +++ b/components/ap2_hub/ap2_hub.cpp @@ -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 // BLE bond list + +#include +#include +#include + +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 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(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 *) ¬ify_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 B A D H.. I.. J.. K.. L.. S.. E:"" + * Space-separated 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 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 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 diff --git a/components/ap2_hub/ap2_hub.h b/components/ap2_hub/ap2_hub.h new file mode 100644 index 0000000..16d2c3a --- /dev/null +++ b/components/ap2_hub/ap2_hub.h @@ -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 +#include +#include + +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 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 cands_; + std::vector 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 diff --git a/components/ap2_hub/ap2_mac_text.h b/components/ap2_hub/ap2_mac_text.h new file mode 100644 index 0000000..7961963 --- /dev/null +++ b/components/ap2_hub/ap2_mac_text.h @@ -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 diff --git a/components/ap2_hub/ap2_probe.cpp b/components/ap2_hub/ap2_probe.cpp new file mode 100644 index 0000000..bd08d72 --- /dev/null +++ b/components/ap2_hub/ap2_probe.cpp @@ -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 *) ¬ify_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 diff --git a/components/ap2_hub/ap2_probe.h b/components/ap2_hub/ap2_probe.h new file mode 100644 index 0000000..1ca6e9b --- /dev/null +++ b/components/ap2_hub/ap2_probe.h @@ -0,0 +1,40 @@ +#pragma once + +#include "esphome/components/esp32_ble_client/ble_client_base.h" +#include "ap2_protocol.h" + +#include +#include + +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 diff --git a/components/ap2_hub/ap2_protocol.h b/components/ap2_hub/ap2_protocol.h new file mode 100644 index 0000000..8280e37 --- /dev/null +++ b/components/ap2_hub/ap2_protocol.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +#include +#include +#include + +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 diff --git a/components/ap2_hub/binary_sensor.py b/components/ap2_hub/binary_sensor.py new file mode 100644 index 0000000..f13d1d8 --- /dev/null +++ b/components/ap2_hub/binary_sensor.py @@ -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)) diff --git a/components/ap2_hub/fan.py b/components/ap2_hub/fan.py new file mode 100644 index 0000000..7449058 --- /dev/null +++ b/components/ap2_hub/fan.py @@ -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)) diff --git a/components/ap2_hub/sensor.py b/components/ap2_hub/sensor.py new file mode 100644 index 0000000..d5324b5 --- /dev/null +++ b/components/ap2_hub/sensor.py @@ -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)) diff --git a/components/ap2_hub/text.py b/components/ap2_hub/text.py new file mode 100644 index 0000000..1d1fc62 --- /dev/null +++ b/components/ap2_hub/text.py @@ -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])) diff --git a/components/ap2_hub/text_sensor.py b/components/ap2_hub/text_sensor.py new file mode 100644 index 0000000..b4be087 --- /dev/null +++ b/components/ap2_hub/text_sensor.py @@ -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)) diff --git a/secrets.yaml.example b/secrets.yaml.example new file mode 100644 index 0000000..6248758 --- /dev/null +++ b/secrets.yaml.example @@ -0,0 +1,3 @@ +# Copy this to `secrets.yaml` (which is gitignored) and fill in your values. +wifi_ssid: "YourWiFiSSID" +wifi_password: "YourWiFiPassword" diff --git a/tools/ap2_ble.py b/tools/ap2_ble.py new file mode 100644 index 0000000..1c3f0e6 --- /dev/null +++ b/tools/ap2_ble.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +xTool SafetyPro AP2 — BLE control (F0F7 / M-code protocol). + +Frame: 0xF0 | id0 id1 id2 | 0x01 | seq | | 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=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 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 / fan set gear A (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 (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)