#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 #include #include 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 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 cands_; std::vector 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