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>
661 lines
21 KiB
C++
661 lines
21 KiB
C++
#include "ap2_hub.h"
|
|
#include "ap2_probe.h"
|
|
#include "ap2_fan.h"
|
|
#include "esphome/core/hal.h" // millis()
|
|
#include "esphome/core/log.h"
|
|
|
|
#include <esp_gap_ble_api.h> // BLE bond list
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstring>
|
|
|
|
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<esp_ble_bond_dev_t> 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<uint64_t>(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<fw> B<batch> A<speed> D<mode> H.. I.. J.. K.. L.. S.. E:"<serial>"
|
|
* Space-separated <letter><number> 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<esp_ble_bond_dev_t> 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<esp_ble_bond_dev_t> 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
|