mirror of
https://github.com/esphome/esphome.git
synced 2026-10-08 03:54:03 +00:00
[xiaomi_mccgq02hl] Add Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL) (#20104)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
Claude Opus 5.5
J. Nick Koston
parent
cf96c53279
commit
006f5f0fc9
@@ -657,6 +657,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51
|
||||
esphome/components/xiaomi_hhccjcy10/* @fariouche
|
||||
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
|
||||
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
|
||||
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
|
||||
esphome/components/xiaomi_mhoc303/* @drug123
|
||||
esphome/components/xiaomi_mhoc401/* @vevsvevs
|
||||
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@ahpohl", "@morph027"]
|
||||
@@ -0,0 +1,65 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, ble_device_base, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_BINDKEY,
|
||||
CONF_LIGHT,
|
||||
CONF_MAC_ADDRESS,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_LIGHT,
|
||||
DEVICE_CLASS_OPENING,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ble_device_base", "sensor"]
|
||||
|
||||
xiaomi_mccgq02hl_ns = cg.esphome_ns.namespace("xiaomi_mccgq02hl")
|
||||
XiaomiMCCGQ02HL = xiaomi_mccgq02hl_ns.class_(
|
||||
"XiaomiMCCGQ02HL",
|
||||
binary_sensor.BinarySensor,
|
||||
cg.Component,
|
||||
ble_device_base.ESPBTDeviceListener,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("xiaomi_mccgq02hl"),
|
||||
binary_sensor.binary_sensor_schema(
|
||||
XiaomiMCCGQ02HL, device_class=DEVICE_CLASS_OPENING
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Required(CONF_BINDKEY): cv.bind_key,
|
||||
cv.Optional(CONF_LIGHT): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_LIGHT
|
||||
),
|
||||
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_BATTERY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
cg.add(var.set_bindkey(config[CONF_BINDKEY]))
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level)
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_LIGHT, var.set_light)
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "xiaomi_mccgq02hl.h"
|
||||
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::xiaomi_mccgq02hl {
|
||||
|
||||
static const char *const TAG = "xiaomi_mccgq02hl";
|
||||
|
||||
static constexpr uint16_t PRODUCT_ID = 0x098b;
|
||||
|
||||
// MiBeacon frame control, low byte (raw[0])
|
||||
static constexpr uint8_t FC_ENCRYPTED = 0x08;
|
||||
static constexpr uint8_t FC_MAC_INCLUDED = 0x10;
|
||||
static constexpr uint8_t FC_CAPABILITY = 0x20;
|
||||
static constexpr uint8_t FC_OBJECT = 0x40;
|
||||
|
||||
// Encrypted frames end in a 3-byte extended counter and a 4-byte MIC.
|
||||
static constexpr size_t MIC_SIZE = 4;
|
||||
static constexpr size_t ENCRYPTED_TRAILER = 3 + MIC_SIZE;
|
||||
// Largest encrypted payload accepted; MCCGQ02HL objects are 4 bytes.
|
||||
static constexpr size_t MAX_PAYLOAD_SIZE = 16;
|
||||
|
||||
// Object ids. The door and light objects come in both the 0x00xx and the
|
||||
// 0x10xx flavour depending on firmware; the semantics are identical.
|
||||
static constexpr uint16_t OBJ_LIGHT = 0x0018;
|
||||
static constexpr uint16_t OBJ_LIGHT_ALT = 0x1018;
|
||||
static constexpr uint16_t OBJ_DOOR = 0x0019;
|
||||
static constexpr uint16_t OBJ_DOOR_ALT = 0x1019;
|
||||
static constexpr uint16_t OBJ_BATTERY = 0x100A;
|
||||
static constexpr uint16_t OBJ_BATTERY_ALT = 0x4803;
|
||||
|
||||
void XiaomiMCCGQ02HL::dump_config() {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
ble_device_base::uint64_to_mac_msb_first(this->address_, mac);
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Xiaomi MCCGQ02HL\n"
|
||||
" MAC Address: %s",
|
||||
format_mac_addr_upper(mac, mac_buf));
|
||||
LOG_BINARY_SENSOR(" ", "Opening", this);
|
||||
LOG_BINARY_SENSOR(" ", "Light", this->light_);
|
||||
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
|
||||
}
|
||||
|
||||
void XiaomiMCCGQ02HL::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); }
|
||||
|
||||
bool XiaomiMCCGQ02HL::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_)
|
||||
return false;
|
||||
|
||||
bool success = false;
|
||||
for (auto &service_data : device.get_service_datas()) {
|
||||
if (!service_data.uuid.contains(0x95, 0xFE))
|
||||
continue;
|
||||
|
||||
Reading reading;
|
||||
if (!this->parse_service_data_(service_data.data, reading))
|
||||
continue;
|
||||
|
||||
ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", this->get_name().c_str(),
|
||||
reading.open.has_value() ? (*reading.open ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"))
|
||||
: LOG_STR_LITERAL("-"),
|
||||
reading.light.has_value() ? (*reading.light ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"))
|
||||
: LOG_STR_LITERAL("-"),
|
||||
reading.battery_level.has_value() ? LOG_STR_LITERAL("updated") : LOG_STR_LITERAL("-"));
|
||||
|
||||
if (reading.open.has_value())
|
||||
this->publish_state(*reading.open);
|
||||
if (reading.light.has_value() && this->light_ != nullptr)
|
||||
this->light_->publish_state(*reading.light);
|
||||
if (reading.battery_level.has_value() && this->battery_level_ != nullptr)
|
||||
this->battery_level_->publish_state(*reading.battery_level);
|
||||
success = true;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
bool XiaomiMCCGQ02HL::decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const {
|
||||
uint8_t nonce[MAC_ADDRESS_SIZE + 6];
|
||||
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++)
|
||||
nonce[i] = static_cast<uint8_t>(this->address_ >> (i * 8)); // MAC, reversed
|
||||
memcpy(nonce + MAC_ADDRESS_SIZE, frame + 2, 3); // product id + frame count
|
||||
memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + size - ENCRYPTED_TRAILER, 3); // extended counter
|
||||
static constexpr uint8_t AUTH_DATA[1] = {0x11};
|
||||
return ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce, sizeof(nonce), AUTH_DATA, sizeof(AUTH_DATA),
|
||||
frame + offset, size - offset - ENCRYPTED_TRAILER, plaintext,
|
||||
frame + size - MIC_SIZE, MIC_SIZE);
|
||||
}
|
||||
|
||||
bool XiaomiMCCGQ02HL::parse_service_data_(const std::vector<uint8_t> &data, Reading &reading) {
|
||||
const size_t size = data.size();
|
||||
if (size < 5)
|
||||
return false;
|
||||
const uint8_t *raw = data.data();
|
||||
|
||||
const uint8_t fc = raw[0];
|
||||
if (!(fc & FC_OBJECT))
|
||||
return false;
|
||||
if (encode_uint16(raw[3], raw[2]) != PRODUCT_ID) {
|
||||
ESP_LOGVV(TAG, "Not an MCCGQ02HL frame (product id %02X%02X).", raw[3], raw[2]);
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t frame_count = raw[4];
|
||||
if (this->last_frame_count_.has_value() && *this->last_frame_count_ == frame_count) {
|
||||
ESP_LOGVV(TAG, "Duplicate frame %u.", frame_count);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The bindkey is required, so plaintext frames are never trusted
|
||||
if (!(fc & FC_ENCRYPTED)) {
|
||||
ESP_LOGVV(TAG, "Ignoring unencrypted frame %u.", frame_count);
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t offset = 5 + ((fc & FC_MAC_INCLUDED) ? 6 : 0) + ((fc & FC_CAPABILITY) ? 1 : 0);
|
||||
if (size <= offset + ENCRYPTED_TRAILER || size - offset - ENCRYPTED_TRAILER > MAX_PAYLOAD_SIZE) {
|
||||
ESP_LOGW(TAG, "Unsupported encrypted frame layout (fc=0x%02X, %u bytes).", fc, (unsigned) size);
|
||||
return false;
|
||||
}
|
||||
uint8_t plaintext[MAX_PAYLOAD_SIZE];
|
||||
if (!this->decrypt_(raw, size, offset, plaintext)) {
|
||||
ESP_LOGW(TAG, "Decryption failed (%u-byte frame) -- check the bindkey.", (unsigned) size);
|
||||
return false;
|
||||
}
|
||||
// Only an authenticated frame may advance the duplicate filter. There is no replay protection: the
|
||||
// counter is not persisted and restarts on a battery change, so a strictly increasing check could lock
|
||||
// the sensor out until the next reboot.
|
||||
this->last_frame_count_ = frame_count;
|
||||
return this->parse_objects_(plaintext, size - offset - ENCRYPTED_TRAILER, reading);
|
||||
}
|
||||
|
||||
bool XiaomiMCCGQ02HL::parse_objects_(const uint8_t *payload, size_t length, Reading &reading) {
|
||||
bool found = false;
|
||||
// Each object: id (uint16 LE), length (uint8), value.
|
||||
while (length >= 3) {
|
||||
const uint16_t id = encode_uint16(payload[1], payload[0]);
|
||||
const uint8_t len = payload[2];
|
||||
if (len < 1 || length < 3u + len)
|
||||
break;
|
||||
const uint8_t *value = payload + 3;
|
||||
|
||||
if ((id == OBJ_DOOR || id == OBJ_DOOR_ALT) && len == 1) {
|
||||
// 0 = open, 1 = closed, 2 = left open past timeout, 3 = device reset
|
||||
if (value[0] <= 2) {
|
||||
reading.open = value[0] != 1;
|
||||
found = true;
|
||||
}
|
||||
} else if ((id == OBJ_LIGHT || id == OBJ_LIGHT_ALT) && len == 1) {
|
||||
reading.light = value[0] != 0;
|
||||
found = true;
|
||||
} else if ((id == OBJ_BATTERY || id == OBJ_BATTERY_ALT) && len == 1) {
|
||||
reading.battery_level = value[0];
|
||||
found = true;
|
||||
} else {
|
||||
ESP_LOGVV(TAG, "Ignoring object 0x%04X (%u bytes).", id, len);
|
||||
}
|
||||
|
||||
payload += 3 + len;
|
||||
length -= 3 + len;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
} // namespace esphome::xiaomi_mccgq02hl
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/optional.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::xiaomi_mccgq02hl {
|
||||
|
||||
// Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL), MiBeacon product id 0x098b.
|
||||
//
|
||||
// Self-contained on purpose: xiaomi_ble's parse_xiaomi_header() does not know
|
||||
// this product id and parse_xiaomi_value() does not know the door object, so
|
||||
// this class does its own header and object parsing and decryption.
|
||||
class XiaomiMCCGQ02HL final : public Component,
|
||||
public binary_sensor::BinarySensorInitiallyOff,
|
||||
public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
void set_address(uint64_t address) { this->address_ = address; }
|
||||
void set_bindkey(const char *bindkey);
|
||||
void set_light(binary_sensor::BinarySensor *light) { this->light_ = light; }
|
||||
void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; }
|
||||
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
struct Reading {
|
||||
optional<bool> open;
|
||||
optional<bool> light;
|
||||
optional<float> battery_level;
|
||||
};
|
||||
|
||||
bool decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const;
|
||||
bool parse_service_data_(const std::vector<uint8_t> &data, Reading &reading);
|
||||
bool parse_objects_(const uint8_t *payload, size_t length, Reading &reading);
|
||||
|
||||
uint64_t address_{0};
|
||||
uint8_t bindkey_[16]{};
|
||||
// Per instance, unlike xiaomi_ble's function-static counter which is shared
|
||||
// by every Xiaomi device on the node.
|
||||
optional<uint8_t> last_frame_count_;
|
||||
binary_sensor::BinarySensor *light_{nullptr};
|
||||
sensor::Sensor *battery_level_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::xiaomi_mccgq02hl
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "../common.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace esphome::xiaomi_mccgq02hl::testing {
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, DecodesOpenAndClosed) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN)));
|
||||
EXPECT_TRUE(h.sensor.state);
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, CLOSED)));
|
||||
EXPECT_FALSE(h.sensor.state);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, LeftOpenIsReportedAsOpen) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, LEFT_OPEN)));
|
||||
EXPECT_TRUE(h.sensor.state);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, DeviceResetIsIgnored) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN)));
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, DEVICE_RESET)));
|
||||
EXPECT_TRUE(h.sensor.state);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, DecodesLight) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, BRIGHT)));
|
||||
EXPECT_TRUE(h.light.state);
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, DARK)));
|
||||
EXPECT_FALSE(h.light.state);
|
||||
EXPECT_EQ(h.door_changes, 0); // light frames carry no door state
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, DecodesBatteryLevel) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, BATTERY)));
|
||||
EXPECT_FLOAT_EQ(h.battery_level.state, 87.0f);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, RepeatedFrameIsDropped) {
|
||||
Harness h;
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN)));
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, OPEN)));
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, RejectsBadMic) {
|
||||
Harness h;
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, BAD_MIC)));
|
||||
EXPECT_EQ(h.door_changes, 0);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, BadMicDoesNotBlockTheNextFrame) {
|
||||
Harness h;
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, BAD_MIC)));
|
||||
ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN)));
|
||||
EXPECT_TRUE(h.sensor.state);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, RejectsPlaintextFrame) {
|
||||
Harness h;
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, PLAINTEXT_OPEN)));
|
||||
EXPECT_EQ(h.door_changes, 0);
|
||||
}
|
||||
|
||||
TEST(XiaomiMCCGQ02HL, IgnoresOtherAddress) {
|
||||
Harness h;
|
||||
EXPECT_FALSE(h.sensor.parse_device(advert(OTHER_ADDRESS, OPEN)));
|
||||
EXPECT_EQ(h.door_changes, 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::xiaomi_mccgq02hl::testing
|
||||
@@ -0,0 +1,9 @@
|
||||
binary_sensor:
|
||||
- platform: xiaomi_mccgq02hl
|
||||
name: Mi Door Sensor 2
|
||||
mac_address: E4:AA:EC:01:02:03
|
||||
bindkey: "48403ebe2d385db8d0c187f81e62cb64"
|
||||
light:
|
||||
name: Mi Door Sensor 2 Light
|
||||
battery_level:
|
||||
name: Mi Door Sensor 2 Battery Level
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h"
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::xiaomi_mccgq02hl::testing {
|
||||
|
||||
using Frame = std::array<uint8_t, 22>;
|
||||
using PlainFrame = std::array<uint8_t, 15>;
|
||||
|
||||
static constexpr uint64_t ADDRESS = 0xE4AAEC010203ULL;
|
||||
static constexpr uint64_t OTHER_ADDRESS = 0xE4AAEC010204ULL;
|
||||
static constexpr const char *BINDKEY = "48403ebe2d385db8d0c187f81e62cb64";
|
||||
|
||||
// MiBeacon v5 frames (MAC included, AES-CCM encrypted) built with Python's cryptography AESCCM using ADDRESS and
|
||||
// BINDKEY. Each frame has a different frame count.
|
||||
// Door open (object 0x0019)
|
||||
static constexpr Frame OPEN = {0x58, 0x59, 0x8b, 0x09, 0x01, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0xe4, 0xd9, 0x75, 0x9f, 0x01, 0x00, 0x00, 0x7e, 0x61, 0x36, 0x49};
|
||||
// Door closed
|
||||
static constexpr Frame CLOSED = {0x58, 0x59, 0x8b, 0x09, 0x02, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0x52, 0x9c, 0x58, 0x6e, 0x01, 0x00, 0x00, 0x69, 0x45, 0x8f, 0xec};
|
||||
// Door left open past the device timeout (object 0x1019)
|
||||
static constexpr Frame LEFT_OPEN = {0x58, 0x59, 0x8b, 0x09, 0x03, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0x38, 0xe3, 0xbd, 0x82, 0x01, 0x00, 0x00, 0x74, 0x97, 0x75, 0x9d};
|
||||
// Device reset, must be ignored
|
||||
static constexpr Frame DEVICE_RESET = {0x58, 0x59, 0x8b, 0x09, 0x04, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0x4b, 0x18, 0xdb, 0x8e, 0x01, 0x00, 0x00, 0x48, 0xa6, 0x52, 0x99};
|
||||
// Light above threshold (object 0x1018)
|
||||
static constexpr Frame BRIGHT = {0x58, 0x59, 0x8b, 0x09, 0x05, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0xe3, 0x96, 0x1c, 0xa4, 0x01, 0x00, 0x00, 0x4b, 0x43, 0xca, 0x14};
|
||||
// Light below threshold (object 0x0018)
|
||||
static constexpr Frame DARK = {0x58, 0x59, 0x8b, 0x09, 0x06, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0x78, 0x07, 0x43, 0xb5, 0x01, 0x00, 0x00, 0x69, 0x62, 0x88, 0x89};
|
||||
// Battery 87 % (object 0x100A)
|
||||
static constexpr Frame BATTERY = {0x58, 0x59, 0x8b, 0x09, 0x07, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0xdc, 0x88, 0xf2, 0xd0, 0x01, 0x00, 0x00, 0x39, 0xe8, 0x96, 0x08};
|
||||
// OPEN with a corrupted MIC (same frame count as OPEN)
|
||||
static constexpr Frame BAD_MIC = {0x58, 0x59, 0x8b, 0x09, 0x01, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4,
|
||||
0xe4, 0xd9, 0x75, 0x9f, 0x01, 0x00, 0x00, 0x7e, 0x61, 0x36, 0xb6};
|
||||
|
||||
// OPEN sent without encryption (MAC included), which must be rejected
|
||||
static constexpr PlainFrame PLAINTEXT_OPEN = {0x50, 0x59, 0x8b, 0x09, 0x09, 0x03, 0x02, 0x01,
|
||||
0xec, 0xaa, 0xe4, 0x19, 0x00, 0x01, 0x00};
|
||||
|
||||
template<size_t N> ble_device_base::ESPBTDevice advert(uint64_t address, const std::array<uint8_t, N> &frame) {
|
||||
// Service data AD structure for UUID 0xFE95
|
||||
std::vector<uint8_t> adv = {static_cast<uint8_t>(frame.size() + 3), 0x16, 0x95, 0xFE};
|
||||
adv.insert(adv.end(), frame.begin(), frame.end());
|
||||
uint8_t mac[6];
|
||||
for (size_t i = 0; i < 6; i++)
|
||||
mac[i] = static_cast<uint8_t>(address >> (i * 8));
|
||||
ble_device_base::ESPBTDevice device;
|
||||
device.from_scan_result(mac, -60, 0, adv.data(), static_cast<uint16_t>(adv.size()));
|
||||
return device;
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
Harness() {
|
||||
this->sensor.set_address(ADDRESS);
|
||||
this->sensor.set_bindkey(BINDKEY);
|
||||
this->sensor.set_light(&this->light);
|
||||
this->sensor.set_battery_level(&this->battery_level);
|
||||
// The door sensor starts as "closed", so count changes to see whether anything was published
|
||||
this->sensor.add_full_state_callback([this](optional<bool>, optional<bool>) { this->door_changes++; });
|
||||
}
|
||||
|
||||
XiaomiMCCGQ02HL sensor;
|
||||
binary_sensor::BinarySensor light;
|
||||
sensor::Sensor battery_level;
|
||||
int door_changes{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::xiaomi_mccgq02hl::testing
|
||||
@@ -0,0 +1,14 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
binary_sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: xiaomi_mccgq02hl
|
||||
ble_hub_id: ble_tracker_hub
|
||||
name: Mi Door Sensor 2
|
||||
mac_address: E4:AA:EC:01:02:03
|
||||
bindkey: "48403ebe2d385db8d0c187f81e62cb64"
|
||||
light:
|
||||
name: Mi Door Sensor 2 Light
|
||||
battery_level:
|
||||
name: Mi Door Sensor 2 Battery Level
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
|
||||
xiaomi_mccgq02hl: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
xiaomi_mccgq02hl: !include common-ln.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
|
||||
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
|
||||
# on a non-esp32 platform; codegen and compilation are not exercised here.
|
||||
bk72xx_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
binary_sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: xiaomi_mccgq02hl
|
||||
ble_hub_id: ble_tracker_hub
|
||||
name: Mi Door Sensor 2
|
||||
mac_address: E4:AA:EC:01:02:03
|
||||
bindkey: "48403ebe2d385db8d0c187f81e62cb64"
|
||||
light:
|
||||
name: Mi Door Sensor 2 Light
|
||||
battery_level:
|
||||
name: Mi Door Sensor 2 Battery Level
|
||||
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: xiaomi_mccgq02hl
|
||||
name: Mi Door Sensor 2 Implicit
|
||||
mac_address: E4:AA:EC:01:02:04
|
||||
bindkey: "48403ebe2d385db8d0c187f81e62cb64"
|
||||
Reference in New Issue
Block a user