ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
xiaomi_mccgq02hl.cpp
Go to the documentation of this file.
1#include "xiaomi_mccgq02hl.h"
4#include "esphome/core/log.h"
5
6#include <cstring>
7
9
10ESPHOME_LOG_TAG(TAG, "xiaomi_mccgq02hl");
11
12static constexpr uint16_t PRODUCT_ID = 0x098b;
13
14// MiBeacon frame control, low byte (raw[0])
15static constexpr uint8_t FC_ENCRYPTED = 0x08;
16static constexpr uint8_t FC_MAC_INCLUDED = 0x10;
17static constexpr uint8_t FC_CAPABILITY = 0x20;
18static constexpr uint8_t FC_OBJECT = 0x40;
19
20// Encrypted frames end in a 3-byte extended counter and a 4-byte MIC.
21static constexpr size_t MIC_SIZE = 4;
22static constexpr size_t ENCRYPTED_TRAILER = 3 + MIC_SIZE;
23// Largest encrypted payload accepted; MCCGQ02HL objects are 4 bytes.
24static constexpr size_t MAX_PAYLOAD_SIZE = 16;
25
26// Object ids. The door and light objects come in both the 0x00xx and the
27// 0x10xx flavour depending on firmware; the semantics are identical.
28static constexpr uint16_t OBJ_LIGHT = 0x0018;
29static constexpr uint16_t OBJ_LIGHT_ALT = 0x1018;
30static constexpr uint16_t OBJ_DOOR = 0x0019;
31static constexpr uint16_t OBJ_DOOR_ALT = 0x1019;
32static constexpr uint16_t OBJ_BATTERY = 0x100A;
33static constexpr uint16_t OBJ_BATTERY_ALT = 0x4803;
34
36 uint8_t mac[MAC_ADDRESS_SIZE];
38 char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
39 ESP_LOGCONFIG(TAG,
40 "Xiaomi MCCGQ02HL\n"
41 " MAC Address: %s",
42 format_mac_addr_upper(mac, mac_buf));
43 LOG_BINARY_SENSOR(" ", "Opening", this);
44 LOG_BINARY_SENSOR(" ", "Light", this->light_);
45 LOG_SENSOR(" ", "Battery Level", this->battery_level_);
46}
47
48void XiaomiMCCGQ02HL::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); }
49
51 if (device.address_uint64() != this->address_)
52 return false;
53
54 bool success = false;
55 for (auto &service_data : device.get_service_datas()) {
56 if (!service_data.uuid.contains(0x95, 0xFE))
57 continue;
58
59 Reading reading;
60 if (!this->parse_service_data_(service_data.data, reading))
61 continue;
62
63 ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", this->get_name().c_str(),
64 reading.open.has_value() ? (*reading.open ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"))
65 : LOG_STR_LITERAL("-"),
66 reading.light.has_value() ? (*reading.light ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no"))
67 : LOG_STR_LITERAL("-"),
68 reading.battery_level.has_value() ? LOG_STR_LITERAL("updated") : LOG_STR_LITERAL("-"));
69
70 if (reading.open.has_value())
71 this->publish_state(*reading.open);
72 if (reading.light.has_value() && this->light_ != nullptr)
73 this->light_->publish_state(*reading.light);
74 if (reading.battery_level.has_value() && this->battery_level_ != nullptr)
76 success = true;
77 }
78 return success;
79}
80
81bool XiaomiMCCGQ02HL::decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const {
82 uint8_t nonce[MAC_ADDRESS_SIZE + 6];
83 for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++)
84 nonce[i] = static_cast<uint8_t>(this->address_ >> (i * 8)); // MAC, reversed
85 memcpy(nonce + MAC_ADDRESS_SIZE, frame + 2, 3); // product id + frame count
86 memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + size - ENCRYPTED_TRAILER, 3); // extended counter
87 static constexpr uint8_t AUTH_DATA[1] = {0x11};
88 return ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce, sizeof(nonce), AUTH_DATA, sizeof(AUTH_DATA),
89 frame + offset, size - offset - ENCRYPTED_TRAILER, plaintext,
90 frame + size - MIC_SIZE, MIC_SIZE);
91}
92
93bool XiaomiMCCGQ02HL::parse_service_data_(const std::vector<uint8_t> &data, Reading &reading) {
94 const size_t size = data.size();
95 if (size < 5)
96 return false;
97 const uint8_t *raw = data.data();
98
99 const uint8_t fc = raw[0];
100 if (!(fc & FC_OBJECT))
101 return false;
102 if (encode_uint16(raw[3], raw[2]) != PRODUCT_ID) {
103 ESP_LOGVV(TAG, "Not an MCCGQ02HL frame (product id %02X%02X).", raw[3], raw[2]);
104 return false;
105 }
106
107 const uint8_t frame_count = raw[4];
108 if (this->last_frame_count_.has_value() && *this->last_frame_count_ == frame_count) {
109 ESP_LOGVV(TAG, "Duplicate frame %u.", frame_count);
110 return false;
111 }
112
113 // The bindkey is required, so plaintext frames are never trusted
114 if (!(fc & FC_ENCRYPTED)) {
115 ESP_LOGVV(TAG, "Ignoring unencrypted frame %u.", frame_count);
116 return false;
117 }
118
119 const size_t offset = 5 + ((fc & FC_MAC_INCLUDED) ? 6 : 0) + ((fc & FC_CAPABILITY) ? 1 : 0);
121 ESP_LOGW(TAG, "Unsupported encrypted frame layout (fc=0x%02X, %u bytes).", fc, (unsigned) size);
122 return false;
123 }
124 uint8_t plaintext[MAX_PAYLOAD_SIZE];
125 if (!this->decrypt_(raw, size, offset, plaintext)) {
126 ESP_LOGW(TAG, "Decryption failed (%u-byte frame) -- check the bindkey.", (unsigned) size);
127 return false;
128 }
129 // Only an authenticated frame may advance the duplicate filter. There is no replay protection: the
130 // counter is not persisted and restarts on a battery change, so a strictly increasing check could lock
131 // the sensor out until the next reboot.
132 this->last_frame_count_ = frame_count;
133 return this->parse_objects_(plaintext, size - offset - ENCRYPTED_TRAILER, reading);
134}
135
136bool XiaomiMCCGQ02HL::parse_objects_(const uint8_t *payload, size_t length, Reading &reading) {
137 bool found = false;
138 // Each object: id (uint16 LE), length (uint8), value.
139 while (length >= 3) {
140 const uint16_t id = encode_uint16(payload[1], payload[0]);
141 const uint8_t len = payload[2];
142 if (len < 1 || length < 3u + len)
143 break;
144 const uint8_t *value = payload + 3;
145
146 if ((id == OBJ_DOOR || id == OBJ_DOOR_ALT) && len == 1) {
147 // 0 = open, 1 = closed, 2 = left open past timeout, 3 = device reset
148 if (value[0] <= 2) {
149 reading.open = value[0] != 1;
150 found = true;
151 }
152 } else if ((id == OBJ_LIGHT || id == OBJ_LIGHT_ALT) && len == 1) {
153 reading.light = value[0] != 0;
154 found = true;
155 } else if ((id == OBJ_BATTERY || id == OBJ_BATTERY_ALT) && len == 1) {
156 reading.battery_level = value[0];
157 found = true;
158 } else {
159 ESP_LOGVV(TAG, "Ignoring object 0x%04X (%u bytes).", id, len);
160 }
161
162 payload += 3 + len;
163 length -= 3 + len;
164 }
165 return found;
166}
167
168} // namespace esphome::xiaomi_mccgq02hl
uint8_t raw[35]
Definition bl0939.h:0
const StringRef & get_name() const
Definition entity_base.h:71
void publish_state(bool new_state)
Publish a new state to the front-end.
uint64_t address_uint64() const
Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
const std::vector< ServiceData > & get_service_datas() const
Definition ble_device.h:225
void publish_state(float state)
Publish a new state to the front-end.
Definition sensor.cpp:65
bool parse_device(const ble_device_base::ESPBTDevice &device) override
bool decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const
bool parse_objects_(const uint8_t *payload, size_t length, Reading &reading)
bool parse_service_data_(const std::vector< uint8_t > &data, Reading &reading)
void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6])
Unpack a uint64 BLE address into printable (MSB-first) byte order — the order bd_addr_t / esp_bd_addr...
Definition ble_device.h:169
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, const uint8_t *tag, size_t tag_len)
ESPHOME_LOG_TAG(TAG, "xiaomi_mccgq02hl")
const void size_t len
Definition hal.h:68
size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count)
Parse bytes from a hex-encoded string into a byte array.
Definition helpers.cpp:370
uint16_t size
Definition helpers.cpp:34
constexpr uint16_t encode_uint16(uint8_t msb, uint8_t lsb)
Encode a 16-bit value given the most and least significant byte.
Definition helpers.h:980
char * format_mac_addr_upper(const uint8_t *mac, char *output)
Format MAC address as XX:XX:XX:XX:XX:XX (uppercase, colon separators)
Definition helpers.h:1600
uint16_t length
Definition tt21100.cpp:0