mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 07:50:21 +00:00
[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:
co-authored by
J. Nick Koston
J. Nick Koston
parent
04a53264b4
commit
cdc77e2126
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@dckiller51"]
|
||||
@@ -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))
|
||||
@@ -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 <cstring>
|
||||
|
||||
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<uint8_t>(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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user