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:
@@ -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
|
||||
Reference in New Issue
Block a user