[xiaomi_body_scale] Add Xiaomi body scale S200 and S400 (#16476)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Dckiller
2026-09-26 16:11:50 +01:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent 04a53264b4
commit cdc77e2126
12 changed files with 543 additions and 0 deletions
@@ -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"
@@ -0,0 +1,78 @@
#pragma once
#include "esphome/components/xiaomi_body_scale/xiaomi_body_scale.h"
#include "esphome/core/application.h"
#include <array>
#include <vector>
namespace esphome::xiaomi_body_scale::testing {
using Frame = std::array<uint8_t, 24>;
// 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<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(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
@@ -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"
@@ -0,0 +1,112 @@
#include "../common.h"
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
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
@@ -0,0 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
xiaomi_body_scale: !include common.yaml
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
xiaomi_body_scale: !include common-ln.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"