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

226 lines
8.4 KiB
C++

#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