Files
mrambossekandClaude Opus 4.8 878f7c9c27 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>
2026-06-22 14:45:11 +02:00

86 lines
2.5 KiB
C++

#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