Files
mrambossekandClaude Opus 4.8 878f7c9c27 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>
2026-06-22 14:45:11 +02:00

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 *) &notify_en,
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
}
uint8_t f[64];
uint8_t n = ap2_build_frame("M99", 0, f);
esp_ble_gattc_write_char((esp_gatt_if_t) this->get_gattc_if(), this->get_conn_id(),
this->rx_handle_, n, f, ESP_GATT_WRITE_TYPE_RSP,
ESP_GATT_AUTH_REQ_NONE);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle == this->tx_handle_) {
std::string fw = ap2_parse_m99_fw(ap2_ascii(param->notify.value, param->notify.value_len));
if (!fw.empty()) {
this->confirmed_ = true;
this->fw_ = fw;
ESP_LOGD(TAG, "%s: M99 confirmed V%s", this->address_str(), fw.c_str());
this->disconnect();
}
}
break;
}
default:
break;
}
return true;
}
} // namespace ap2_hub
} // namespace esphome