mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 23:42:15 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb0517f48 | ||
|
|
58ac35702e | ||
|
|
71bb2488a7 | ||
|
|
dc5f91c9e0 |
No files matched your search
@@ -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)))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user