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>
226 lines
8.4 KiB
C++
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
|