Compare commits

...
Author SHA1 Message Date
J. Nick Koston 4cb0517f48 [xiaomi_ble] Drop the trailing underscore from the virtual publish_result and check optionals in the tests 2026-10-24 08:29:34 -10:00
J. Nick Koston 58ac35702e [xiaomi_ble] Emit the bindkey as bytes and drop an unused include 2026-10-24 08:29:34 -10:00
J. Nick Koston 71bb2488a7 [xiaomi_ble] Drop repeated frames before the header parse and share the bindkey dump
Format the address only when the result is logged, keep the header flags
local, and add XiaomiEncryptedDevice::dump_bindkey_() for dump_config.
2026-10-24 08:29:34 -10:00
J. Nick Koston dc5f91c9e0 [xiaomi_ble] Add a shared XiaomiDevice base for MiBeacon devices
The base matches the address, filters duplicate frames per device, decrypts
into a stack copy instead of the tracker's shared service data, and hands
each decoded result to publish_result_(). XiaomiEncryptedDevice adds the
bindkey only for models that need it.
2026-10-24 08:29:34 -10:00
4 changed files with 323 additions and 95 deletions

No files matched your search

+13 -1
View File
@@ -1,7 +1,9 @@
import esphome.codegen as cg
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS
from esphome.core import HexInt
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
AUTO_LOAD = ["ble_device_base"]
@@ -11,6 +13,8 @@ xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble")
XiaomiListener = xiaomi_ble_ns.class_(
"XiaomiListener", ble_device_base.ESPBTDeviceListener
)
XiaomiDevice = xiaomi_ble_ns.class_("XiaomiDevice", ble_device_base.ESPBTDeviceListener)
XiaomiEncryptedDevice = xiaomi_ble_ns.class_("XiaomiEncryptedDevice", XiaomiDevice)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("xiaomi_ble"),
@@ -25,3 +29,11 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await ble_device_base.register_ble_device(var, config)
def setup_xiaomi_device(var: MockObj, config: ConfigType) -> None:
"""Set the address, and the bindkey when configured, of a XiaomiDevice."""
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
if bindkey := config.get(CONF_BINDKEY):
key = (HexInt(byte) for byte in bytes.fromhex(bindkey))
cg.add(var.set_bindkey(cg.ArrayInitializer(*key)))
+152 -90
View File
@@ -2,6 +2,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <vector>
// AES-CCM backend for encrypted-payload (bindkey) decryption:
@@ -119,7 +121,7 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_
return true;
}
bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult &result) {
bool parse_xiaomi_message(const uint8_t *message, size_t size, XiaomiParseResult &result) {
result.has_encryption = message[0] & 0x08; // update encryption status
if (result.has_encryption) {
ESP_LOGVV(TAG, "parse_xiaomi_message(): payload is encrypted, stop reading message.");
@@ -132,13 +134,13 @@ bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult
// Byte 2: length
// Byte 3..3+len-1: data point value
if (result.raw_offset < 0 || static_cast<size_t>(result.raw_offset) >= message.size()) {
ESP_LOGVV(TAG, "parse_xiaomi_message(): raw_offset (%d) exceeds message size (%d)!", result.raw_offset,
message.size());
if (result.raw_offset < 0 || static_cast<size_t>(result.raw_offset) >= size) {
ESP_LOGVV(TAG, "parse_xiaomi_message(): raw_offset (%d) exceeds message size (%u)!", result.raw_offset,
static_cast<unsigned>(size));
return false;
}
const uint8_t *payload = message.data() + result.raw_offset;
uint8_t payload_length = message.size() - result.raw_offset;
const uint8_t *payload = message + result.raw_offset;
uint8_t payload_length = size - result.raw_offset;
uint8_t payload_offset = 0;
bool success = false;
@@ -173,36 +175,25 @@ bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult
return success;
}
optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceData &service_data) {
XiaomiParseResult result;
if (!service_data.uuid.contains(0x95, 0xFE)) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes.");
return {};
}
bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult &result) {
return parse_xiaomi_message(message.data(), message.size(), result);
}
auto raw = service_data.data;
if (raw.size() < 5) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): service data too short (%d).", raw.size());
optional<XiaomiParseResult> parse_xiaomi_header(const uint8_t *raw, size_t size) {
XiaomiParseResult result;
if (size < 5) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): service data too short (%u).", static_cast<unsigned>(size));
return {};
}
result.has_data = raw[0] & 0x40;
result.has_capability = raw[0] & 0x20;
result.has_encryption = raw[0] & 0x08;
if (!result.has_data) {
if (!(raw[0] & 0x40)) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): service data has no DATA flag.");
return {};
}
static uint8_t last_frame_count = 0;
if (last_frame_count == raw[4]) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): duplicate data packet received (%d).", static_cast<int>(last_frame_count));
result.is_duplicate = true;
return {};
}
last_frame_count = raw[4];
result.is_duplicate = false;
result.raw_offset = result.has_capability ? 12 : 11;
result.raw_offset = (raw[0] & 0x20) ? 12 : 11;
const uint16_t device_uuid = encode_uint16(raw[3], raw[2]);
@@ -235,7 +226,7 @@ optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceDa
} else if (device_uuid == 0x2542) { // rectangular body, e-ink display — with bindkeys
result.type = XiaomiParseResult::TYPE_LYWSD02MMC;
result.name = "LYWSD02MMC";
if (raw.size() == 19)
if (size == 19)
result.raw_offset -= 6;
} else if (device_uuid == 0x040a) { // Mosquito Repellent Smart Version
result.type = XiaomiParseResult::TYPE_WX08ZM;
@@ -252,12 +243,12 @@ optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceDa
} else if (device_uuid == 0x1203) { // small square body, e-ink display, encrypted
result.type = XiaomiParseResult::TYPE_XMWSDJ04MMC;
result.name = "XMWSDJ04MMC";
if (raw.size() == 19)
if (size == 19)
result.raw_offset -= 6;
} else if (device_uuid == 0x07f6) { // Xiaomi-Yeelight BLE nightlight
result.type = XiaomiParseResult::TYPE_MJYD02YLA;
result.name = "MJYD02YLA";
if (raw.size() == 19)
if (size == 19)
result.raw_offset -= 6;
} else if (device_uuid == 0x06d3) { // rectangular body, e-ink display with alarm
result.type = XiaomiParseResult::TYPE_MHOC303;
@@ -268,12 +259,12 @@ optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceDa
} else if (device_uuid == 0x0A83) { // Qingping-branded, motion & ambient light sensor
result.type = XiaomiParseResult::TYPE_CGPR1;
result.name = "CGPR1";
if (raw.size() == 19)
if (size == 19)
result.raw_offset -= 6;
} else if (device_uuid == 0x0A8D) { // Xiaomi Mi Motion Sensor 2
result.type = XiaomiParseResult::TYPE_RTCGQ02LM;
result.name = "RTCGQ02LM";
if (raw.size() == 19)
if (size == 19)
result.raw_offset -= 6;
} else {
ESP_LOGVV(TAG, "parse_xiaomi_header(): unknown device, no magic bytes.");
@@ -283,10 +274,27 @@ optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceDa
return result;
}
bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, const uint64_t &address) {
if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) {
ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size());
ESP_LOGVV_HEX(TAG, raw.data(), raw.size(), XIAOMI_MAX_LOG_BYTES, " Packet : %s");
optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceData &service_data) {
if (!service_data.uuid.contains(0x95, 0xFE)) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes.");
return {};
}
const auto &raw = service_data.data;
if (raw.size() < 5 || !(raw[0] & 0x40))
return {};
static uint8_t last_frame_count = 0;
if (last_frame_count == raw[4]) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): duplicate data packet received (%d).", static_cast<int>(last_frame_count));
return {};
}
last_frame_count = raw[4];
return parse_xiaomi_header(raw.data(), raw.size());
}
bool decrypt_xiaomi_payload(uint8_t *raw, size_t size, const uint8_t *bindkey, uint64_t address) {
if ((size != 19) && ((size < 22) || (size > XIAOMI_MAX_ENCRYPTED_FRAME_SIZE))) {
ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%u)!", static_cast<unsigned>(size));
ESP_LOGVV_HEX(TAG, raw, size, XIAOMI_MAX_LOG_BYTES, " Packet : %s");
return false;
}
@@ -310,17 +318,17 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, c
.tagsize = 4,
.ivsize = 12};
vector.datasize = (raw.size() == 19) ? raw.size() - 12 : raw.size() - 18;
int cipher_pos = (raw.size() == 19) ? 5 : 11;
vector.datasize = (size == 19) ? size - 12 : size - 18;
const size_t cipher_pos = (size == 19) ? 5 : 11;
const uint8_t *v = raw.data();
const uint8_t *v = raw;
memcpy(vector.key, bindkey, vector.keysize);
memcpy(vector.ciphertext, v + cipher_pos, vector.datasize);
memcpy(vector.tag, v + raw.size() - vector.tagsize, vector.tagsize);
memcpy(vector.iv, mac_reverse, 6); // MAC address reverse
memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1)
memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter
memcpy(vector.tag, v + size - vector.tagsize, vector.tagsize);
memcpy(vector.iv, mac_reverse, 6); // MAC address reverse
memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1)
memcpy(vector.iv + 9, v + size - 7, 3); // payload counter
#ifdef XIAOMI_CRYPTO_PSA
// PSA AEAD expects ciphertext + tag concatenated
@@ -369,7 +377,7 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, c
char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " MAC address : %s", mac_buf);
ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size()));
ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw, size));
ESP_LOGVV(TAG, " Key : %s", format_hex_pretty_to(hex_buf, vector.key, vector.keysize));
ESP_LOGVV(TAG, " Iv : %s", format_hex_pretty_to(hex_buf, vector.iv, vector.ivsize));
ESP_LOGVV(TAG, " Cipher : %s", format_hex_pretty_to(hex_buf, vector.ciphertext, vector.datasize));
@@ -377,71 +385,125 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, c
return false;
}
// replace encrypted payload with plaintext
uint8_t *p = vector.plaintext;
for (std::vector<uint8_t>::iterator it = raw.begin() + cipher_pos; it != raw.begin() + cipher_pos + vector.datasize;
++it) {
*it = *(p++);
}
// clear encrypted flag
memcpy(raw + cipher_pos, vector.plaintext, vector.datasize);
raw[0] &= ~0x08;
ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): authenticated decryption passed.");
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d",
format_hex_pretty_to(hex_buf, raw.data() + cipher_pos, vector.datasize), static_cast<int>(raw[4]));
ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d", format_hex_pretty_to(hex_buf, raw + cipher_pos, vector.datasize),
static_cast<int>(raw[4]));
return true;
}
bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, const uint64_t &address) {
return decrypt_xiaomi_payload(raw.data(), raw.size(), bindkey, address);
}
void report_xiaomi_results(const XiaomiParseResult &result, const char *address) {
ESP_LOGD(TAG, "Got Xiaomi %s (%s):", result.name, address);
if (result.temperature.has_value()) {
ESP_LOGD(TAG, " Temperature: %.1f°C", *result.temperature);
}
if (result.humidity.has_value()) {
ESP_LOGD(TAG, " Humidity: %.1f%%", *result.humidity);
}
if (result.battery_level.has_value()) {
ESP_LOGD(TAG, " Battery Level: %.0f%%", *result.battery_level);
}
if (result.conductivity.has_value()) {
ESP_LOGD(TAG, " Conductivity: %.0fµS/cm", *result.conductivity);
}
if (result.illuminance.has_value()) {
ESP_LOGD(TAG, " Illuminance: %.0flx", *result.illuminance);
}
if (result.moisture.has_value()) {
ESP_LOGD(TAG, " Moisture: %.0f%%", *result.moisture);
}
if (result.tablet.has_value()) {
ESP_LOGD(TAG, " Mosquito tablet: %.0f%%", *result.tablet);
}
if (result.is_active.has_value()) {
ESP_LOGD(TAG, " Repellent: %s", *result.is_active ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));
}
if (result.has_motion.has_value()) {
ESP_LOGD(TAG, " Motion: %s", *result.has_motion ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"));
}
if (result.is_light.has_value()) {
ESP_LOGD(TAG, " Light: %s", *result.is_light ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));
}
if (result.button_press.has_value()) {
ESP_LOGD(TAG, " Button: %s", *result.button_press ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL(""));
}
}
bool report_xiaomi_results(const optional<XiaomiParseResult> &result, const char *address) {
if (!result.has_value()) {
ESP_LOGVV(TAG, "report_xiaomi_results(): no results available.");
return false;
}
ESP_LOGD(TAG, "Got Xiaomi %s (%s):", result->name.c_str(), address);
if (result->temperature.has_value()) {
ESP_LOGD(TAG, " Temperature: %.1f°C", *result->temperature);
}
if (result->humidity.has_value()) {
ESP_LOGD(TAG, " Humidity: %.1f%%", *result->humidity);
}
if (result->battery_level.has_value()) {
ESP_LOGD(TAG, " Battery Level: %.0f%%", *result->battery_level);
}
if (result->conductivity.has_value()) {
ESP_LOGD(TAG, " Conductivity: %.0fµS/cm", *result->conductivity);
}
if (result->illuminance.has_value()) {
ESP_LOGD(TAG, " Illuminance: %.0flx", *result->illuminance);
}
if (result->moisture.has_value()) {
ESP_LOGD(TAG, " Moisture: %.0f%%", *result->moisture);
}
if (result->tablet.has_value()) {
ESP_LOGD(TAG, " Mosquito tablet: %.0f%%", *result->tablet);
}
if (result->is_active.has_value()) {
ESP_LOGD(TAG, " Repellent: %s", (*result->is_active) ? "on" : "off");
}
if (result->has_motion.has_value()) {
ESP_LOGD(TAG, " Motion: %s", (*result->has_motion) ? "yes" : "no");
}
if (result->is_light.has_value()) {
ESP_LOGD(TAG, " Light: %s", (*result->is_light) ? "on" : "off");
}
if (result->button_press.has_value()) {
ESP_LOGD(TAG, " Button: %s", (*result->button_press) ? "pressed" : "");
}
report_xiaomi_results(*result, address);
return true;
}
bool XiaomiDevice::parse_xiaomi_device_(const ble_device_base::ESPBTDevice &device, const uint8_t *bindkey) {
if (device.address_uint64() != this->address_)
return false;
bool success = false;
for (const auto &service_data : device.get_service_datas()) {
if (!service_data.uuid.contains(0x95, 0xFE))
continue;
const uint8_t *raw = service_data.data.data();
const size_t size = service_data.data.size();
// Devices repeat each frame many times; drop repeats before parsing the header
if (size >= 5 && this->last_frame_count_.has_value() && *this->last_frame_count_ == raw[4]) {
ESP_LOGVV(TAG, "Duplicate frame %u.", raw[4]);
continue;
}
auto res = parse_xiaomi_header(raw, size);
if (!res.has_value())
continue;
// Decrypt a copy: the tracker hands the same service data to every listener
uint8_t plaintext[XIAOMI_MAX_ENCRYPTED_FRAME_SIZE];
if (res->has_encryption) {
if (bindkey == nullptr) {
ESP_LOGVV(TAG, "Encrypted frame but no bindkey.");
continue;
}
if (size > sizeof(plaintext))
continue;
memcpy(plaintext, raw, size);
if (!decrypt_xiaomi_payload(plaintext, size, bindkey, this->address_))
continue;
raw = plaintext;
}
// Only a frame that decrypted (or was plain) advances the duplicate filter
this->last_frame_count_ = raw[4];
if (!parse_xiaomi_message(raw, size, *res))
continue;
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
report_xiaomi_results(*res, device.address_str_to(addr_buf));
#endif
this->publish_result(*res);
success = true;
}
return success;
}
void XiaomiEncryptedDevice::dump_bindkey_(const char *tag) const {
char bindkey_hex[format_hex_pretty_size(sizeof(this->bindkey_))];
ESP_LOGCONFIG(tag, " Bindkey: %s", format_hex_pretty_to(bindkey_hex, this->bindkey_, sizeof(this->bindkey_), '.'));
}
void XiaomiEncryptedDevice::set_bindkey(std::initializer_list<uint8_t> bindkey) {
memcpy(this->bindkey_, bindkey.begin(), std::min(bindkey.size(), sizeof(this->bindkey_)));
}
bool XiaomiListener::parse_device(const ble_device_base::ESPBTDevice &device) {
// Previously the message was parsed twice per packet, once by XiaomiListener::parse_device()
// and then again by the respective device class's parse_device() function. Parsing the header
+44 -4
View File
@@ -1,7 +1,9 @@
#pragma once
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/core/component.h"
#include "esphome/core/optional.h"
#include <initializer_list>
#include <vector>
@@ -29,7 +31,7 @@ struct XiaomiParseResult {
TYPE_CGPR1,
TYPE_RTCGQ02LM,
} type;
std::string name;
const char *name;
optional<float> temperature;
optional<float> humidity;
optional<float> moisture;
@@ -43,8 +45,6 @@ struct XiaomiParseResult {
optional<bool> has_motion;
optional<bool> is_light;
optional<bool> button_press;
bool has_data; // 0x40
bool has_capability; // 0x20
bool has_encryption; // 0x08
bool is_duplicate;
int raw_offset;
@@ -64,12 +64,52 @@ struct XiaomiAESVector {
size_t ivsize;
};
/// Largest MiBeacon frame decrypt_xiaomi_payload() accepts.
static constexpr size_t XIAOMI_MAX_ENCRYPTED_FRAME_SIZE = 24;
bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result);
bool parse_xiaomi_message(const uint8_t *message, size_t size, XiaomiParseResult &result);
bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult &result);
/// Parses the frame control and product id; the caller checks the 0xFE95 UUID and filters duplicates.
optional<XiaomiParseResult> parse_xiaomi_header(const uint8_t *raw, size_t size);
optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceData &service_data);
/// Decrypts the payload of `raw` in place and clears its encryption flag.
bool decrypt_xiaomi_payload(uint8_t *raw, size_t size, const uint8_t *bindkey, uint64_t address);
bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, const uint64_t &address);
void report_xiaomi_results(const XiaomiParseResult &result, const char *address);
bool report_xiaomi_results(const optional<XiaomiParseResult> &result, const char *address);
/// A Xiaomi MiBeacon device: matches its address, decodes each frame once and hands it to publish_result().
class XiaomiDevice : public ble_device_base::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { this->address_ = address; }
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
return this->parse_xiaomi_device_(device, nullptr);
}
protected:
/// Encrypted frames are dropped when `bindkey` is null.
bool parse_xiaomi_device_(const ble_device_base::ESPBTDevice &device, const uint8_t *bindkey);
virtual void publish_result(const XiaomiParseResult &result) = 0;
uint64_t address_{0};
optional<uint8_t> last_frame_count_;
};
/// A XiaomiDevice whose frames are encrypted with a bindkey.
class XiaomiEncryptedDevice : public XiaomiDevice {
public:
void set_bindkey(std::initializer_list<uint8_t> bindkey);
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
return this->parse_xiaomi_device_(device, this->bindkey_);
}
protected:
void dump_bindkey_(const char *tag) const;
uint8_t bindkey_[16]{};
};
class XiaomiListener final : public ble_device_base::ESPBTDeviceListener {
public:
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
@@ -0,0 +1,114 @@
#include "esphome/components/xiaomi_ble/xiaomi_ble.h"
#include <gtest/gtest.h>
#include <cmath>
#include <cstring>
#include <initializer_list>
#include <vector>
namespace esphome::xiaomi_ble::testing {
// Least significant byte first, as from_scan_result() takes them
static constexpr uint8_t MAC[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
static constexpr uint8_t OTHER_MAC[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x77};
// LYWSDCGQ, frame count 5: temperature 22.5 °C, humidity 30.0 %
static const std::vector<uint8_t> PLAIN_FRAME = {0x50, 0x20, 0xAA, 0x01, 0x05, 0x11, 0x22, 0x33, 0x44,
0x55, 0x66, 0x0D, 0x10, 0x04, 0xE1, 0x00, 0x2C, 0x01};
// LYWSD03MMC, frame count 7, humidity 30.0 %, encrypted with BINDKEY for MAC
static const std::vector<uint8_t> ENCRYPTED_FRAME = {0x58, 0x58, 0x5B, 0x05, 0x07, 0x11, 0x22, 0x33,
0x44, 0x55, 0x66, 0x19, 0x5F, 0x86, 0x6A, 0x56,
0x01, 0x02, 0x03, 0xA4, 0x77, 0xE8, 0x30};
static constexpr std::initializer_list<uint8_t> BINDKEY = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
static constexpr std::initializer_list<uint8_t> WRONG_BINDKEY = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
template<typename Base> class Recording : public Base {
public:
std::vector<XiaomiParseResult> results;
protected:
void publish_result(const XiaomiParseResult &result) override { this->results.push_back(result); }
};
using RecordingDevice = Recording<XiaomiDevice>;
using RecordingEncryptedDevice = Recording<XiaomiEncryptedDevice>;
static ble_device_base::ESPBTDevice device_from(const uint8_t *mac, const std::vector<uint8_t> &frame) {
std::vector<uint8_t> adv = {static_cast<uint8_t>(frame.size() + 3), 0x16, 0x95, 0xFE};
adv.insert(adv.end(), frame.begin(), frame.end());
ble_device_base::ESPBTDevice device;
device.from_scan_result(mac, -60, 0, adv.data(), static_cast<uint16_t>(adv.size()));
return device;
}
TEST(XiaomiDevice, PublishesPlainFrame) {
auto device = device_from(MAC, PLAIN_FRAME);
RecordingDevice sensor;
sensor.set_address(device.address_uint64());
EXPECT_TRUE(sensor.parse_device(device));
ASSERT_EQ(sensor.results.size(), 1u);
EXPECT_EQ(sensor.results[0].type, XiaomiParseResult::TYPE_LYWSDCGQ);
EXPECT_FLOAT_EQ(sensor.results[0].temperature.value_or(NAN), 22.5f);
EXPECT_FLOAT_EQ(sensor.results[0].humidity.value_or(NAN), 30.0f);
}
TEST(XiaomiDevice, IgnoresOtherAddress) {
RecordingDevice sensor;
sensor.set_address(device_from(OTHER_MAC, PLAIN_FRAME).address_uint64());
EXPECT_FALSE(sensor.parse_device(device_from(MAC, PLAIN_FRAME)));
EXPECT_TRUE(sensor.results.empty());
}
TEST(XiaomiDevice, DropsRepeatedFrameCount) {
auto device = device_from(MAC, PLAIN_FRAME);
RecordingDevice sensor;
sensor.set_address(device.address_uint64());
EXPECT_TRUE(sensor.parse_device(device));
EXPECT_FALSE(sensor.parse_device(device));
EXPECT_EQ(sensor.results.size(), 1u);
}
TEST(XiaomiDevice, FrameCountIsPerDevice) {
auto device_a = device_from(MAC, PLAIN_FRAME);
auto device_b = device_from(OTHER_MAC, PLAIN_FRAME);
RecordingDevice sensor_a;
RecordingDevice sensor_b;
sensor_a.set_address(device_a.address_uint64());
sensor_b.set_address(device_b.address_uint64());
EXPECT_TRUE(sensor_a.parse_device(device_a));
EXPECT_TRUE(sensor_b.parse_device(device_b));
}
TEST(XiaomiDevice, DecryptsWithoutChangingSharedServiceData) {
auto device = device_from(MAC, ENCRYPTED_FRAME);
RecordingEncryptedDevice sensor;
sensor.set_address(device.address_uint64());
sensor.set_bindkey(BINDKEY);
EXPECT_TRUE(sensor.parse_device(device));
ASSERT_EQ(sensor.results.size(), 1u);
EXPECT_EQ(sensor.results[0].type, XiaomiParseResult::TYPE_LYWSD03MMC);
EXPECT_FLOAT_EQ(sensor.results[0].humidity.value_or(NAN), 30.0f);
EXPECT_EQ(device.get_service_datas()[0].data, ENCRYPTED_FRAME);
}
TEST(XiaomiDevice, DropsEncryptedFrameWithoutBindkey) {
auto device = device_from(MAC, ENCRYPTED_FRAME);
RecordingDevice sensor;
sensor.set_address(device.address_uint64());
EXPECT_FALSE(sensor.parse_device(device));
}
TEST(XiaomiDevice, FailedDecryptionDoesNotAdvanceFrameCount) {
auto device = device_from(MAC, ENCRYPTED_FRAME);
RecordingEncryptedDevice sensor;
sensor.set_address(device.address_uint64());
sensor.set_bindkey(WRONG_BINDKEY);
EXPECT_FALSE(sensor.parse_device(device));
sensor.set_bindkey(BINDKEY);
EXPECT_TRUE(sensor.parse_device(device));
}
} // namespace esphome::xiaomi_ble::testing