[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:
Alexander Pohl
2026-10-07 07:09:35 -10:00
committed by GitHub
co-authored by Claude Opus 5.5 J. Nick Koston
parent cf96c53279
commit 006f5f0fc9
12 changed files with 487 additions and 0 deletions
+1
View File
@@ -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"