#include "ap2_hub.h" #include "ap2_probe.h" #include "ap2_fan.h" #include "esphome/core/hal.h" // millis() #include "esphome/core/log.h" #include // BLE bond list #include #include #include namespace esphome { namespace ap2_hub { static const char *const TAG = "ap2_hub"; /* Scan tuning. */ static const size_t MAX_CANDS = 16; // distinct nameless devices we remember static const size_t PROBE_CAP = 10; // strongest-RSSI candidates we actually probe static const uint32_t COLLECT_MS = 8000; // advertisement-collection window static const uint32_t PROBE_TIMEOUT_MS = 6000; // per-candidate connect+M99 budget /* "AA:BB:CC:DD:EE:FF" -> 0xAABBCCDDEEFF. Empty/whitespace -> 0 (clear). * Returns false on malformed input (caller leaves the slot unchanged). */ static bool parse_mac(const std::string &in, uint64_t *out) { std::string s; for (char c : in) if (!std::isspace((unsigned char) c)) s += c; if (s.empty()) { *out = 0; return true; } uint64_t v = 0; int bytes = 0, nib = 0; uint8_t cur = 0; for (char c : s) { if (c == ':' || c == '-') { if (nib == 0) return false; v = (v << 8) | cur; cur = 0; nib = 0; bytes++; continue; } int h; if (c >= '0' && c <= '9') h = c - '0'; else if (c >= 'a' && c <= 'f') h = c - 'a' + 10; else if (c >= 'A' && c <= 'F') h = c - 'A' + 10; else return false; cur = (cur << 4) | h; if (++nib > 2) return false; } if (nib == 0) return false; v = (v << 8) | cur; if (++bytes != 6) return false; *out = v; return true; } /* Is this peer address already in the BLE bond store (NVS)? */ static bool addr_in_bond_list(const uint8_t *bda) { int n = esp_ble_get_bond_device_num(); if (n <= 0) return false; std::vector list(n); esp_ble_get_bond_device_list(&n, list.data()); for (int i = 0; i < n; i++) if (memcmp(list[i].bd_addr, bda, sizeof(esp_bd_addr_t)) == 0) return true; return false; } static void mac_to_bda(uint64_t mac, esp_bd_addr_t bda) { for (int i = 0; i < 6; i++) bda[i] = (mac >> (8 * (5 - i))) & 0xFF; } /* Same, but for a uint64 MAC (works while disconnected — queries NVS). */ static bool mac_in_bond_list(uint64_t mac) { if (mac == 0) return false; esp_bd_addr_t bda; mac_to_bda(mac, bda); return addr_in_bond_list(bda); } /* Forget any stored bond for this MAC (e.g. when a slot is cleared/reassigned). */ static void remove_bond_for_mac(uint64_t mac) { if (mac == 0) return; esp_bd_addr_t bda; mac_to_bda(mac, bda); if (addr_in_bond_list(bda)) esp_ble_remove_bond_device(bda); } // ===================== AP2Slot ===================== void AP2Slot::setup() { BLEClientBase::setup(); this->mac_pref_ = global_preferences->make_preference(fnv1_hash("ap2_hub_slot_mac") ^ (uint32_t) (this->index_ + 1)); uint64_t saved = 0; if (this->mac_pref_.load(&saved) && saved != 0) { ESP_LOGI(TAG, "slot %u: restoring MAC from flash", this->index_); this->apply_mac_(saved, /*persist=*/false); } else { this->set_auto_connect(false); this->publish_status_(); } } void AP2Slot::loop() { BLEClientBase::loop(); // NOTE: disables this loop while idle (see hub loop for bond check) // Poll the AP2's live status (M9033) — the reply is logged by the NOTIFY handler. uint32_t now = millis(); if (this->ready_ && now - this->last_status_poll_ms_ >= 10000) { this->last_status_poll_ms_ = now; this->send_cmd_("M9033"); } } void AP2Slot::update_bonded() { // NVS-backed, valid even while disconnected (bond persists when powered off). bool bonded = mac_in_bond_list(this->mac_); if (!this->bonded_inited_ || bonded != this->last_bonded_) { this->bonded_inited_ = true; this->last_bonded_ = bonded; if (this->bonded_sensor_ != nullptr) this->bonded_sensor_->publish_state(bonded); } } void AP2Slot::dump_config() { ESP_LOGCONFIG(TAG, "AP2 slot %u: MAC %s", this->index_, this->mac_ == 0 ? "(unset)" : this->mac_str().c_str()); } void AP2Slot::apply_mac_(uint64_t mac, bool persist) { uint64_t old = this->mac_; this->mac_ = mac; if (persist) { this->mac_pref_.save(&this->mac_); // ESPHome's save() only queues to RAM and commits on the next sync (e.g. a // clean OTA reboot). Flush now so the MAC also survives a hard reset/power-cut. global_preferences->sync(); } if (this->connected()) this->disconnect(); // If the slot's device changed (cleared or reassigned), forget its old bond. if (old != 0 && old != mac) { ESP_LOGI(TAG, "slot %u: forgetting bond for %s", this->index_, ap2_format_mac(old).c_str()); remove_bond_for_mac(old); } if (mac != 0) { this->set_address(mac); this->set_auto_connect(!this->scanning_); } else { this->set_address(0); this->set_auto_connect(false); this->ready_ = false; this->fw_.clear(); } this->publish_status_(); } void AP2Slot::set_mac_str(const std::string &mac) { uint64_t parsed; if (!parse_mac(mac, &parsed)) { ESP_LOGW(TAG, "slot %u: invalid MAC '%s' (ignored)", this->index_, mac.c_str()); return; } ESP_LOGI(TAG, "slot %u: set MAC '%s'", this->index_, parsed == 0 ? "(clear)" : mac.c_str()); this->apply_mac_(parsed, /*persist=*/true); } std::string AP2Slot::mac_str() const { return ap2_format_mac(this->mac_); } void AP2Slot::set_scanning(bool scanning) { if (scanning == this->scanning_) return; this->scanning_ = scanning; if (scanning) { if (this->connected()) this->disconnect(); this->set_auto_connect(false); this->ready_ = false; } else if (this->mac_ != 0) { this->set_auto_connect(true); } this->publish_status_(); } void AP2Slot::try_reconnect() { if (this->mac_ == 0 || this->scanning_) return; if (this->state() != espbt::ClientState::IDLE) return; // already connecting / connected / busy // Active direct connect: listens ~continuously for ~20s for the AP2 to // advertise. (A background connect with is_direct=false just errors instantly // on this stack, so this is the best we can do — it can only succeed when the // AP2 actually advertises, e.g. in pairing mode or a cold boot that re-adverts.) ESP_LOGI(TAG, "slot %u: reconnect attempt", this->index_); this->connect(); } void AP2Slot::set_state(espbt::ClientState st) { BLEClientBase::set_state(st); if (st != espbt::ClientState::ESTABLISHED && (this->ready_ || this->rx_handle_ != 0)) { this->ready_ = false; this->rx_handle_ = 0; this->tx_handle_ = 0; this->ccc_handle_ = 0; this->bond_tried_ = false; } this->publish_status_(); } bool AP2Slot::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; 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_LOGW(TAG, "slot %u: Nordic UART not found on peer", this->index_); 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_LOGD(TAG, "slot %u: NUS found (rx=0x%04x tx=0x%04x) — subscribing", this->index_, this->rx_handle_, this->tx_handle_); 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); } this->ready_ = true; ESP_LOGD(TAG, "slot %u ready", this->index_); this->send_cmd_("M99"); // identify/handshake // Bond ONCE so the AP2 remembers us and advertises for us on future // power-ups. Never re-pair an already-bonded peer — a fresh pairing request // to a bonded device makes it delete the bond (and stop auto-advertising). if (!this->bond_tried_) { this->bond_tried_ = true; if (addr_in_bond_list(this->get_remote_bda())) { ESP_LOGD(TAG, "slot %u: already bonded; not re-pairing", this->index_); } else { ESP_LOGI(TAG, "slot %u: not bonded yet — requesting bond", this->index_); this->pair(); } } this->publish_status_(); break; } case ESP_GATTC_NOTIFY_EVT: { if (param->notify.handle == this->tx_handle_) { std::string s = ap2_ascii(param->notify.value, param->notify.value_len); ESP_LOGD(TAG, "slot %u <- %s", this->index_, s.c_str()); std::string fw = ap2_parse_m99_fw(s); if (!fw.empty() && fw != this->fw_) { this->fw_ = fw; if (this->firmware_sensor_ != nullptr) this->firmware_sensor_->publish_state(fw); } if (s.find("M9033") != std::string::npos) this->parse_m9033_(s); } break; } default: break; } return true; } void AP2Slot::send_cmd_(const char *cmd) { if (!this->ready_ || this->rx_handle_ == 0) { ESP_LOGW(TAG, "slot %u not ready, dropping '%s'", this->index_, cmd); return; } uint8_t f[64]; uint8_t n = ap2_build_frame(cmd, this->seq_, f); this->seq_ = (this->seq_ < 0x7E) ? (this->seq_ + 1) : 1; ESP_LOGD(TAG, "slot %u -> %s", this->index_, cmd); 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); } void AP2Slot::set_gear(uint8_t gear) { if (!this->is_ready()) { ESP_LOGW(TAG, "slot %u not ready, ignoring gear A%u", this->index_, (unsigned) gear); return; } if (gear > 4) gear = 4; char b[16]; snprintf(b, sizeof(b), "M9039 A%u", (unsigned) gear); ESP_LOGD(TAG, "slot %u: set gear A%u", this->index_, (unsigned) gear); this->send_cmd_(b); } void AP2Slot::publish_status_() { bool controllable = this->ready_ && !this->scanning_; std::string st; if (this->scanning_) st = "scanning"; else if (this->mac_ == 0) st = "unset"; else if (this->ready_) st = "connected"; else if (this->connected()) st = "connecting"; else st = "disconnected"; if (!this->status_inited_ || st != this->last_status_) { if (this->status_inited_) ESP_LOGI(TAG, "slot %u: %s", this->index_, st.c_str()); // connect/disconnect visibility this->last_status_ = st; if (this->status_sensor_ != nullptr) this->status_sensor_->publish_state(st); } if (!this->status_inited_ || controllable != this->last_connected_) { this->last_connected_ = controllable; if (this->connected_sensor_ != nullptr) this->connected_sensor_->publish_state(controllable); } this->status_inited_ = true; } /* Parse an M9033 status reply: * M9033 V B A D H.. I.. J.. K.. L.. S.. E:"" * Space-separated fields; E:"..." is the serial. */ void AP2Slot::parse_m9033_(const std::string &s) { auto p = s.find("M9033"); std::string body = s.substr(p + 5); size_t i = 0; while (i < body.size()) { while (i < body.size() && body[i] == ' ') i++; size_t j = i; while (j < body.size() && body[j] != ' ') j++; std::string tok = body.substr(i, j - i); i = j; if (tok.size() < 2) continue; char f = tok[0]; if (f == 'E') { // serial: E:"...." auto q1 = tok.find('"'); auto q2 = tok.rfind('"'); if (q1 != std::string::npos && q2 > q1) { std::string serial = tok.substr(q1 + 1, q2 - q1 - 1); if (serial != this->last_serial_) { this->last_serial_ = serial; if (this->serial_sensor_ != nullptr) this->serial_sensor_->publish_state(serial); } } continue; } int v = atoi(tok.c_str() + 1); // value after the field letter int fi = -1; switch (f) { case 'A': // fan speed/gear -> reflect into the fan entity if (this->fan_ != nullptr) this->fan_->update_from_device((uint8_t) (v < 0 ? 0 : v)); break; case 'H': fi = FILT_PRE; break; case 'I': fi = FILT_MEDIUM; break; case 'J': fi = FILT_CARBON; break; case 'K': fi = FILT_CARBON_CLOTH; break; case 'L': fi = FILT_FORMALDEHYDE; break; case 'S': fi = FILT_HEPA; break; default: break; } if (fi >= 0 && this->filter_sensors_[fi] != nullptr) this->filter_sensors_[fi]->publish_state((float) v); } } void AP2Slot::toggle_buzzer() { if (!this->is_ready()) { ESP_LOGW(TAG, "slot %u: buzzer toggle ignored (not ready)", this->index_); return; } this->buzzer_on_ = !this->buzzer_on_; ESP_LOGI(TAG, "slot %u: buzzer %s", this->index_, this->buzzer_on_ ? "on" : "off"); this->send_cmd_(this->buzzer_on_ ? "M9046 F1" : "M9046 F0"); } // ===================== AP2Hub ===================== void AP2Hub::loop() { // The hub's loop always runs (unlike the slots', which BLEClientBase disables // while idle). Use it to refresh each slot's bonded indicator even when the // AP2 is disconnected / powered off. uint32_t now = millis(); if (now - this->last_bond_poll_ms_ >= 2000) { this->last_bond_poll_ms_ = now; for (auto *s : this->slots_) s->update_bonded(); } // Actively retry reconnection while disconnected (more reliable than passive // auto-connect for a bonded AP2 that uses directed advertising). if (now - this->last_reconnect_ms_ >= 20000) { this->last_reconnect_ms_ = now; for (auto *s : this->slots_) s->try_reconnect(); } } void AP2Hub::reconnect_all() { for (auto *s : this->slots_) s->try_reconnect(); } void AP2Hub::dump_config() { ESP_LOGCONFIG(TAG, "AP2 hub: %u slot(s), scanner %s", (unsigned) this->slots_.size(), this->prober_ != nullptr ? "ready" : "DISABLED (no prober)"); int n = esp_ble_get_bond_device_num(); ESP_LOGCONFIG(TAG, " BLE bonds stored: %d", n); if (n > 0) { std::vector list(n); esp_ble_get_bond_device_list(&n, list.data()); for (int i = 0; i < n; i++) { const uint8_t *a = list[i].bd_addr; const uint8_t *id = list[i].bond_key.pid_key.static_addr; // identity address ESP_LOGCONFIG(TAG, " bond %d: bonded=%02X:%02X:%02X:%02X:%02X:%02X identity=%02X:%02X:%02X:%02X:%02X:%02X", i, a[0], a[1], a[2], a[3], a[4], a[5], id[0], id[1], id[2], id[3], id[4], id[5]); } } } void AP2Hub::clear_bonds() { int n = esp_ble_get_bond_device_num(); ESP_LOGI(TAG, "clearing %d BLE bond(s)", n); if (n <= 0) return; std::vector list(n); esp_ble_get_bond_device_list(&n, list.data()); for (int i = 0; i < n; i++) esp_ble_remove_bond_device(list[i].bd_addr); } void AP2Hub::toggle_scan() { if (this->is_scanning()) { ESP_LOGI(TAG, "scan: cancelled by user"); this->finish_scan_("stopped"); } else { this->start_scan(); } } void AP2Hub::start_scan() { if (this->scan_state_ != ScanState::IDLE) { ESP_LOGW(TAG, "scan: already running"); return; } if (this->prober_ == nullptr) { ESP_LOGE(TAG, "scan: no prober configured"); return; } // The AP2 only advertises in pairing mode — remind the user, then start now. ESP_LOGI(TAG, "scan: make sure your AP2 is in Pairing Mode (hold the Power Button ~5s until the " "Link LED blinks) — it only advertises in that state. Scanning now (%us budget)…", (unsigned) this->scan_duration_s_); this->cands_.clear(); this->found_.clear(); this->probe_idx_ = 0; this->scan_start_ms_ = millis(); for (auto *s : this->slots_) s->set_scanning(true); // suspend control, free the radio this->scan_state_ = ScanState::COLLECTING; this->publish_scan_active_(true); this->set_scan_status_("scanning…"); uint32_t budget_ms = (uint32_t) this->scan_duration_s_ * 1000; uint32_t collect_ms = std::min(COLLECT_MS, budget_ms / 2); this->set_timeout("collect", collect_ms, [this]() { this->begin_probing_(); }); this->set_timeout("watchdog", budget_ms, [this]() { ESP_LOGW(TAG, "scan: time budget reached"); this->finish_scan_(); }); } bool AP2Hub::parse_device(const espbt::ESPBTDevice &device) { if (this->scan_state_ != ScanState::COLLECTING) return false; // AP2s advertise nameless and with no service data; filter to those candidates. if (!device.get_name().empty() || !device.get_service_uuids().empty()) return false; uint64_t mac = device.address_uint64(); for (auto &c : this->cands_) if (c.mac == mac) return false; // already known if (this->cands_.size() >= MAX_CANDS) return false; this->cands_.push_back({mac, device.get_address_type(), device.get_rssi()}); return false; } void AP2Hub::begin_probing_() { if (this->scan_state_ != ScanState::COLLECTING) return; std::sort(this->cands_.begin(), this->cands_.end(), [](const Cand &a, const Cand &b) { return a.rssi > b.rssi; }); if (this->cands_.size() > PROBE_CAP) { ESP_LOGI(TAG, "scan: %u candidates, probing strongest %u", (unsigned) this->cands_.size(), (unsigned) PROBE_CAP); this->cands_.resize(PROBE_CAP); } this->scan_state_ = ScanState::PROBING; ESP_LOGI(TAG, "scan: probing %u candidate(s)", (unsigned) this->cands_.size()); this->probe_next_(); } void AP2Hub::probe_next_() { if (this->scan_state_ != ScanState::PROBING) return; uint32_t elapsed = millis() - this->scan_start_ms_; if (this->probe_idx_ >= this->cands_.size() || elapsed >= (uint32_t) this->scan_duration_s_ * 1000) { this->finish_scan_(); return; } auto &c = this->cands_[this->probe_idx_]; ESP_LOGD(TAG, "scan: probe %u/%u %s (rssi %d)", (unsigned) (this->probe_idx_ + 1), (unsigned) this->cands_.size(), ap2_format_mac(c.mac).c_str(), c.rssi); this->set_scan_status_(str_sprintf("probing %u/%u — %u found", (unsigned) (this->probe_idx_ + 1), (unsigned) this->cands_.size(), (unsigned) this->found_.size())); this->prober_->start_probe(c.mac, c.type); this->set_timeout("probe", PROBE_TIMEOUT_MS, [this]() { ESP_LOGD(TAG, "scan: probe timed out"); this->prober_->abort_probe(); // -> IDLE -> probe_finished() }); } void AP2Hub::probe_finished(uint64_t mac, bool confirmed, const std::string &fw) { if (this->scan_state_ != ScanState::PROBING) return; // stray IDLE (e.g. teardown during finish_scan_) this->cancel_timeout("probe"); if (confirmed) { int rssi = 0; for (auto &c : this->cands_) if (c.mac == mac) { rssi = c.rssi; break; } this->found_.push_back({mac, fw, rssi}); ESP_LOGI(TAG, "scan: FOUND AP2 %s (V%s, rssi %d)", ap2_format_mac(mac).c_str(), fw.c_str(), rssi); this->publish_devices_(); this->try_adopt_(mac); } this->probe_idx_++; this->set_scan_status_(str_sprintf( "probing %u/%u — %u found", (unsigned) std::min((size_t) (this->probe_idx_ + 1), this->cands_.size()), (unsigned) this->cands_.size(), (unsigned) this->found_.size())); this->defer([this]() { this->probe_next_(); }); } void AP2Hub::finish_scan_(const char *verb) { if (this->scan_state_ == ScanState::IDLE) return; this->cancel_timeout("collect"); this->cancel_timeout("probe"); this->cancel_timeout("watchdog"); this->scan_state_ = ScanState::IDLE; // set before aborting so the prober's IDLE is ignored if (this->prober_ != nullptr) this->prober_->abort_probe(); for (auto *s : this->slots_) s->set_scanning(false); // restore control this->publish_devices_(); this->set_scan_status_(str_sprintf("%s — %u found", verb, (unsigned) this->found_.size())); this->publish_scan_active_(false); ESP_LOGI(TAG, "scan: %s, %u AP2(s) found", verb, (unsigned) this->found_.size()); } void AP2Hub::try_adopt_(uint64_t mac) { std::string ms = ap2_format_mac(mac); for (auto *s : this->slots_) if (s->mac_str() == ms) return; // already assigned somewhere for (auto *s : this->slots_) if (s->mac_str().empty()) { ESP_LOGI(TAG, "scan: adopting %s into a free slot", ms.c_str()); s->set_mac_str(ms); return; } } void AP2Hub::publish_devices_() { std::string j = "["; for (size_t i = 0; i < this->found_.size(); i++) { if (i) j += ","; j += str_sprintf("{\"mac\":\"%s\",\"fw\":\"%s\",\"rssi\":%d}", ap2_format_mac(this->found_[i].mac).c_str(), this->found_[i].fw.c_str(), this->found_[i].rssi); } j += "]"; this->devices_json_ = j; if (this->devices_found_sensor_ != nullptr) this->devices_found_sensor_->publish_state(j); } void AP2Hub::set_scan_status_(const std::string &s) { this->scan_status_ = s; if (this->scan_status_sensor_ != nullptr) this->scan_status_sensor_->publish_state(s); } void AP2Hub::publish_scan_active_(bool active) { if (this->scan_active_sensor_ != nullptr) this->scan_active_sensor_->publish_state(active); } } // namespace ap2_hub } // namespace esphome