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>
97 lines
3.2 KiB
C++
97 lines
3.2 KiB
C++
#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
|