[ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 6: thermopro_ble, exposure_notifications, xiaomi_ble) (#18168)

This commit is contained in:
Edvard Filistovič
2026-08-07 20:47:52 -05:00
committed by GitHub
parent 8d494a84c5
commit 1d184b43eb
22 changed files with 196 additions and 85 deletions
@@ -1,33 +1,59 @@
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_TRIGGER_ID
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
CODEOWNERS = ["@OttoWinter"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications")
ExposureNotification = exposure_notifications_ns.struct("ExposureNotification")
ExposureNotificationTrigger = exposure_notifications_ns.class_(
"ExposureNotificationTrigger",
esp32_ble_tracker.ESPBTDeviceListener,
ble_device_base.ESPBTDeviceListener,
automation.Trigger.template(ExposureNotification),
)
CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification"
_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications")
_VALIDATE_AUTOMATION = automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger),
}
# The trigger is the BLE listener, so the hub id lives on it.
).extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
# validate_automation() needs a dict-based schema, so the rename cannot go
# inside it and has to run on the option value first. That value may also be a
# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so
# map over lists and let validate_automation() report anything else.
# schema_extractor keeps the key typed as a trigger in the generated editor
# schema; build_language_schema.py recurses into cv.All but not into a plain
# function.
@schema_extractor("automation")
def _validate_on_exposure_notification(value: Any) -> list[ConfigType]:
if value is SCHEMA_EXTRACT:
return _VALIDATE_AUTOMATION(value)
if isinstance(value, dict):
value = _RENAME_HUB_ID(value)
elif isinstance(value, list):
value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value]
return _VALIDATE_AUTOMATION(value)
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ExposureNotificationTrigger
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
),
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification,
}
)
@@ -36,4 +62,4 @@ async def to_code(config):
for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(ExposureNotification, "x")], conf)
await esp32_ble_tracker.register_ble_device(trigger, conf)
await ble_device_base.register_ble_device(trigger, conf)
@@ -2,11 +2,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
using namespace esp32_ble_tracker;
using namespace ble_device_base;
static const char *const TAG = "exposure_notifications";
@@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) {
}
} // namespace esphome::exposure_notifications
#endif
@@ -2,11 +2,9 @@
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include <array>
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
struct ExposureNotification {
@@ -17,11 +15,9 @@ struct ExposureNotification {
};
class ExposureNotificationTrigger final : public Trigger<ExposureNotification>,
public esp32_ble_tracker::ESPBTDeviceListener {
public ble_device_base::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
};
} // namespace esphome::exposure_notifications
#endif
+7 -6
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -22,14 +22,15 @@ from esphome.const import (
CODEOWNERS = ["@sittner"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble")
ThermoProBLE = thermopro_ble_ns.class_(
"ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
"ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("thermopro_ble"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ThermoProBLE),
@@ -68,15 +69,15 @@ CONFIG_SCHEMA = (
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
@@ -2,8 +2,6 @@
#include <cmath>
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::thermopro_ble {
// this size must be large enough to hold the largest data frame
@@ -34,7 +32,7 @@ void ThermoProBLE::dump_config() {
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
}
bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) {
// check for matching mac address
if (device.address_uint64() != this->address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
@@ -66,8 +64,8 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
}
// reconstruct whole record from 2 byte uuid and data
esp_bt_uuid_t uuid = service_data.uuid.get_uuid();
uint8_t data[MAX_DATA_SIZE] = {static_cast<uint8_t>(uuid.uuid.uuid16), static_cast<uint8_t>(uuid.uuid.uuid16 >> 8)};
uint16_t svc_uuid16 = service_data.uuid.uuid16();
uint8_t data[MAX_DATA_SIZE] = {static_cast<uint8_t>(svc_uuid16), static_cast<uint8_t>(svc_uuid16 >> 8)};
std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2);
// dispatch data to parser
@@ -202,5 +200,3 @@ static optional<ParseResult> parse_tp3(const uint8_t *data, std::size_t data_siz
}
} // namespace esphome::thermopro_ble
#endif
@@ -2,9 +2,7 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_ESP32
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::thermopro_ble {
@@ -17,11 +15,11 @@ struct ParseResult {
using DeviceParser = optional<ParseResult> (*)(const uint8_t *data, std::size_t data_size);
class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { this->address_ = address; };
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; }
void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; }
@@ -45,5 +43,3 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi
};
} // namespace esphome::thermopro_ble
#endif
+12 -9
View File
@@ -1,22 +1,25 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble")
XiaomiListener = xiaomi_ble_ns.class_(
"XiaomiListener", esp32_ble_tracker.ESPBTDeviceListener
"XiaomiListener", ble_device_base.ESPBTDeviceListener
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(XiaomiListener),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("xiaomi_ble"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(XiaomiListener),
}
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
+18 -23
View File
@@ -2,14 +2,21 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include <vector>
// AES-CCM backend for encrypted-payload (bindkey) decryption:
// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed.
// - every other platform -> the portable software AES-CCM in ble_device_base, so
// decryption never depends on the SDK exposing mbedtls/PSA to application code.
#ifdef USE_ESP32
#include <esp_idf_version.h>
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <psa/crypto.h>
#else
#include "mbedtls/ccm.h"
#define XIAOMI_CRYPTO_PSA
#endif
#endif
#ifndef XIAOMI_CRYPTO_PSA
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
#endif
namespace esphome::xiaomi_ble {
@@ -166,7 +173,7 @@ bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult
return success;
}
optional<XiaomiParseResult> parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data) {
optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceData &service_data) {
XiaomiParseResult result;
if (!service_data.uuid.contains(0x95, 0xFE)) {
ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes.");
@@ -318,7 +325,7 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, c
memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1)
memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#ifdef XIAOMI_CRYPTO_PSA
// PSA AEAD expects ciphertext + tag concatenated
uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)];
memcpy(ct_with_tag, vector.ciphertext, vector.datasize);
@@ -344,20 +351,10 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, c
psa_destroy_key(key_id);
bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize);
#else
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, vector.key, vector.keysize * 8);
if (ret) {
ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): mbedtls_ccm_setkey() failed.");
mbedtls_ccm_free(&ctx);
return false;
}
ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize,
vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize);
mbedtls_ccm_free(&ctx);
bool decrypt_ok = (ret == 0);
// Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency.
bool decrypt_ok = ble_device_base::aes_ccm_auth_decrypt(vector.key, vector.iv, vector.ivsize, vector.authdata,
vector.authsize, vector.ciphertext, vector.datasize,
vector.plaintext, vector.tag, vector.tagsize);
#endif
if (!decrypt_ok) {
@@ -448,7 +445,7 @@ bool report_xiaomi_results(const optional<XiaomiParseResult> &result, const char
return true;
}
bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool XiaomiListener::parse_device(const ble_device_base::ESPBTDevice &device) {
// Previously the message was parsed twice per packet, once by XiaomiListener::parse_device()
// and then again by the respective device class's parse_device() function. Parsing the header
// here and then for each device seems to be unnecessary and complicates the duplicate packet filtering.
@@ -460,5 +457,3 @@ bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
}
} // namespace esphome::xiaomi_ble
#endif
+4 -8
View File
@@ -1,12 +1,10 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/core/component.h"
#include <vector>
#ifdef USE_ESP32
namespace esphome::xiaomi_ble {
struct XiaomiParseResult {
@@ -68,15 +66,13 @@ struct XiaomiAESVector {
bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result);
bool parse_xiaomi_message(const std::vector<uint8_t> &message, XiaomiParseResult &result);
optional<XiaomiParseResult> parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data);
optional<XiaomiParseResult> parse_xiaomi_header(const ble_device_base::ServiceData &service_data);
bool decrypt_xiaomi_payload(std::vector<uint8_t> &raw, const uint8_t *bindkey, const uint64_t &address);
bool report_xiaomi_results(const optional<XiaomiParseResult> &result, const char *address);
class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener {
class XiaomiListener final : public ble_device_base::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
};
} // namespace esphome::xiaomi_ble
#endif
@@ -19,6 +19,33 @@ const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56};
const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01};
} // namespace
// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD
// (0x11). Both the AAD block and the l = 3 length encoding are only reachable
// through this shape, so they need their own vector. Generated the same way.
namespace {
const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b};
const uint8_t AAD_XIAOMI[1] = {0x11};
const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23};
const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6};
const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00};
} // namespace
TEST(BleAesCcm, DecryptsXiaomiShapedVector) {
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI),
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI)));
}
TEST(BleAesCcm, RejectsWrongAssociatedData) {
uint8_t bad_aad[sizeof(AAD_XIAOMI)];
memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI));
bad_aad[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad),
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
}
TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) {
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
@@ -0,0 +1,5 @@
exposure_notifications:
on_exposure_notification:
then:
- lambda: |
ESP_LOGD("main", "RSSI: %d", x.rssi);
@@ -1,7 +1,10 @@
esp32_ble_tracker:
id: ble_tracker_hub
exposure_notifications:
on_exposure_notification:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
ble_hub_id: ble_tracker_hub
then:
- lambda: |
ESP_LOGD("main", "Got notification:");
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
exposure_notifications: !include common-ln.yaml
@@ -0,0 +1,15 @@
# 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
exposure_notifications:
on_exposure_notification:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
ble_hub_id: ble_tracker_hub
then:
- lambda: |
ESP_LOGD("main", "Got notification:");
ESP_LOGD("main", " RPI: %s", format_hex(x.rolling_proximity_identifier).c_str());
ESP_LOGD("main", " RSSI: %d", x.rssi);
@@ -0,0 +1,7 @@
sensor:
- platform: thermopro_ble
mac_address: FE:74:B8:6A:97:B7
temperature:
name: ThermoPro Temperature
humidity:
name: ThermoPro Humidity
@@ -1,7 +1,10 @@
esp32_ble_tracker:
id: ble_tracker_hub
sensor:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
- platform: thermopro_ble
ble_hub_id: ble_tracker_hub
mac_address: FE:74:B8:6A:97:B7
temperature:
name: "ThermoPro Temperature"
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
thermopro_ble: !include common-ln.yaml
@@ -0,0 +1,24 @@
# 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
sensor:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
- platform: thermopro_ble
ble_hub_id: ble_tracker_hub
mac_address: FE:74:B8:6A:97:B7
temperature:
name: "ThermoPro Temperature"
humidity:
name: "ThermoPro Humidity"
battery_level:
name: "ThermoPro Battery Level"
signal_strength:
name: "ThermoPro Signal Strength"
# No ble_hub_id: exercises the generated binding real configs use.
- platform: thermopro_ble
mac_address: FE:74:B8:6A:97:B8
temperature:
name: BK ThermoPro Implicit Temperature
@@ -0,0 +1 @@
xiaomi_ble:
+3
View File
@@ -1,3 +1,6 @@
esp32_ble_tracker:
id: ble_tracker_hub
# Explicit ble_hub_id: pins the neutral binding as a declared key.
xiaomi_ble:
ble_hub_id: ble_tracker_hub
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
xiaomi_ble: !include common-ln.yaml
@@ -0,0 +1,9 @@
# 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
# Explicit ble_hub_id: pins the neutral binding as a declared key.
xiaomi_ble:
ble_hub_id: ble_tracker_hub