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,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))
|
||||
Reference in New Issue
Block a user