From cdc77e21269e878cbc27af07bf19002b1dad2dd6 Mon Sep 17 00:00:00 2001 From: Dckiller <53062806+dckiller51@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:11:50 +0200 Subject: [PATCH] [xiaomi_body_scale] Add Xiaomi body scale S200 and S400 (#16476) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/xiaomi_body_scale/__init__.py | 1 + .../components/xiaomi_body_scale/sensor.py | 93 ++++++++++++ .../xiaomi_body_scale/xiaomi_body_scale.cpp | 141 ++++++++++++++++++ .../xiaomi_body_scale/xiaomi_body_scale.h | 40 +++++ .../xiaomi_body_scale/common-ln.yaml | 16 ++ tests/components/xiaomi_body_scale/common.h | 78 ++++++++++ .../components/xiaomi_body_scale/common.yaml | 27 ++++ .../sensor/xiaomi_body_scale_test.cpp | 112 ++++++++++++++ .../xiaomi_body_scale/test.esp32-idf.yaml | 3 + .../xiaomi_body_scale/test.ln882x-ard.yaml | 3 + .../validate.bk72xx-ard.yaml | 28 ++++ 12 files changed, 543 insertions(+) create mode 100644 esphome/components/xiaomi_body_scale/__init__.py create mode 100644 esphome/components/xiaomi_body_scale/sensor.py create mode 100644 esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp create mode 100644 esphome/components/xiaomi_body_scale/xiaomi_body_scale.h create mode 100644 tests/components/xiaomi_body_scale/common-ln.yaml create mode 100644 tests/components/xiaomi_body_scale/common.h create mode 100644 tests/components/xiaomi_body_scale/common.yaml create mode 100644 tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp create mode 100644 tests/components/xiaomi_body_scale/test.esp32-idf.yaml create mode 100644 tests/components/xiaomi_body_scale/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index cfe5638a61..bc601f0533 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -641,6 +641,7 @@ esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier +esphome/components/xiaomi_body_scale/* @dckiller51 esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 esphome/components/xiaomi_lywsd03mmc/* @ahpohl diff --git a/esphome/components/xiaomi_body_scale/__init__.py b/esphome/components/xiaomi_body_scale/__init__.py new file mode 100644 index 0000000000..3d75ef24ec --- /dev/null +++ b/esphome/components/xiaomi_body_scale/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@dckiller51"] diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py new file mode 100644 index 0000000000..c8cbc0e820 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -0,0 +1,93 @@ +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_BINDKEY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_WEIGHT, + DEVICE_CLASS_WEIGHT, + ICON_HEART_PULSE, + ICON_OMEGA, + ICON_SCALE_BATHROOM, + STATE_CLASS_MEASUREMENT, + UNIT_BEATS_PER_MINUTE, + UNIT_KILOGRAM, + UNIT_OHM, +) +from esphome.types import ConfigType + +CONF_IMPEDANCE_LOW = "impedance_low" +CONF_IMPEDANCE_HIGH = "impedance_high" +CONF_HEART_RATE = "heart_rate" +CONF_PROFILE_ID = "profile_id" +CONF_STABILIZED = "stabilized" + +AUTO_LOAD = ["ble_device_base", "binary_sensor"] + +xiaomi_body_scale_ns = cg.esphome_ns.namespace("xiaomi_body_scale") +XiaomiBodyScale = xiaomi_body_scale_ns.class_( + "XiaomiBodyScale", ble_device_base.ESPBTDeviceListener, cg.Component +) + +IMPEDANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiBodyScale), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_WEIGHT): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOGRAM, + accuracy_decimals=2, + device_class=DEVICE_CLASS_WEIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IMPEDANCE_LOW): IMPEDANCE_SCHEMA, + cv.Optional(CONF_IMPEDANCE_HIGH): IMPEDANCE_SCHEMA, + cv.Optional(CONF_HEART_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_BEATS_PER_MINUTE, + icon=ICON_HEART_PULSE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROFILE_ID): sensor.sensor_schema( + icon="mdi:identifier", + accuracy_decimals=0, + ), + cv.Optional(CONF_STABILIZED): binary_sensor.binary_sensor_schema( + icon=ICON_SCALE_BATHROOM, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + ) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + for key, setter in ( + (CONF_WEIGHT, var.set_weight), + (CONF_IMPEDANCE_LOW, var.set_impedance_low), + (CONF_IMPEDANCE_HIGH, var.set_impedance_high), + (CONF_HEART_RATE, var.set_heart_rate), + (CONF_PROFILE_ID, var.set_profile_id), + ): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(setter(sens)) + if (conf := config.get(CONF_STABILIZED)) is not None: + sens = await binary_sensor.new_binary_sensor(conf) + cg.add(var.set_stabilized(sens)) diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp new file mode 100644 index 0000000000..7f9cd17d1f --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp @@ -0,0 +1,141 @@ +#include "xiaomi_body_scale.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_body_scale { + +static const char *const TAG = "xiaomi_body_scale"; + +// Encrypted MiBeacon frame without a MAC: frame control, device id, frame count, cipher, counter, tag +static constexpr size_t DEVICE_ID_POS = 2; +static constexpr size_t FRAME_COUNT_POS = 4; +static constexpr size_t CIPHER_POS = 5; +static constexpr size_t CIPHER_SIZE = 12; +static constexpr size_t COUNTER_POS = CIPHER_POS + CIPHER_SIZE; +static constexpr size_t TAG_POS = COUNTER_POS + 3; +static constexpr size_t TAG_SIZE = 4; +static constexpr size_t FRAME_SIZE = TAG_POS + TAG_SIZE; +// Frame control: has data (0x40) and encrypted (0x08) +static constexpr uint8_t FRAME_ENCRYPTED_DATA = 0x48; +static constexpr uint8_t OBJECT_SIZE = 9; +static constexpr uint16_t OBJECT_S200_MEASUREMENT = 0x4E16; +static constexpr uint16_t OBJECT_S400_MEASUREMENT = 0x6E16; +static constexpr uint32_t STABILIZED_RESET_ID = 0; +static constexpr uint32_t STABILIZED_RESET_MS = 1000; + +XiaomiBodyScale::XiaomiBodyScale(uint64_t address, const char *bindkey) : address_(address) { + parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); +} + +void XiaomiBodyScale::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 Body Scale\n" + " MAC Address: %s", + format_mac_addr_upper(mac, mac_buf)); + LOG_SENSOR(" ", "Weight", this->weight_); + LOG_SENSOR(" ", "Impedance Low (50 kHz)", this->impedance_low_); + LOG_SENSOR(" ", "Impedance High (250 kHz)", this->impedance_high_); + LOG_SENSOR(" ", "Heart Rate", this->heart_rate_); + LOG_SENSOR(" ", "Profile ID", this->profile_id_); + LOG_BINARY_SENSOR(" ", "Stabilized", this->stabilized_); +} + +bool XiaomiBodyScale::decrypt_(const uint8_t *frame, 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 + DEVICE_ID_POS, 3); // device id + frame count + memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + COUNTER_POS, 3); + 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 + CIPHER_POS, CIPHER_SIZE, plaintext, frame + TAG_POS, TAG_SIZE); +} + +void XiaomiBodyScale::publish_stabilized_(bool stabilized) { + if (this->stabilized_ == nullptr) + return; + this->stabilized_->publish_state(stabilized); + // Clear it again so the next measurement is seen as a new one + if (stabilized) + this->set_timeout(STABILIZED_RESET_ID, STABILIZED_RESET_MS, [this]() { this->stabilized_->publish_state(false); }); +} + +void XiaomiBodyScale::publish_s400_(uint32_t packed) { + // Weight x10 (11 bits), heart rate - 50 (7 bits), impedance x10 (14 bits). A measurement sends weight, heart + // rate and the 50 kHz impedance (the larger value), then a packet with only the 250 kHz impedance. + const uint16_t weight = packed & 0x7FF; + const uint8_t heart_rate = (packed >> 11) & 0x7F; + const uint16_t impedance = packed >> 18; + ESP_LOGD(TAG, "weight=%u heart_rate=%u impedance=%u", weight, heart_rate, impedance); + + if (weight != 0 && this->weight_ != nullptr) + this->weight_->publish_state(weight / 10.0f); + if (heart_rate > 0 && heart_rate < 127 && this->heart_rate_ != nullptr) + this->heart_rate_->publish_state(heart_rate + 50.0f); + + if (weight == 0 && heart_rate == 0) { + // Impedance only: the final 250 kHz packet (bare feet); all zero: stepped off + if (impedance != 0 && this->impedance_high_ != nullptr) + this->impedance_high_->publish_state(impedance / 10.0f); + this->publish_stabilized_(impedance != 0); + } else if (impedance != 0) { + if (this->impedance_low_ != nullptr) + this->impedance_low_->publish_state(impedance / 10.0f); + this->publish_stabilized_(false); + } else { + // Weight without impedance: measurement complete (with socks) + this->publish_stabilized_(true); + } +} + +bool XiaomiBodyScale::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + for (const auto &service_data : device.get_service_datas()) { + if (service_data.data.size() != FRAME_SIZE || !service_data.uuid.contains(0x95, 0xFE)) + continue; + const uint8_t *frame = service_data.data.data(); + // The bindkey is required, so plaintext frames are never trusted + if ((frame[0] & FRAME_ENCRYPTED_DATA) != FRAME_ENCRYPTED_DATA) + continue; + + if (frame[FRAME_COUNT_POS] == this->last_frame_count_) + continue; + uint8_t object[CIPHER_SIZE]; + if (!this->decrypt_(frame, object)) { + ESP_LOGW(TAG, "Decryption failed, wrong bindkey?"); + continue; + } + // Only an authenticated frame may advance the duplicate filter + this->last_frame_count_ = frame[FRAME_COUNT_POS]; + + // Both objects hold the profile ID, a packed uint32 (LE) and a timestamp (not published) + const uint16_t value_type = encode_uint16(object[1], object[0]); + const uint8_t *data = object + 3; + const uint32_t packed = encode_uint32(data[4], data[3], data[2], data[1]); + const bool sized = object[2] == OBJECT_SIZE; + if (sized && value_type == OBJECT_S400_MEASUREMENT) { + this->publish_s400_(packed); + } else if (sized && value_type == OBJECT_S200_MEASUREMENT) { + // Weight x100 only + if (packed != 0 && this->weight_ != nullptr) + this->weight_->publish_state(packed / 100.0f); + } else { + ESP_LOGVV(TAG, "Unknown object 0x%04X, length %u", value_type, object[2]); + continue; + } + if (this->profile_id_ != nullptr) + this->profile_id_->publish_state(data[0]); + return true; + } + return false; +} + +} // namespace esphome::xiaomi_body_scale diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h new file mode 100644 index 0000000000..4113bd6d26 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h @@ -0,0 +1,40 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::xiaomi_body_scale { + +class XiaomiBodyScale final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + XiaomiBodyScale(uint64_t address, const char *bindkey); + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + void set_weight(sensor::Sensor *weight) { this->weight_ = weight; } + void set_impedance_low(sensor::Sensor *impedance_low) { this->impedance_low_ = impedance_low; } + void set_impedance_high(sensor::Sensor *impedance_high) { this->impedance_high_ = impedance_high; } + void set_heart_rate(sensor::Sensor *heart_rate) { this->heart_rate_ = heart_rate; } + void set_profile_id(sensor::Sensor *profile_id) { this->profile_id_ = profile_id; } + void set_stabilized(binary_sensor::BinarySensor *stabilized) { this->stabilized_ = stabilized; } + + protected: + bool decrypt_(const uint8_t *frame, uint8_t *plaintext) const; + void publish_s400_(uint32_t packed); + void publish_stabilized_(bool stabilized); + + uint64_t address_; + uint8_t bindkey_[16]; + sensor::Sensor *weight_{nullptr}; + sensor::Sensor *impedance_low_{nullptr}; + sensor::Sensor *impedance_high_{nullptr}; + sensor::Sensor *heart_rate_{nullptr}; + sensor::Sensor *profile_id_{nullptr}; + binary_sensor::BinarySensor *stabilized_{nullptr}; + uint16_t last_frame_count_{0x100}; // outside the 8-bit range, so the first frame always passes +}; + +} // namespace esphome::xiaomi_body_scale diff --git a/tests/components/xiaomi_body_scale/common-ln.yaml b/tests/components/xiaomi_body_scale/common-ln.yaml new file mode 100644 index 0000000000..a6492ed0dc --- /dev/null +++ b/tests/components/xiaomi_body_scale/common-ln.yaml @@ -0,0 +1,16 @@ +sensor: + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h new file mode 100644 index 0000000000..67f1eaae91 --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.h @@ -0,0 +1,78 @@ +#pragma once + +#include "esphome/components/xiaomi_body_scale/xiaomi_body_scale.h" +#include "esphome/core/application.h" + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +using Frame = std::array; + +// Real captures and keys from the xiaomi-ble test suite (Bluetooth-Devices/xiaomi-ble) +static constexpr uint64_t SCALE_A = 0x8CD0B2F6BEEFULL; +static constexpr const char *KEY_A = "0728974d657a4b60964c1b1677f35f7c"; +// Weight, heart rate and the 50 kHz impedance +static constexpr Frame PACKET_1 = {0x48, 0x59, 0xd5, 0x3b, 0x0a, 0xbc, 0x07, 0x8f, 0xf2, 0x34, 0x8c, 0x84, + 0x41, 0x38, 0xe9, 0x30, 0x22, 0x00, 0x00, 0x00, 0x9e, 0x53, 0x85, 0x99}; +// Only the 250 kHz impedance, the end of a bare feet measurement +static constexpr Frame PACKET_2 = {0x48, 0x59, 0xd5, 0x3b, 0x0b, 0xd6, 0xef, 0x0b, 0x25, 0xdb, 0x72, 0x78, + 0x5e, 0x7e, 0x2f, 0x46, 0xd6, 0x00, 0x00, 0x00, 0xd8, 0x64, 0x2d, 0xf6}; +// PACKET_1 re-encrypted with KEY_A and frame count 0xFF +static constexpr Frame PACKET_1_COUNT_FF = {0x48, 0x59, 0xd5, 0x3b, 0xff, 0x6c, 0x2f, 0xd8, 0xdb, 0xaa, 0x70, 0xc7, + 0xbb, 0x1f, 0x0a, 0x08, 0x9c, 0x00, 0x00, 0x00, 0x31, 0xce, 0xf1, 0x95}; + +static constexpr uint64_t SCALE_B = 0x04AE4767C67CULL; +static constexpr const char *KEY_B = "02d2900363ef629c736a4549677acbee"; +// Weight without impedance, the end of a measurement with socks +static constexpr Frame SOCKS = {0x48, 0x59, 0xd5, 0x3b, 0x71, 0x53, 0x04, 0x38, 0xb5, 0x89, 0x4b, 0x24, + 0x2c, 0x20, 0x99, 0x08, 0xda, 0x00, 0x00, 0x00, 0x47, 0x9e, 0xcd, 0xa3}; +// All metrics zero, stepped off the scale +static constexpr Frame STEP_OFF = {0x48, 0x59, 0xd5, 0x3b, 0x72, 0x03, 0x6c, 0x67, 0x94, 0x35, 0x5a, 0x19, + 0xdb, 0xc8, 0x64, 0xbf, 0xb3, 0x00, 0x00, 0x00, 0xe4, 0x15, 0x1d, 0xc8}; + +// S200: weight only, 62.25 kg for profile 1 +static constexpr uint64_t SCALE_S200 = 0xD07B6F27D729ULL; +static constexpr const char *KEY_S200 = "653b1b10e1cb35e4ac5e60fa45f3bf29"; +static constexpr Frame S200_WEIGHT = {0x48, 0x59, 0x04, 0x4c, 0x01, 0x9a, 0x80, 0xa2, 0x75, 0x93, 0x90, 0x10, + 0xf0, 0xab, 0xc4, 0xfa, 0xdc, 0x06, 0x00, 0x00, 0x3d, 0x29, 0xc0, 0x44}; + +inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &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(uint64_t address, const char *key) : scale(address, key) { + // The test main does not construct App as generated code does; the stabilized reset needs its scheduler + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_scale", 10, "", 0); + this->scale.set_weight(&this->weight); + this->scale.set_impedance_low(&this->impedance_low); + this->scale.set_impedance_high(&this->impedance_high); + this->scale.set_heart_rate(&this->heart_rate); + this->scale.set_profile_id(&this->profile_id); + this->scale.set_stabilized(&this->stabilized); + } + + ~Harness() { + // Drop the pending stabilized reset (id 0) while the scale is alive; App outlives each test + App.scheduler.cancel_timeout(&this->scale, 0u); + App.scheduler.call(millis()); + } + + XiaomiBodyScale scale; + sensor::Sensor weight, impedance_low, impedance_high, heart_rate, profile_id; + binary_sensor::BinarySensor stabilized; +}; + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/common.yaml b/tests/components/xiaomi_body_scale/common.yaml new file mode 100644 index 0000000000..6eb2bf76dd --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.yaml @@ -0,0 +1,27 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight" diff --git a/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp new file mode 100644 index 0000000000..36a93710f9 --- /dev/null +++ b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp @@ -0,0 +1,112 @@ +#include "../common.h" + +#include + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +TEST(XiaomiBodyScale, DecodesWeightHeartRateAndLowImpedance) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); + EXPECT_FLOAT_EQ(h.impedance_low.state, 543.2f); + EXPECT_FLOAT_EQ(h.heart_rate.state, 92.0f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, HighImpedancePacketCompletesTheMeasurement) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + EXPECT_FLOAT_EQ(h.impedance_high.state, 497.6f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.weight.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, StabilizedClearsAfterOneSecond) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + ASSERT_TRUE(h.stabilized.state); + const uint32_t start = millis(); + while (h.stabilized.state && millis() - start < 2000) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_FALSE(h.stabilized.state); + EXPECT_GE(millis() - start, 900u); +} + +TEST(XiaomiBodyScale, WeightWithoutImpedanceCompletesTheMeasurement) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, SteppingOffClearsStabilized) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, STEP_OFF))); + EXPECT_FALSE(h.stabilized.state); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); // a zero weight is not published +} + +TEST(XiaomiBodyScale, DecodesS200Weight) { + Harness h(SCALE_S200, KEY_S200); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_S200, S200_WEIGHT))); + EXPECT_FLOAT_EQ(h.weight.state, 62.25f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + // The S200 has no impedance or heart rate, and does not drive stabilized + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.heart_rate.has_state()); + EXPECT_FALSE(h.stabilized.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresOtherAddresses) { + Harness h(SCALE_A, KEY_A); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_B, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAWrongBindkey) { + Harness h(SCALE_A, KEY_B); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAPlaintextFrame) { + Harness h(SCALE_A, KEY_A); + Frame plain = PACKET_1; + plain[0] &= ~0x08; + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, plain))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresARepeatedFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +TEST(XiaomiBodyScale, AcceptsFrameCountFFAsTheFirstFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1_COUNT_FF))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); +} + +TEST(XiaomiBodyScale, AFailedFrameDoesNotBlockTheRealOne) { + Harness h(SCALE_A, KEY_A); + Frame forged = PACKET_1; + forged[23] ^= 0xFF; // corrupt the tag, same frame count + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, forged))); + EXPECT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/test.esp32-idf.yaml b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml new file mode 100644 index 0000000000..4ea8a5c379 --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_body_scale: !include common.yaml diff --git a/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9a95e14750 --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_body_scale: !include common-ln.yaml diff --git a/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..6f677a951f --- /dev/null +++ b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml @@ -0,0 +1,28 @@ +# Config-only: the CI bk72xx board cannot compile the BLE 5.x tracker +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight"