diff --git a/CODEOWNERS b/CODEOWNERS index 930bdc08d2..944c5f2809 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/xiaomi_mccgq02hl/__init__.py b/esphome/components/xiaomi_mccgq02hl/__init__.py new file mode 100644 index 0000000000..3aecbd8207 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@ahpohl", "@morph027"] diff --git a/esphome/components/xiaomi_mccgq02hl/binary_sensor.py b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py new file mode 100644 index 0000000000..f410ad7f73 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py @@ -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) diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp new file mode 100644 index 0000000000..88b9865e47 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -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 + +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(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 &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 diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h new file mode 100644 index 0000000000..8897752b44 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h @@ -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 + +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 open; + optional light; + optional 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 &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 last_frame_count_; + binary_sensor::BinarySensor *light_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; +}; + +} // namespace esphome::xiaomi_mccgq02hl diff --git a/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp b/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp new file mode 100644 index 0000000000..c9a4a67a34 --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp @@ -0,0 +1,74 @@ +#include "../common.h" + +#include + +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 diff --git a/tests/components/xiaomi_mccgq02hl/common-ln.yaml b/tests/components/xiaomi_mccgq02hl/common-ln.yaml new file mode 100644 index 0000000000..9d7a7278a9 --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common-ln.yaml @@ -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 diff --git a/tests/components/xiaomi_mccgq02hl/common.h b/tests/components/xiaomi_mccgq02hl/common.h new file mode 100644 index 0000000000..3f6dcd39bb --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h" + +#include +#include + +namespace esphome::xiaomi_mccgq02hl::testing { + +using Frame = std::array; +using PlainFrame = std::array; + +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 ble_device_base::ESPBTDevice advert(uint64_t address, const std::array &frame) { + // Service data AD structure for UUID 0xFE95 + std::vector adv = {static_cast(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(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(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, optional) { this->door_changes++; }); + } + + XiaomiMCCGQ02HL sensor; + binary_sensor::BinarySensor light; + sensor::Sensor battery_level; + int door_changes{0}; +}; + +} // namespace esphome::xiaomi_mccgq02hl::testing diff --git a/tests/components/xiaomi_mccgq02hl/common.yaml b/tests/components/xiaomi_mccgq02hl/common.yaml new file mode 100644 index 0000000000..9cf9be8e2d --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common.yaml @@ -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 diff --git a/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml b/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml new file mode 100644 index 0000000000..8e5a674bc2 --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_mccgq02hl: !include common.yaml diff --git a/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml b/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8bbe6097db --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mccgq02hl: !include common-ln.yaml diff --git a/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..921263442c --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.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"