Initial commit: esphome-xtool-ap2 ESPHome component
Control one or two xTool SafetyPro AP2 air purifiers over BLE and expose them to Home Assistant via an ESP32 (the ap2_hub external component): per-slot fan control with live gear sync, runtime-settable flash-persisted MACs, an on-device scanner, M9033 status polling (filter life, serial, firmware), buzzer control, and BLE bonding. Includes the PC debug tool (tools/ap2_ble.py) and the reverse-engineered protocol spec (SPEC.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Normalize line endings to LF in the repo (this component runs on Linux / HA).
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,9 @@
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
secrets.yaml
|
||||
.env
|
||||
.esphome/
|
||||
.pioenvs/
|
||||
build/
|
||||
logs/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–4 | 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 (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<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>.
|
||||
@@ -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);"
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 *) ¬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<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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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]))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copy this to `secrets.yaml` (which is gitignored) and fill in your values.
|
||||
wifi_ssid: "YourWiFiSSID"
|
||||
wifi_password: "YourWiFiPassword"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user