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>
This commit is contained in:
mrambossek
2026-06-22 14:45:11 +02:00
co-authored by Claude Opus 4.8
commit 878f7c9c27
23 changed files with 2523 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import esp32_ble_client, esp32_ble_tracker
from esphome.const import CONF_ID
CODEOWNERS = ["@netplanet"]
DEPENDENCIES = ["esp32_ble_tracker"]
# esp32_ble_client gives us BLEClientBase; the hub publishes connection state to
# binary_sensor/text_sensor. The fan/text platforms load when the user adds them.
AUTO_LOAD = ["esp32_ble_client", "binary_sensor", "text_sensor", "sensor"]
# Number of AP2 slots one ESP32 manages (control connections + 1 transient
# probe in Phase 2 must stay within esp32_ble_tracker's max_connections).
NUM_SLOTS = 2
ap2_hub_ns = cg.esphome_ns.namespace("ap2_hub")
AP2Hub = ap2_hub_ns.class_(
"AP2Hub", cg.Component, esp32_ble_tracker.ESPBTDeviceListener
)
AP2Slot = ap2_hub_ns.class_("AP2Slot", esp32_ble_client.BLEClientBase)
AP2ProbeClient = ap2_hub_ns.class_("AP2ProbeClient", esp32_ble_client.BLEClientBase)
# Hidden auto-generated IDs for the slot + prober sub-objects. Declaring them here
# (rather than fabricating IDs in to_code) is what registers them in
# CORE.component_ids, so cg.register_component accepts them.
SLOT_ID_KEYS = [f"slot_{i}_id" for i in range(NUM_SLOTS)]
PROBE_ID_KEY = "probe_id"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AP2Hub),
cv.GenerateID(PROBE_ID_KEY): cv.declare_id(AP2ProbeClient),
**{cv.GenerateID(k): cv.declare_id(AP2Slot) for k in SLOT_ID_KEYS},
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
hub = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(hub, config)
# The hub listens to advertisements to collect scan candidates.
await esp32_ble_tracker.register_ble_device(hub, config)
for i, key in enumerate(SLOT_ID_KEYS):
slot = cg.new_Pvariable(config[key], i)
await cg.register_component(slot, config)
# Register each slot as a BLE client of the esp32_ble tracker (uses esp32_ble_id).
await esp32_ble_tracker.register_client(slot, config)
cg.add(hub.add_slot(slot))
# Transient scan prober — a third BLE client, only ever connects while the
# slots are suspended for a scan.
probe = cg.new_Pvariable(config[PROBE_ID_KEY])
await cg.register_component(probe, config)
await esp32_ble_tracker.register_client(probe, config)
cg.add(probe.set_hub(hub))
cg.add(hub.set_prober(probe))
+34
View File
@@ -0,0 +1,34 @@
#include "ap2_fan.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome {
namespace ap2_hub {
static const char *const TAG = "ap2_hub.fan";
void AP2Fan::control(const fan::FanCall &call) {
if (this->hub_ == nullptr || !this->hub_->slot_ready(this->slot_)) {
ESP_LOGD(TAG, "slot %u not ready; command ignored", (unsigned) this->slot_);
return; // leave published state unchanged
}
bool had_state = call.get_state().has_value();
bool had_speed = call.get_speed().has_value();
if (had_state)
this->state = *call.get_state();
if (had_speed)
this->speed = *call.get_speed();
// A speed-only change (e.g. the web_server slider) of 0 means "off", any
// other speed means "on" — keeps the slider's 0 position consistent with the
// on/off toggle. An explicit state in the call always wins.
if (had_speed && !had_state)
this->state = this->speed > 0;
uint8_t gear = this->state ? (uint8_t) std::max(1, this->speed) : 0;
this->hub_->set_gear(this->slot_, gear);
this->publish_state();
}
} // namespace ap2_hub
} // namespace esphome
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/fan/fan.h"
#include "ap2_hub.h"
namespace esphome {
namespace ap2_hub {
/* Per-slot fan: off + speeds 1..4 -> gear A0..A4. Commands are silently dropped
* while the slot is not ready (unset / disconnected / scanning). */
class AP2Fan : public fan::Fan, public Component {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
fan::FanTraits get_traits() override {
auto t = fan::FanTraits();
t.set_speed(true);
t.set_supported_speed_count(4);
return t;
}
/* Reflect the AP2's actual gear (from an M9033 poll) into this entity WITHOUT
* sending a command back — keeps the UI in sync with the physical button. */
void update_from_device(uint8_t gear) {
bool st = gear > 0;
int sp = gear > 0 ? (int) gear : this->speed;
if (this->state == st && (!st || this->speed == sp))
return; // no change
this->state = st;
if (st)
this->speed = sp;
this->publish_state();
}
protected:
void control(const fan::FanCall &call) override;
AP2Hub *hub_{nullptr};
uint8_t slot_{0};
};
} // namespace ap2_hub
} // namespace esphome
+660
View File
@@ -0,0 +1,660 @@
#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 *) &notify_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
+225
View File
@@ -0,0 +1,225 @@
#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
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text/text.h"
#include "ap2_hub.h"
namespace esphome {
namespace ap2_hub {
/* HA text input for a slot's MAC. Writing a value retargets+persists the slot;
* an empty value clears it. Publishes the restored MAC back to HA on boot. */
class AP2MacText : public text::Text, public Component {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
void loop() override {
if (this->published_ || this->hub_ == nullptr || this->hub_->get_slot(this->slot_) == nullptr)
return;
this->publish_state(this->hub_->slot_mac_str(this->slot_));
this->published_ = true;
}
protected:
void control(const std::string &value) override {
if (this->hub_ != nullptr) {
this->hub_->set_slot_mac(this->slot_, value);
this->publish_state(this->hub_->slot_mac_str(this->slot_)); // normalized / cleared
} else {
this->publish_state(value);
}
}
AP2Hub *hub_{nullptr};
uint8_t slot_{0};
bool published_{false};
};
} // namespace ap2_hub
} // namespace esphome
+96
View File
@@ -0,0 +1,96 @@
#include "ap2_probe.h"
#include "ap2_hub.h"
#include "esphome/core/log.h"
namespace esphome {
namespace ap2_hub {
static const char *const TAG = "ap2_hub.probe";
void AP2ProbeClient::start_probe(uint64_t mac, esp_ble_addr_type_t type) {
this->active_ = true;
this->confirmed_ = false;
this->fw_.clear();
this->rx_handle_ = this->tx_handle_ = this->ccc_handle_ = 0;
this->set_address(mac);
this->set_remote_addr_type(type);
this->connect();
}
void AP2ProbeClient::abort_probe() {
if (!this->active_)
return;
this->disconnect(); // -> ClientState::IDLE -> set_state() -> hub->probe_finished()
}
void AP2ProbeClient::set_state(espbt::ClientState st) {
BLEClientBase::set_state(st);
// IDLE while a probe is active means the attempt is over (confirmed, no-NUS,
// failed connect, or aborted) and the client is free again.
if (st == espbt::ClientState::IDLE && this->active_) {
this->active_ = false;
if (this->hub_ != nullptr)
this->hub_->probe_finished(this->get_address(), this->confirmed_, this->fw_);
}
}
bool AP2ProbeClient::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;
if (!this->active_)
return true;
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_LOGD(TAG, "%s: no Nordic UART — not an AP2", this->address_str());
this->disconnect();
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_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 *) &notify_en,
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
}
uint8_t f[64];
uint8_t n = ap2_build_frame("M99", 0, f);
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);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle == this->tx_handle_) {
std::string fw = ap2_parse_m99_fw(ap2_ascii(param->notify.value, param->notify.value_len));
if (!fw.empty()) {
this->confirmed_ = true;
this->fw_ = fw;
ESP_LOGD(TAG, "%s: M99 confirmed V%s", this->address_str(), fw.c_str());
this->disconnect();
}
}
break;
}
default:
break;
}
return true;
}
} // namespace ap2_hub
} // namespace esphome
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "ap2_protocol.h"
#include <esp_gattc_api.h>
#include <string>
namespace esphome {
namespace ap2_hub {
class AP2Hub;
/* Transient BLE client the hub uses during a scan: connect to one candidate,
* look for the Nordic-UART service, send M99, and confirm an AP2 by its reply.
* Every probe ends at ClientState::IDLE, which is the single signal back to the
* hub that the prober is free for the next candidate. */
class AP2ProbeClient : public esp32_ble_client::BLEClientBase {
public:
void set_hub(AP2Hub *hub) { this->hub_ = hub; }
void start_probe(uint64_t mac, esp_ble_addr_type_t type);
void abort_probe();
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;
protected:
AP2Hub *hub_{nullptr};
uint16_t rx_handle_{0};
uint16_t tx_handle_{0};
uint16_t ccc_handle_{0};
bool active_{false};
bool confirmed_{false};
std::string fw_;
};
} // namespace ap2_hub
} // namespace esphome
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <cstdint>
#include <cstdio>
#include <string>
namespace esphome {
namespace ap2_hub {
namespace espbt = esphome::esp32_ble_tracker;
/* xTool AP2 Nordic-UART service + the F0F7 / M-code framing, shared by the
* control slots and the scan prober. See SPEC.md for the full protocol. */
inline espbt::ESPBTUUID nus_svc() {
return espbt::ESPBTUUID::from_raw("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
}
inline espbt::ESPBTUUID nus_rx() {
return espbt::ESPBTUUID::from_raw("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
}
inline espbt::ESPBTUUID nus_tx() {
return espbt::ESPBTUUID::from_raw("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
}
/* Build an F0F7 frame for an ASCII M-code into f (>= 64 bytes). Returns length. */
inline uint8_t ap2_build_frame(const char *cmd, uint8_t seq, uint8_t *f) {
static const uint8_t AP2_ID[3] = {0x45, 0x73, 0x60}; // PURIFIER_V2 device id/prefix
uint8_t i = 0, sum = 0, start;
f[i++] = 0xF0;
start = i;
f[i++] = AP2_ID[0];
f[i++] = AP2_ID[1];
f[i++] = AP2_ID[2];
f[i++] = 0x01;
f[i++] = seq;
for (const char *p = cmd; *p != '\0'; ++p)
f[i++] = (uint8_t) *p;
f[i++] = 0x0A;
for (uint8_t j = start; j < i; j++)
sum += f[j];
f[i++] = sum & 0x7F;
f[i++] = 0xF7;
return i;
}
/* Printable-ASCII view of a notification payload. */
inline std::string ap2_ascii(const uint8_t *data, uint16_t len) {
std::string s;
s.reserve(len);
for (uint16_t k = 0; k < len; k++) {
uint8_t c = data[k];
if (c >= 32 && c < 127)
s += (char) c;
}
return s;
}
/* If s is an M99 reply, return the firmware string (after 'V', up to a space),
* else "". A non-empty result is the definitive "this is an AP2" fingerprint. */
inline std::string ap2_parse_m99_fw(const std::string &s) {
auto p = s.find("M99");
if (p == std::string::npos)
return "";
auto v = s.find('V', p);
if (v == std::string::npos)
return "";
auto e = s.find(' ', v);
return s.substr(v + 1, e == std::string::npos ? std::string::npos : e - (v + 1));
}
/* 0xAABBCCDDEEFF -> "AA:BB:CC:DD:EE:FF"; 0 -> "". */
inline std::string ap2_format_mac(uint64_t mac) {
if (mac == 0)
return "";
char b[18];
snprintf(b, sizeof(b), "%02X:%02X:%02X:%02X:%02X:%02X", (unsigned) ((mac >> 40) & 0xFF),
(unsigned) ((mac >> 32) & 0xFF), (unsigned) ((mac >> 24) & 0xFF),
(unsigned) ((mac >> 16) & 0xFF), (unsigned) ((mac >> 8) & 0xFF), (unsigned) (mac & 0xFF));
return b;
}
} // namespace ap2_hub
} // namespace esphome
+54
View File
@@ -0,0 +1,54 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import binary_sensor
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# "connected" / "bonded" are per-slot; "scan_active" is a hub-level flag.
TYPES = ["connected", "bonded", "scan_active"]
_SLOT_TYPES = ("connected", "bonded")
def _validate(config):
has_slot = CONF_SLOT in config
t = config.get(CONF_TYPE)
if t is None:
t = "connected" if has_slot else None
if t is None:
raise cv.Invalid(
"set 'type: scan_active', or give a 'slot' for a connected/bonded sensor"
)
config[CONF_TYPE] = t
if t in _SLOT_TYPES and not has_slot:
raise cv.Invalid(f"type '{t}' requires a 'slot'")
if t == "scan_active" and has_slot:
raise cv.Invalid("type 'scan_active' must not have a 'slot'")
return config
CONFIG_SCHEMA = cv.All(
binary_sensor.binary_sensor_schema().extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Optional(CONF_TYPE): cv.one_of(*TYPES, lower=True),
cv.Optional(CONF_SLOT): cv.int_range(min=0, max=1),
}
),
_validate,
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
t = config[CONF_TYPE]
if t == "connected":
cg.add(parent.set_connected_sensor(config[CONF_SLOT], var))
elif t == "bonded":
cg.add(parent.set_bonded_sensor(config[CONF_SLOT], var))
else:
cg.add(parent.set_scan_active_sensor(var))
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import fan
from . import AP2Hub, ap2_hub_ns
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
AP2Fan = ap2_hub_ns.class_("AP2Fan", fan.Fan, cg.Component)
CONFIG_SCHEMA = fan.fan_schema(AP2Fan).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = await fan.new_fan(config)
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(var.set_hub(parent))
cg.add(var.set_slot(config[CONF_SLOT]))
# Let the slot push live gear updates (from M9033) into this fan.
cg.add(parent.set_fan(config[CONF_SLOT], var))
+38
View File
@@ -0,0 +1,38 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor
from esphome.const import UNIT_PERCENT
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# Filter-life types -> index into AP2Filter (M9033 fields H I J K L S).
FILTERS = {
"pre_filter": 0,
"medium_filter": 1,
"activated_carbon": 2,
"carbon_cloth": 3,
"formaldehyde": 4,
"hepa": 5,
}
CONFIG_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
icon="mdi:air-filter",
).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
cv.Required(CONF_TYPE): cv.one_of(*FILTERS, lower=True),
}
)
async def to_code(config):
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(parent.set_filter_sensor(config[CONF_SLOT], FILTERS[config[CONF_TYPE]], var))
+26
View File
@@ -0,0 +1,26 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text
from . import AP2Hub, ap2_hub_ns
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
AP2MacText = ap2_hub_ns.class_("AP2MacText", text.Text, cg.Component)
# "AA:BB:CC:DD:EE:FF" is 17 chars; allow empty to clear.
CONFIG_SCHEMA = text.text_schema(AP2MacText).extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Required(CONF_SLOT): cv.int_range(min=0, max=1),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = await text.new_text(config, min_length=0, max_length=17)
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
cg.add(var.set_hub(parent))
cg.add(var.set_slot(config[CONF_SLOT]))
+58
View File
@@ -0,0 +1,58 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text_sensor
from . import AP2Hub
CONF_AP2_HUB_ID = "ap2_hub_id"
CONF_SLOT = "slot"
CONF_TYPE = "type"
# "status" / "serial" / "firmware" are per-slot; "scan_status" and "devices_found" are hub-level.
TYPES = ["status", "serial", "firmware", "scan_status", "devices_found"]
_SLOT_TYPES = ("status", "serial", "firmware")
def _validate(config):
has_slot = CONF_SLOT in config
t = config.get(CONF_TYPE)
if t is None:
t = "status" if has_slot else None
if t is None:
raise cv.Invalid(
"set 'type: scan_status'/'devices_found', or give a 'slot' for a status/serial sensor"
)
config[CONF_TYPE] = t
if t in _SLOT_TYPES and not has_slot:
raise cv.Invalid(f"type '{t}' requires a 'slot'")
if t not in _SLOT_TYPES and has_slot:
raise cv.Invalid(f"type '{t}' must not have a 'slot'")
return config
CONFIG_SCHEMA = cv.All(
text_sensor.text_sensor_schema().extend(
{
cv.GenerateID(CONF_AP2_HUB_ID): cv.use_id(AP2Hub),
cv.Optional(CONF_TYPE): cv.one_of(*TYPES, lower=True),
cv.Optional(CONF_SLOT): cv.int_range(min=0, max=1),
}
),
_validate,
)
async def to_code(config):
var = await text_sensor.new_text_sensor(config)
parent = await cg.get_variable(config[CONF_AP2_HUB_ID])
t = config[CONF_TYPE]
if t == "status":
cg.add(parent.set_status_sensor(config[CONF_SLOT], var))
elif t == "serial":
cg.add(parent.set_serial_sensor(config[CONF_SLOT], var))
elif t == "firmware":
cg.add(parent.set_firmware_sensor(config[CONF_SLOT], var))
elif t == "scan_status":
cg.add(parent.set_scan_status_sensor(var))
else:
cg.add(parent.set_devices_found_sensor(var))