mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 16:30:22 +00:00
Merge branch 'dev' into jesserockz-2026-503
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform.
|
||||
# Remove before 2027.2.0
|
||||
sensor:
|
||||
- id: adc_temperature_sensor
|
||||
platform: adc
|
||||
pin: TEMPERATURE
|
||||
name: ADC Test temperature
|
||||
@@ -0,0 +1 @@
|
||||
airthings_ble:
|
||||
@@ -0,0 +1,6 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
airthings_ble:
|
||||
ble_hub_id: ble_tracker_hub
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
|
||||
airthings_ble: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
airthings_ble: !include common-ln.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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
|
||||
|
||||
airthings_ble:
|
||||
ble_hub_id: ble_tracker_hub
|
||||
@@ -0,0 +1,7 @@
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Temperature
|
||||
humidity:
|
||||
name: ATC 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: atc_mithermometer
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Temperature
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
atc_mithermometer: !include common-ln.yaml
|
||||
@@ -0,0 +1,10 @@
|
||||
# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates.
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
esp32_ble_id: ble_tracker_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Legacy Key Temperature
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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_hub
|
||||
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
ble_hub_id: ble_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: BK ATC Temperature
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:79
|
||||
temperature:
|
||||
name: BK ATC Implicit Temperature
|
||||
@@ -0,0 +1,7 @@
|
||||
sensor:
|
||||
- platform: b_parasite
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
temperature:
|
||||
name: b-parasite Air Temperature
|
||||
@@ -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: b_parasite
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
b_parasite: !include common-ln.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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: b_parasite
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
temperature:
|
||||
name: b-parasite Air Temperature
|
||||
moisture:
|
||||
name: b-parasite Soil Moisture
|
||||
battery_voltage:
|
||||
name: b-parasite Battery Voltage
|
||||
illuminance:
|
||||
name: b-parasite Illuminance
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: b_parasite
|
||||
mac_address: F0:CA:F0:CA:01:02
|
||||
temperature:
|
||||
name: BK b-parasite Implicit Temperature
|
||||
@@ -0,0 +1,54 @@
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- bk72xx_ble_tracker.start_scan
|
||||
- bk72xx_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
- bk72xx_ble_tracker.stop_scan
|
||||
- bk72xx_ble_tracker.stop_scan: ble_tracker
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
on_ble_advertise:
|
||||
- mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: |-
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
- AC:37:43:77:5F:4D
|
||||
then:
|
||||
- lambda: |-
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: ABCD
|
||||
# mac_address exercises the UUID triggers' set_address() codegen branch.
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of service data is %zu", x.size());
|
||||
- service_uuid: ABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "32-bit service data is %zu", x.size());
|
||||
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "128-bit service data is %zu", x.size());
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- manufacturer_id: ABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
|
||||
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size());
|
||||
on_scan_end:
|
||||
- then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Scan ended");
|
||||
@@ -0,0 +1,8 @@
|
||||
# Passive scanning variant: the package merge keeps the shared parameters from
|
||||
# common.yaml and overrides only the mode.
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
@@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# resolve_irk() is compiled only when a sensor configures irk:
|
||||
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
|
||||
# no sensors, so emit the define here to put the real IRK path under test.
|
||||
# Likewise the scan-response merger (emitted by the split-report trackers)
|
||||
# and the listener vector it dispatches into (codegen-sized by consumers).
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace esphome::ble_device_base::testing {
|
||||
|
||||
// from_scan_result() ingests BLE controller order (LSB-first); the public
|
||||
// accessors must expose the historical esp32 semantics: address() in printable
|
||||
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str()
|
||||
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str_to()
|
||||
// printed MSB-first.
|
||||
namespace {
|
||||
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
|
||||
@@ -25,7 +25,14 @@ TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) {
|
||||
|
||||
EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL);
|
||||
|
||||
char buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
EXPECT_STREQ(device.address_str_to(buf), "AA:BB:CC:DD:EE:FF");
|
||||
|
||||
// The deprecated wrapper must keep returning the same string until its 2027.2.0 removal.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF");
|
||||
#pragma GCC diagnostic pop
|
||||
}
|
||||
|
||||
// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement
|
||||
@@ -43,4 +50,18 @@ TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) {
|
||||
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64());
|
||||
}
|
||||
|
||||
// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields
|
||||
// printable (MSB-first) order, and round-tripping through the LSB-first
|
||||
// packer restores the original value.
|
||||
TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) {
|
||||
uint8_t msb_first[6];
|
||||
uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first);
|
||||
EXPECT_EQ(msb_first[0], 0xaa);
|
||||
EXPECT_EQ(msb_first[5], 0xff);
|
||||
uint8_t lsb_first[6];
|
||||
for (int i = 0; i < 6; i++)
|
||||
lsb_first[i] = msb_first[5 - i];
|
||||
EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
namespace {
|
||||
|
||||
// AD types under test
|
||||
constexpr uint8_t AD_SHORT_NAME = 0x08;
|
||||
constexpr uint8_t AD_COMPLETE_NAME = 0x09;
|
||||
|
||||
void append_name(std::vector<uint8_t> &adv, uint8_t ad_type, const char *name) {
|
||||
size_t len = strlen(name);
|
||||
adv.push_back(static_cast<uint8_t>(len + 1));
|
||||
adv.push_back(ad_type);
|
||||
adv.insert(adv.end(), name, name + len);
|
||||
}
|
||||
|
||||
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
|
||||
return device;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BleAdvName, ParsesACompleteName) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, "TP96");
|
||||
ESPBTDevice device = device_from(adv);
|
||||
EXPECT_EQ(device.get_name(), "TP96");
|
||||
// The backing buffer is NUL-terminated so c_str() is usable directly.
|
||||
EXPECT_STREQ(device.get_name().c_str(), "TP96");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, LongestNameWinsShortenedThenComplete) {
|
||||
// A merged adv + scan-response frame can carry both forms; the shortened
|
||||
// one must never replace the complete one.
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_SHORT_NAME, "Radon");
|
||||
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
|
||||
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, LongestNameWinsCompleteThenShortened) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
|
||||
append_name(adv, AD_SHORT_NAME, "Radon");
|
||||
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, MaxLengthNameFitsAndTerminates) {
|
||||
// 29 bytes is the largest name a legacy AD element can carry and exactly
|
||||
// fills the fixed buffer.
|
||||
std::string max_name(29, 'a');
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, max_name.c_str());
|
||||
ESPBTDevice device = device_from(adv);
|
||||
EXPECT_EQ(device.get_name().size(), 29u);
|
||||
EXPECT_EQ(device.get_name(), max_name);
|
||||
EXPECT_STREQ(device.get_name().c_str(), max_name.c_str());
|
||||
}
|
||||
|
||||
TEST(BleAdvName, ReparseResetsThePreviousName) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
std::vector<uint8_t> first;
|
||||
append_name(first, AD_COMPLETE_NAME, "RadonEye");
|
||||
std::vector<uint8_t> second;
|
||||
append_name(second, AD_COMPLETE_NAME, "TP96");
|
||||
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, first.data(), static_cast<uint16_t>(first.size()));
|
||||
ASSERT_EQ(device.get_name(), "RadonEye");
|
||||
// A shorter name from a fresh report must fully replace the longer one:
|
||||
// the longest-name rule applies within one report, not across reports.
|
||||
device.from_scan_result(mac, -59, 0, second.data(), static_cast<uint16_t>(second.size()));
|
||||
EXPECT_EQ(device.get_name(), "TP96");
|
||||
EXPECT_STREQ(device.get_name().c_str(), "TP96");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, NoNamePresentIsEmpty) {
|
||||
std::vector<uint8_t> adv = {0x02, 0x0A, 0x00}; // TX power only
|
||||
EXPECT_TRUE(device_from(adv).get_name().empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,60 @@ TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) {
|
||||
EXPECT_TRUE(u32 == u128);
|
||||
}
|
||||
|
||||
// A default-constructed UUID is UNSET, the historical "not configured" sentinel
|
||||
// (len 0 through the esp32 get_uuid() adapter).
|
||||
TEST(BleDeviceUuid, DefaultConstructedIsUnset) {
|
||||
const ESPBTUUID unset;
|
||||
EXPECT_EQ(unset.type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_TRUE(unset == ESPBTUUID());
|
||||
EXPECT_FALSE(unset == ESPBTUUID::from_uint16(0x1234));
|
||||
EXPECT_FALSE(unset.contains(0x00, 0x00));
|
||||
}
|
||||
|
||||
// Every factory yields a non-UNSET UUID, even for 0x0000: only default construction and a
|
||||
// failed text parse are unset, keeping type() != UNSET equivalent to the old len > 0 check.
|
||||
TEST(BleDeviceUuid, AllFactoriesProduceSetUuids) {
|
||||
const uint8_t raw[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
|
||||
EXPECT_NE(ESPBTUUID::from_uint16(0x0000).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_uint32(0).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw(raw).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw_reversed(raw).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("180F", 4).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("0000180F", 8).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw(reinterpret_cast<const char *>(raw), 16).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("6E400001-B5A3-F393-E0A9-E50E24DCCA9E").type(), ESPBTUUID::Type::UNSET);
|
||||
}
|
||||
|
||||
// 0x0000 is a valid short UUID on real devices (esphome/aioesphomeapi#1742); an unset
|
||||
// UUID must never compare equal to it. Unset equals only unset.
|
||||
TEST(BleDeviceUuid, UnsetIsNotEqualToZeroUuid) {
|
||||
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_uint16(0x0000));
|
||||
EXPECT_FALSE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID());
|
||||
const uint8_t base[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_raw(base));
|
||||
EXPECT_TRUE(ESPBTUUID() == ESPBTUUID());
|
||||
EXPECT_FALSE(ESPBTUUID().as_128bit().is_set()); // widening preserves the unset state
|
||||
// A configured 0x0000 still matches its own 128-bit base UUID expansion.
|
||||
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID::from_raw(base));
|
||||
}
|
||||
|
||||
// is_set() is the sentinel check; an unset UUID prints as "None" instead of a
|
||||
// valid-looking all-zero 128-bit UUID.
|
||||
TEST(BleDeviceUuid, IsSetAndUnsetToStr) {
|
||||
char buf[UUID_STR_LEN];
|
||||
EXPECT_FALSE(ESPBTUUID().is_set());
|
||||
EXPECT_STREQ(ESPBTUUID().to_str(buf), "None");
|
||||
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000).is_set());
|
||||
EXPECT_STREQ(ESPBTUUID::from_uint16(0x0000).to_str(buf), "0x0000");
|
||||
}
|
||||
|
||||
// Text parsing of an invalid length historically produced a len-0 (unset) UUID.
|
||||
TEST(BleDeviceUuid, InvalidTextFormParsesToUnset) {
|
||||
EXPECT_EQ(ESPBTUUID::from_raw("nope", 3).type(), ESPBTUUID::Type::UNSET);
|
||||
}
|
||||
|
||||
TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) {
|
||||
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235));
|
||||
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// The GATT client contract compiles in no real build until a hub backend is
|
||||
// configured; this TU pins it on the host so the header cannot rot unseen.
|
||||
// The contract is a concept (BLEGattConnection is a per-platform alias), so
|
||||
// the minimal backend here proves the concept stays satisfiable and routes
|
||||
// events through the GattClientListener interface the way a real backend does.
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// Overrides only what it records; the interface's defaults cover the rest.
|
||||
class RecordingListener : public GattClientListener {
|
||||
public:
|
||||
void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
|
||||
void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
|
||||
void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
|
||||
|
||||
bool connected_{false};
|
||||
int discovery_error_{0};
|
||||
uint16_t write_handle_{0};
|
||||
};
|
||||
|
||||
class MinimalConnection {
|
||||
public:
|
||||
void set_listener(GattClientListener *listener) { this->listener_ = listener; }
|
||||
|
||||
int connect(uint64_t address, uint8_t addr_type) {
|
||||
this->listener_->on_connection_state(true, 517, 0);
|
||||
return 0;
|
||||
}
|
||||
bool cancel_gatt_disconnect() { return false; }
|
||||
int gatt_disconnect() { return 0; }
|
||||
int discover_services() {
|
||||
this->listener_->on_service_discovery_done(0);
|
||||
return 0;
|
||||
}
|
||||
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; }
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
this->listener_->on_write_result(handle, 0);
|
||||
return 0;
|
||||
}
|
||||
int read_descriptor(uint16_t handle) { return 0; }
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; }
|
||||
int notify_characteristic(uint16_t handle, bool enable) { return 0; }
|
||||
int pair() { return GATT_ERR_NOT_CONNECTED; }
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
|
||||
return 0;
|
||||
}
|
||||
GattServiceTable get_service_table() { return {}; }
|
||||
void release_services() {}
|
||||
void set_connection_type(ConnectionType ct) {}
|
||||
|
||||
protected:
|
||||
GattClientListener *listener_{nullptr};
|
||||
};
|
||||
|
||||
static_assert(BLEGattConnectionContract<MinimalConnection>,
|
||||
"a minimal backend must satisfy the contract the alias asserts");
|
||||
|
||||
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
|
||||
MinimalConnection connection;
|
||||
RecordingListener listener;
|
||||
connection.set_listener(&listener);
|
||||
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
|
||||
EXPECT_TRUE(listener.connected_);
|
||||
EXPECT_EQ(connection.discover_services(), 0);
|
||||
EXPECT_EQ(listener.discovery_error_, 0);
|
||||
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
|
||||
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
|
||||
EXPECT_EQ(listener.write_handle_, 7);
|
||||
|
||||
// A default table is empty and safe to walk.
|
||||
GattServiceTable table = connection.get_service_table();
|
||||
EXPECT_EQ(table.service_count, 0);
|
||||
EXPECT_EQ(table.characteristic_count, 0);
|
||||
EXPECT_EQ(table.descriptor_count, 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -0,0 +1,158 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// from_manufacturer_data() accepts exactly the iBeacon frame: Apple company ID,
|
||||
// 23 payload bytes, and the 0x02/0x15 sub-type/length prefix. The prefix check
|
||||
// is stricter than the legacy esp32 parser (which surfaced any 23-byte Apple
|
||||
// payload as a beacon) — a declared behavior change; these tests pin the
|
||||
// accept/reject boundary.
|
||||
namespace {
|
||||
|
||||
ServiceData make_apple_payload(uint8_t sub_type, uint8_t length, size_t size = 23) {
|
||||
ServiceData data;
|
||||
data.uuid = ESPBTUUID::from_uint16(0x004C); // Apple company ID
|
||||
data.data.assign(size, 0);
|
||||
if (size >= 2) {
|
||||
data.data[0] = sub_type;
|
||||
data.data[1] = length;
|
||||
}
|
||||
// BeaconData layout: sub_type[0], length[1], proximity_uuid[2..17],
|
||||
// major[18..19], minor[20..21], signal_power[22] — all wire values big-endian.
|
||||
if (size >= 23) {
|
||||
data.data[18] = 0x12; // major 0x1234
|
||||
data.data[19] = 0x34;
|
||||
data.data[20] = 0x56; // minor 0x5678
|
||||
data.data[21] = 0x78;
|
||||
data.data[22] = 0xC5; // signal power -59 dBm
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BleIBeacon, AcceptsWellFormedFrame) {
|
||||
auto beacon = ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15));
|
||||
ASSERT_TRUE(beacon.has_value());
|
||||
// Explicit guard: clang-tidy's unchecked-optional-access models neither
|
||||
// gtest's ASSERT_TRUE nor value() as a check.
|
||||
if (beacon.has_value()) {
|
||||
// Pins every scalar accessor's offset and the on-wire big-endian order.
|
||||
EXPECT_EQ(beacon->get_major(), 0x1234);
|
||||
EXPECT_EQ(beacon->get_minor(), 0x5678);
|
||||
EXPECT_EQ(beacon->get_signal_power(), -59);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongSubType) {
|
||||
// Apple "nearby" and other frames of coincidental length must not parse.
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongLengthByte) {
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x14)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongPayloadSize) {
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 22)).has_value());
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 24)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsNonAppleCompany) {
|
||||
auto data = make_apple_payload(0x02, 0x15);
|
||||
data.uuid = ESPBTUUID::from_uint16(0x0059); // Nordic
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(data).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, PrefixRejectedFlagsOnlyTheSubTypeCase) {
|
||||
// The out-param drives the get_ibeacon() diagnostic for frames the legacy
|
||||
// parser accepted: exactly the 23-byte Apple payload with a wrong prefix.
|
||||
// Wrong size and non-Apple frames were never accepted and must stay silent.
|
||||
bool flagged = false;
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15), &flagged).has_value());
|
||||
EXPECT_TRUE(flagged);
|
||||
|
||||
flagged = false;
|
||||
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15), &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
|
||||
flagged = false;
|
||||
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15, 22), &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
|
||||
flagged = false;
|
||||
auto nordic = make_apple_payload(0x10, 0x15);
|
||||
nordic.uuid = ESPBTUUID::from_uint16(0x0059);
|
||||
ESPBLEiBeacon::from_manufacturer_data(nordic, &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One AD manufacturer-data record: [len][0xFF][company LE][payload...].
|
||||
void append_mfr_record(std::vector<uint8_t> &adv, uint16_t company, const std::vector<uint8_t> &payload) {
|
||||
adv.push_back(static_cast<uint8_t>(1 + 2 + payload.size()));
|
||||
adv.push_back(0xFF);
|
||||
adv.push_back(static_cast<uint8_t>(company & 0xFF));
|
||||
adv.push_back(static_cast<uint8_t>(company >> 8));
|
||||
adv.insert(adv.end(), payload.begin(), payload.end());
|
||||
}
|
||||
|
||||
std::vector<uint8_t> beacon_payload(uint8_t sub_type, uint8_t length) {
|
||||
std::vector<uint8_t> p(23, 0);
|
||||
p[0] = sub_type;
|
||||
p[1] = length;
|
||||
p[18] = 0x12;
|
||||
p[19] = 0x34;
|
||||
p[20] = 0x56;
|
||||
p[21] = 0x78;
|
||||
p[22] = 0xC5;
|
||||
return p;
|
||||
}
|
||||
|
||||
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
|
||||
return device;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// get_ibeacon() wraps the parser with first-rejection capture and the log
|
||||
// gate; pin its short circuits so a regression there needs a code change, not
|
||||
// a review, to surface.
|
||||
TEST(BleIBeacon, GetIbeaconReturnsBeaconDespitePrecedingRejectedFrame) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); // rejected prefix
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x02, 0x15)); // real iBeacon
|
||||
auto device = device_from(adv);
|
||||
auto beacon = device.get_ibeacon();
|
||||
ASSERT_TRUE(beacon.has_value());
|
||||
if (beacon.has_value()) {
|
||||
EXPECT_EQ(beacon->get_major(), 0x1234);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, GetIbeaconEmptyWhenOnlyRejectedFrames) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15));
|
||||
auto device = device_from(adv);
|
||||
EXPECT_FALSE(device.get_ibeacon().has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, GetIbeaconEmptyWithoutManufacturerData) {
|
||||
std::vector<uint8_t> adv;
|
||||
adv.push_back(0x02); // flags record only
|
||||
adv.push_back(0x01);
|
||||
adv.push_back(0x06);
|
||||
auto device = device_from(adv);
|
||||
EXPECT_FALSE(device.get_ibeacon().has_value());
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing {
|
||||
//
|
||||
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
|
||||
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
|
||||
// pinned here through a minimal host BLEHub implementation instead.
|
||||
// pinned here through a minimal host hub carrying only the slot under test.
|
||||
namespace {
|
||||
|
||||
class FakeHub : public BLEHub {
|
||||
class FakeHub {
|
||||
public:
|
||||
void register_listener(ESPBTDeviceListener *listener) override {}
|
||||
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
|
||||
HubCapabilities get_capabilities() const override { return {false, false, false}; }
|
||||
void get_adapter_mac(uint8_t out[6]) override {}
|
||||
bool scan_running() override { return false; }
|
||||
bool scan_active() override { return false; }
|
||||
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; }
|
||||
|
||||
/// The emit path every tracker implements: fire only when a subscriber is set.
|
||||
void emit(const RawAdvertisement &adv) {
|
||||
@@ -46,13 +41,13 @@ struct CapturingSubscriber {
|
||||
}
|
||||
};
|
||||
|
||||
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
|
||||
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
|
||||
// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it.
|
||||
constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL;
|
||||
const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00};
|
||||
|
||||
RawAdvertisement make_test_adv() {
|
||||
return RawAdvertisement{
|
||||
.mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
|
||||
.address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -70,7 +65,7 @@ TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) {
|
||||
hub.emit(make_test_adv());
|
||||
|
||||
ASSERT_EQ(subscriber.calls, 1);
|
||||
EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST);
|
||||
EXPECT_EQ(subscriber.last.address, TEST_ADDRESS);
|
||||
EXPECT_EQ(subscriber.last.data, ADV_DATA);
|
||||
EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA));
|
||||
EXPECT_EQ(subscriber.last.rssi, -63);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// The host test build gets this from the manifest override; clang-tidy does not.
|
||||
#ifndef USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#define USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#endif
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ble_device_base/scan_response_merger.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
namespace {
|
||||
|
||||
// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot
|
||||
// bookkeeping, the same-device reuse path, the table-full fallback, the
|
||||
// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate.
|
||||
// Delivery is observed through a real AdvDispatcher: the raw callback sees
|
||||
// every frame (including raw_only), a listener only the parsed ones.
|
||||
|
||||
struct DeliveredFrame {
|
||||
uint64_t address;
|
||||
std::vector<uint8_t> data;
|
||||
int8_t rssi;
|
||||
};
|
||||
|
||||
struct RawCapture {
|
||||
std::vector<DeliveredFrame> frames;
|
||||
|
||||
static void trampoline(void *self, const RawAdvertisement &adv) {
|
||||
auto *capture = static_cast<RawCapture *>(self);
|
||||
capture->frames.push_back({adv.address, std::vector<uint8_t>(adv.data, adv.data + adv.data_len), adv.rssi});
|
||||
}
|
||||
};
|
||||
|
||||
class CountingListener : public ESPBTDeviceListener {
|
||||
public:
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
this->parsed++;
|
||||
return true; // claimed: keeps the discovered log quiet
|
||||
}
|
||||
int parsed{0};
|
||||
};
|
||||
|
||||
class ScanResponseMergerTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline});
|
||||
this->dispatcher_.register_listener(&this->listener_);
|
||||
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test");
|
||||
}
|
||||
|
||||
void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) {
|
||||
std::vector<uint8_t> data(data_len, fill);
|
||||
this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now);
|
||||
}
|
||||
|
||||
void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) {
|
||||
std::vector<uint8_t> data(data_len, fill);
|
||||
this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len);
|
||||
}
|
||||
|
||||
ScanResponseMerger merger_;
|
||||
AdvDispatcher dispatcher_;
|
||||
RawCapture raw_;
|
||||
CountingListener listener_;
|
||||
bool scan_continuous_{true};
|
||||
};
|
||||
|
||||
constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
|
||||
constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16};
|
||||
|
||||
TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA);
|
||||
EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered
|
||||
|
||||
this->scan_rsp_(MAC_A, -70, 10, 0xBB);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
const auto &frame = this->raw_.frames[0];
|
||||
ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame
|
||||
EXPECT_EQ(frame.data[0], 0xAA);
|
||||
EXPECT_EQ(frame.data[19], 0xAA);
|
||||
EXPECT_EQ(frame.data[20], 0xBB);
|
||||
// The advertisement's RSSI, never the scan response's.
|
||||
EXPECT_EQ(frame.rssi, -40);
|
||||
EXPECT_EQ(this->listener_.parsed, 1);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA);
|
||||
this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 20u);
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
|
||||
EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot
|
||||
|
||||
this->scan_rsp_(MAC_A, -70, 5, 0xBB);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 2u);
|
||||
EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot
|
||||
EXPECT_EQ(this->raw_.frames[1].rssi, -45);
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) {
|
||||
uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
for (uint8_t i = 0; i < 8; i++) {
|
||||
mac[5] = i;
|
||||
this->stash_(mac, -50, 10, i);
|
||||
}
|
||||
EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held
|
||||
|
||||
mac[5] = 8;
|
||||
this->stash_(mac, -50, 10, 8); // 9th device: no slot left
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
|
||||
|
||||
this->merger_.flush(); // the 8 held frames are all still intact
|
||||
EXPECT_EQ(this->raw_.frames.size(), 9u);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) {
|
||||
this->stash_(MAC_A, -40, 31, 0xAA);
|
||||
this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 62u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB);
|
||||
EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB);
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) {
|
||||
this->scan_rsp_(MAC_B, -60, 12, 0xDD);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -60);
|
||||
EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) {
|
||||
std::vector<uint8_t> adv(20, 0xAA);
|
||||
this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0);
|
||||
std::vector<uint8_t> rsp(10, 0xBB);
|
||||
this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size());
|
||||
// Same MAC, different addr_type: no merge — the response goes out raw_only.
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
|
||||
EXPECT_EQ(this->listener_.parsed, 0);
|
||||
EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
|
||||
this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout
|
||||
EXPECT_TRUE(this->raw_.frames.empty());
|
||||
this->merger_.sweep(1301);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
|
||||
EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
|
||||
this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000);
|
||||
this->merger_.flush();
|
||||
EXPECT_EQ(this->raw_.frames.size(), 2u);
|
||||
EXPECT_EQ(this->listener_.parsed, 2);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) {
|
||||
ScanResponseMerger unbound;
|
||||
std::vector<uint8_t> data(20, 0xAA);
|
||||
unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
|
||||
unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
|
||||
unbound.sweep(1000);
|
||||
unbound.flush(); // no null jump anywhere
|
||||
EXPECT_TRUE(unbound.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) {
|
||||
ScanResponseMerger partial;
|
||||
partial.bind(&this->dispatcher_, nullptr, "test");
|
||||
std::vector<uint8_t> data(20, 0xAA);
|
||||
partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
|
||||
partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
|
||||
partial.flush(); // dropped, not dispatched through half a binding
|
||||
EXPECT_TRUE(this->raw_.frames.empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -0,0 +1,52 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback:
|
||||
// a default-constructed slot is "no subscriber", a set slot delivers the
|
||||
// state, and a new registration replaces the old.
|
||||
namespace {
|
||||
|
||||
struct CapturingSubscriber {
|
||||
ScannerState last{ScannerState::IDLE};
|
||||
int calls{0};
|
||||
|
||||
static void trampoline(void *self, ScannerState state) {
|
||||
auto *sub = static_cast<CapturingSubscriber *>(self);
|
||||
sub->last = state;
|
||||
sub->calls++;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) {
|
||||
const ScannerStateCallback callback{};
|
||||
EXPECT_FALSE(callback.is_set());
|
||||
}
|
||||
|
||||
TEST(ScannerStateCallback, SubscriberSeesState) {
|
||||
CapturingSubscriber subscriber;
|
||||
ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline};
|
||||
ASSERT_TRUE(callback.is_set());
|
||||
callback.invoke(ScannerState::RUNNING);
|
||||
EXPECT_EQ(subscriber.calls, 1);
|
||||
EXPECT_EQ(subscriber.last, ScannerState::RUNNING);
|
||||
}
|
||||
|
||||
TEST(ScannerStateCallback, NewSubscriberReplacesOld) {
|
||||
CapturingSubscriber first;
|
||||
CapturingSubscriber second;
|
||||
ScannerStateCallback callback{&first, CapturingSubscriber::trampoline};
|
||||
callback = {&second, CapturingSubscriber::trampoline};
|
||||
callback.invoke(ScannerState::STOPPED);
|
||||
EXPECT_EQ(first.calls, 0);
|
||||
EXPECT_EQ(second.calls, 1);
|
||||
EXPECT_EQ(second.last, ScannerState::STOPPED);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -0,0 +1,4 @@
|
||||
binary_sensor:
|
||||
- platform: ble_presence
|
||||
mac_address: 11:22:33:44:55:66
|
||||
name: BLE Test Presence
|
||||
@@ -1,7 +1,10 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
binary_sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: ble_presence
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
name: ESP32 BLE Tracker Google Home Mini
|
||||
- platform: ble_presence
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
ble_presence: !include common-ln.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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_hub
|
||||
|
||||
binary_sensor:
|
||||
- platform: ble_presence
|
||||
ble_hub_id: ble_hub
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
name: BK BLE Presence
|
||||
- platform: ble_presence
|
||||
irk: 1234567890abcdef1234567890abcdef
|
||||
name: BK BLE Presence IRK
|
||||
@@ -0,0 +1,5 @@
|
||||
sensor:
|
||||
- platform: ble_rssi
|
||||
# irk: is the only thing that emits USE_BLE_DEVICE_IRK off ESP32
|
||||
irk: 1234567890abcdef1234567890abcdef
|
||||
name: BLE Test RSSI
|
||||
@@ -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: ble_rssi
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
name: BLE Google Home Mini RSSI value
|
||||
- platform: ble_rssi
|
||||
@@ -14,7 +17,9 @@ sensor:
|
||||
service_uuid: 11223344-5566-7788-99aa-bbccddeeff00
|
||||
name: BLE Test Service 128
|
||||
- platform: ble_rssi
|
||||
service_uuid: 11223344-5566-7788-99aa-bbccddeeff00
|
||||
ibeacon_uuid: 11223344-5566-7788-99aa-bbccddeeff00
|
||||
ibeacon_major: 100
|
||||
ibeacon_minor: 1
|
||||
name: BLE Test iBeacon UUID
|
||||
- platform: ble_rssi
|
||||
irk: 1234567890abcdef1234567890abcdef
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
ble_rssi: !include common-ln.yaml
|
||||
@@ -0,0 +1,11 @@
|
||||
# Config-only: pins the esp32_ble_id: -> ble_hub_id: deprecation alias — the
|
||||
# legacy key must keep validating (with a rename warning) until its removal
|
||||
# release (2027.2.0).
|
||||
esp32_ble_tracker:
|
||||
id: legacy_tracker
|
||||
|
||||
sensor:
|
||||
- platform: ble_rssi
|
||||
esp32_ble_id: legacy_tracker
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
name: Legacy Key RSSI
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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_hub
|
||||
|
||||
sensor:
|
||||
- platform: ble_rssi
|
||||
ble_hub_id: ble_hub
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
name: BK BLE RSSI
|
||||
- platform: ble_rssi
|
||||
irk: 1234567890abcdef1234567890abcdef
|
||||
name: BK BLE RSSI IRK
|
||||
@@ -0,0 +1,3 @@
|
||||
text_sensor:
|
||||
- platform: ble_scanner
|
||||
name: BLE Test Scanner
|
||||
@@ -1,5 +1,8 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
text_sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: ble_scanner
|
||||
ble_hub_id: ble_tracker_hub
|
||||
name: Scanner
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
ble_scanner: !include common-ln.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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_hub
|
||||
|
||||
text_sensor:
|
||||
- platform: ble_scanner
|
||||
ble_hub_id: ble_hub
|
||||
name: BK Scanner
|
||||
# No ble_hub_id: exercises the generated binding _require_hub guards.
|
||||
- platform: ble_scanner
|
||||
name: BK Scanner Implicit
|
||||
@@ -0,0 +1,22 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS;
|
||||
# emit the backend define so the host build exercises it.
|
||||
async def to_code_testing(config):
|
||||
# These defines are global to the merged host test binary; safe
|
||||
# because no co-compiled test observes them.
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND")
|
||||
cg.add_define("USE_BLUETOOTH_PROXY")
|
||||
# Gates the connection half of the API surface, which is what
|
||||
# close_service_batch and the GATT response types live behind.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
# The batcher sizes api protobuf messages.
|
||||
manifest.dependencies = manifest.dependencies + ["api"]
|
||||
@@ -0,0 +1,8 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// The three cursor behaviors: a fitting service advances and continues, an
|
||||
// overflowing batch with >1 service pops and retries it, and a single
|
||||
// oversized service is force-advanced so the stream cannot wedge.
|
||||
|
||||
static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) {
|
||||
resp.services.emplace_back();
|
||||
auto &svc = resp.services.back();
|
||||
svc.handle = resp.services.size();
|
||||
svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL};
|
||||
svc.characteristics.init(characteristics);
|
||||
for (uint16_t i = 0; i < characteristics; i++) {
|
||||
auto &chr = svc.characteristics.emplace_back();
|
||||
chr.handle = 100 + i;
|
||||
chr.properties = 0x12;
|
||||
chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL};
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) {
|
||||
api::BluetoothGATTGetServicesResponse resp;
|
||||
add_service(resp, 1);
|
||||
size_t current_size = 0;
|
||||
int16_t cursor = 0;
|
||||
EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE);
|
||||
EXPECT_EQ(cursor, 1);
|
||||
EXPECT_GT(current_size, 0u);
|
||||
}
|
||||
|
||||
TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) {
|
||||
api::BluetoothGATTGetServicesResponse resp;
|
||||
add_service(resp, 1);
|
||||
add_service(resp, 1);
|
||||
size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes
|
||||
int16_t cursor = 5;
|
||||
EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND);
|
||||
EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch
|
||||
EXPECT_EQ(cursor, 5); // not advanced: retried next batch
|
||||
}
|
||||
|
||||
TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) {
|
||||
api::BluetoothGATTGetServicesResponse resp;
|
||||
add_service(resp, 60); // ~30 bytes per characteristic, far past the budget
|
||||
ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE);
|
||||
size_t current_size = 0;
|
||||
int16_t cursor = 7;
|
||||
EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND);
|
||||
EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pins the shared UUID wire packing and the size-estimate budget the service
|
||||
// streamers rely on, in both efficient and legacy client modes.
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace esphome::bluetooth_connection::testing {
|
||||
|
||||
using ble_device_base::ESPBTUUID;
|
||||
|
||||
TEST(GattUuidPacking, ShortUuidUsedWhenClientSupportsIt) {
|
||||
std::array<uint64_t, 2> uuid128{};
|
||||
uint32_t short_uuid = 0;
|
||||
fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), true);
|
||||
EXPECT_EQ(short_uuid, 0x180Fu);
|
||||
EXPECT_EQ(uuid128[0], 0u);
|
||||
EXPECT_EQ(uuid128[1], 0u);
|
||||
}
|
||||
|
||||
TEST(GattUuidPacking, LegacyClientGetsBaseUuidExpansion) {
|
||||
// 0000180F-0000-1000-8000-00805F9B34FB
|
||||
std::array<uint64_t, 2> uuid128{};
|
||||
uint32_t short_uuid = 0;
|
||||
fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), false);
|
||||
EXPECT_EQ(short_uuid, 0u);
|
||||
EXPECT_EQ(uuid128[0], 0x0000180F00001000ULL);
|
||||
EXPECT_EQ(uuid128[1], 0x800000805F9B34FBULL);
|
||||
}
|
||||
|
||||
TEST(GattUuidPacking, FullUuidPassesThroughBigEndian) {
|
||||
// 12345678-90AB-CDEF-1122-334455667788, stored little-endian in ESPBTUUID.
|
||||
const uint8_t big_endian[16] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88};
|
||||
std::array<uint64_t, 2> uuid128{};
|
||||
uint32_t short_uuid = 0;
|
||||
// Efficient mode must still use the 128-bit form for 128-bit UUIDs.
|
||||
fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_raw_reversed(big_endian), true);
|
||||
EXPECT_EQ(short_uuid, 0u);
|
||||
EXPECT_EQ(uuid128[0], 0x1234567890ABCDEFULL);
|
||||
EXPECT_EQ(uuid128[1], 0x1122334455667788ULL);
|
||||
}
|
||||
|
||||
TEST(GattUuidPacking, EstimateGrowsWithCharacteristicsAndMode) {
|
||||
// The estimate only gates batching; pin its shape, not exact bytes.
|
||||
EXPECT_LT(estimate_service_size(0, true), estimate_service_size(0, false));
|
||||
EXPECT_LT(estimate_service_size(1, false), estimate_service_size(2, false));
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection::testing
|
||||
@@ -0,0 +1,12 @@
|
||||
# Distinct shape from bluetooth_proxy's own rp2 fixtures: explicit slot count
|
||||
# on the platform whose backend lives in this component (validate-only, so it
|
||||
# never collides with grouped builds).
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
# Two slots: the one shape where the wrap pools are smaller than the cap.
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
connection_slots: 2
|
||||
@@ -0,0 +1,12 @@
|
||||
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
|
||||
# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the
|
||||
# address-scoped maintenance path that a connections build never exercises.
|
||||
# Under batch grouping the active default build is what runs; the standalone
|
||||
# compile of this fixture is what exercises the passive gating.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: false
|
||||
@@ -0,0 +1,11 @@
|
||||
# Advertisement-only proxy on rp2 by explicit choice. Variant tests compile
|
||||
# as their own builds when this component is tested individually; under CI
|
||||
# batch grouping the active default build is what runs, so this fixture's
|
||||
# guarantee is the individual run plus config validation.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: false
|
||||
@@ -0,0 +1,10 @@
|
||||
# Advertisement-only proxy on the ln882x BLE hub (active-scan-capable, in-tree
|
||||
# since #16691) — a target CI fully compiles. Same bare-hub arrangement as
|
||||
# test.rp2040-ard.yaml: no explicit ble_hub_id so a grouped build cannot
|
||||
# collide with ln882h_ble_tracker's own fixture id.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
ln882h_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,15 @@
|
||||
# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity),
|
||||
# so this compiles the BTstack GATT client backend with the default three
|
||||
# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap
|
||||
# link.
|
||||
# No explicit ble_hub_id: the generated binding resolves the single declared
|
||||
# hub, and an inline id here would collide with rp2_ble_tracker's own fixture
|
||||
# once CI merges both components into one grouped rp2040-ard build (grouped
|
||||
# component dicts collapse; only one id survives). The explicit-key form is
|
||||
# covered by validate.rp2040-ard.yaml, which never participates in grouping.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,9 @@
|
||||
# Pico 2 W build of the full proxy: links the rp2350 framework archive, so
|
||||
# the pool --wrap overrides and their per-architecture layout asserts are
|
||||
# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape).
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,11 @@
|
||||
# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the
|
||||
# tracker's packed-command start). Config-only: the CI base board generic-bk7252
|
||||
# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement
|
||||
# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot
|
||||
# collide with bk72xx_ble_tracker's own fixture id.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,13 @@
|
||||
# Connections given as a bare list, with no explicit per-entry id. The ids are
|
||||
# generated during validation, so this config breaks if the schema validates the
|
||||
# connections list more than once.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
connections:
|
||||
- {}
|
||||
- {}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Explicit ble_hub_id on the rp2 hub — the documented disambiguator once a
|
||||
# platform has more than one tracker. Validate-only: never merged into grouped
|
||||
# builds, so the inline id cannot collide with rp2_ble_tracker's own fixture.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
id: ble_hub
|
||||
|
||||
bluetooth_proxy:
|
||||
ble_hub_id: ble_hub
|
||||
@@ -0,0 +1,9 @@
|
||||
sensor:
|
||||
- platform: bthome_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
# bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif
|
||||
bindkey: eef418daf699a0c188f3bfd17e4565d9
|
||||
temperature:
|
||||
name: BTHome Temperature
|
||||
humidity:
|
||||
name: BTHome 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: bthome_mithermometer
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
bindkey: eef418daf699a0c188f3bfd17e4565d9
|
||||
temperature:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
bthome_mithermometer: !include common-ln.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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_hub
|
||||
|
||||
sensor:
|
||||
- platform: bthome_mithermometer
|
||||
ble_hub_id: ble_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
bindkey: eef418daf699a0c188f3bfd17e4565d9
|
||||
temperature:
|
||||
name: BK BTHome Temperature
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: bthome_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:79
|
||||
temperature:
|
||||
name: BK BTHome Implicit Temperature
|
||||
@@ -55,4 +55,32 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StaticVectorTest, ConvertingConstructorFromSmaller) {
|
||||
StaticVector<uint8_t, 5> small{0x03, 0x00, 0x10, 0x00, 0x01};
|
||||
StaticVector<uint8_t, 253> big = small;
|
||||
ASSERT_EQ(big.size(), small.size());
|
||||
for (size_t i = 0; i < small.size(); i++) {
|
||||
EXPECT_EQ(big[i], small[i]) << "mismatch at index " << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StaticVectorTest, ConvertingConstructorPartiallyFilledAndEmpty) {
|
||||
StaticVector<uint8_t, 5> partial{0xAA, 0xBB};
|
||||
StaticVector<uint8_t, 8> from_partial = partial;
|
||||
ASSERT_EQ(from_partial.size(), 2u);
|
||||
EXPECT_EQ(from_partial[0], 0xAA);
|
||||
EXPECT_EQ(from_partial[1], 0xBB);
|
||||
|
||||
StaticVector<uint8_t, 5> empty;
|
||||
StaticVector<uint8_t, 8> from_empty = empty;
|
||||
EXPECT_TRUE(from_empty.empty());
|
||||
}
|
||||
|
||||
TEST(StaticVectorTest, ConvertingConstructorSameSize) {
|
||||
StaticVector<int, 3> src{1, 2, 3};
|
||||
StaticVector<int, 3> dst = src;
|
||||
ASSERT_EQ(dst.size(), 3u);
|
||||
EXPECT_EQ(dst[2], 3);
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "esphome/core/event_pool.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <set>
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
struct PoolItem {
|
||||
int value{0};
|
||||
// EventPool contract: release() cleans up per-object state; nothing here.
|
||||
void release() {}
|
||||
};
|
||||
|
||||
TEST(EventPool, AllocateUpToCapacityThenNull) {
|
||||
esphome::EventPool<PoolItem, 4> pool;
|
||||
PoolItem *items[4];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
}
|
||||
// At capacity: the pool refuses rather than growing past SIZE.
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
TEST(EventPool, FullDrainRetainsEveryObject) {
|
||||
// Pins the SIZE + 1 free-list sizing: a fully returned pool must hold all
|
||||
// SIZE objects. With a SIZE-slot ring (capacity SIZE - 1) the last release()
|
||||
// of a full drain was dropped, permanently orphaning one object.
|
||||
esphome::EventPool<PoolItem, 4> pool;
|
||||
PoolItem *items[4];
|
||||
for (auto *&item : items)
|
||||
item = pool.allocate();
|
||||
for (auto *item : items)
|
||||
pool.release(item);
|
||||
|
||||
// Every object must be allocatable again — no orphan, no new creation
|
||||
// (total_created_ is already at SIZE, so a lost object would surface as a
|
||||
// nullptr on the fourth allocation).
|
||||
std::set<PoolItem *> seen;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
PoolItem *item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
seen.insert(item);
|
||||
}
|
||||
// And they are the same four objects, recycled rather than re-created.
|
||||
for (auto *item : items)
|
||||
EXPECT_TRUE(seen.count(item) == 1);
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
TEST(EventPool, RepeatedDrainCyclesAreStable) {
|
||||
esphome::EventPool<PoolItem, 3> pool;
|
||||
// Several full allocate/release cycles: capacity must not shrink over time.
|
||||
for (int cycle = 0; cycle < 10; cycle++) {
|
||||
PoolItem *items[3];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
}
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
for (auto *item : items)
|
||||
pool.release(item);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(EventPool, ReleaseNullptrIsSafe) {
|
||||
esphome::EventPool<PoolItem, 2> pool;
|
||||
pool.release(nullptr);
|
||||
EXPECT_NE(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
TEST(EventPool, WarmFullyPopulatesThePool) {
|
||||
// warm()'s guarantee is invisible at runtime: no later allocate() may touch
|
||||
// malloc(). Fully populated means SIZE allocations succeed from the free
|
||||
// list and the SIZE + 1-th refuses.
|
||||
esphome::EventPool<PoolItem, 4> pool;
|
||||
ASSERT_TRUE(pool.warm());
|
||||
PoolItem *items[4];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
}
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
TEST(EventPool, WarmIsIdempotent) {
|
||||
esphome::EventPool<PoolItem, 3> pool;
|
||||
ASSERT_TRUE(pool.warm());
|
||||
ASSERT_TRUE(pool.warm());
|
||||
// Still exactly SIZE objects: no growth past capacity.
|
||||
PoolItem *items[3];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
}
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
TEST(EventPool, AllocateAfterWarmRecyclesTheWarmedObjects) {
|
||||
// The objects handed out after warm() are the ones warm() created,
|
||||
// recycled rather than re-created.
|
||||
esphome::EventPool<PoolItem, 4> pool;
|
||||
ASSERT_TRUE(pool.warm());
|
||||
std::set<PoolItem *> first_round;
|
||||
PoolItem *items[4];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
first_round.insert(item);
|
||||
}
|
||||
for (auto *item : items)
|
||||
pool.release(item);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
PoolItem *item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
EXPECT_TRUE(first_round.count(item) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(EventPool, WarmTopsUpWithEntriesOutstanding) {
|
||||
// warm() counts existing entries (free or checked out) instead of failing
|
||||
// when some are outstanding: it tops the pool up from any state.
|
||||
esphome::EventPool<PoolItem, 4> pool;
|
||||
PoolItem *held = pool.allocate();
|
||||
ASSERT_NE(held, nullptr);
|
||||
ASSERT_TRUE(pool.warm());
|
||||
// The held object plus three more accounts for all SIZE entries.
|
||||
PoolItem *items[3];
|
||||
for (auto *&item : items) {
|
||||
item = pool.allocate();
|
||||
ASSERT_NE(item, nullptr);
|
||||
}
|
||||
EXPECT_EQ(pool.allocate(), nullptr);
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,115 @@
|
||||
// Exercises the LockFreeQueue PlainAtomic path under ESPHOME_THREAD_SINGLE —
|
||||
// the gate added for single-threaded platforms whose only concurrency is
|
||||
// same-core interrupt preemption (RP2: BTstack packet handler in the CYW43
|
||||
// async-context IRQ). The define is forced before the include so this TU
|
||||
// deterministically compiles that path regardless of the host's default
|
||||
// thread model. The instantiations here deliberately differ from
|
||||
// test_lock_free_queue.cpp's (uint32_t elements, non-power-of-2 sizes): no
|
||||
// template instantiation is shared between the two TUs, so the differing
|
||||
// AtomicIndex definitions can never collide under the one-definition rule,
|
||||
// and the non-power-of-2 sizes cover next_index()'s comparison branch, which
|
||||
// the other TU's power-of-2 sizes never reach.
|
||||
#define ESPHOME_THREAD_SINGLE
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <type_traits>
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
// Pin the gate itself: under ESPHOME_THREAD_SINGLE the index type must be the
|
||||
// PlainAtomic fallback, not std::atomic — otherwise the RP2040 build silently
|
||||
// pulls __atomic_* library calls back in.
|
||||
static_assert(!std::is_same_v<esphome::lockfree_internal::AtomicIndex<uint8_t>, std::atomic<uint8_t>>,
|
||||
"ESPHOME_THREAD_SINGLE must select the PlainAtomic index path");
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, EmptyPopReturnsNull) {
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
EXPECT_EQ(q.pop(), nullptr);
|
||||
EXPECT_TRUE(q.empty());
|
||||
EXPECT_FALSE(q.full());
|
||||
EXPECT_EQ(q.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, FifoOrder) {
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
uint32_t a = 1, b = 2, c = 3, d = 4;
|
||||
EXPECT_TRUE(q.push(&a));
|
||||
EXPECT_TRUE(q.push(&b));
|
||||
EXPECT_TRUE(q.push(&c));
|
||||
EXPECT_TRUE(q.push(&d));
|
||||
EXPECT_EQ(q.size(), 4u);
|
||||
EXPECT_EQ(q.pop(), &a);
|
||||
EXPECT_EQ(q.pop(), &b);
|
||||
EXPECT_EQ(q.pop(), &c);
|
||||
EXPECT_EQ(q.pop(), &d);
|
||||
EXPECT_EQ(q.pop(), nullptr);
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, CapacityIsSizeMinusOne) {
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
uint32_t v[5] = {0, 1, 2, 3, 4};
|
||||
EXPECT_TRUE(q.push(&v[0]));
|
||||
EXPECT_TRUE(q.push(&v[1]));
|
||||
EXPECT_TRUE(q.push(&v[2]));
|
||||
EXPECT_TRUE(q.push(&v[3]));
|
||||
EXPECT_TRUE(q.full());
|
||||
// Ring reserves one slot: the SIZEth push fails and is counted as dropped.
|
||||
EXPECT_FALSE(q.push(&v[4]));
|
||||
EXPECT_EQ(q.get_and_reset_dropped_count(), 1u);
|
||||
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, NullPushRejected) {
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
EXPECT_FALSE(q.push(nullptr));
|
||||
EXPECT_TRUE(q.empty());
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, WrapAround) {
|
||||
// Non-power-of-2 SIZE: next_index() wraps via the comparison branch here.
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
uint32_t v[4] = {10, 20, 30, 40};
|
||||
// Cycle several times the ring size to cross the wrap boundary repeatedly.
|
||||
for (int cycle = 0; cycle < 10; cycle++) {
|
||||
for (auto &value : v)
|
||||
ASSERT_TRUE(q.push(&value));
|
||||
EXPECT_TRUE(q.full());
|
||||
for (auto &value : v)
|
||||
ASSERT_EQ(q.pop(), &value);
|
||||
EXPECT_TRUE(q.empty());
|
||||
}
|
||||
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u);
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, IncrementDroppedCount) {
|
||||
esphome::LockFreeQueue<uint32_t, 5> q;
|
||||
// Producer-side external drop accounting (pool exhausted before push).
|
||||
q.increment_dropped_count();
|
||||
q.increment_dropped_count();
|
||||
EXPECT_EQ(q.get_and_reset_dropped_count(), 2u);
|
||||
}
|
||||
|
||||
TEST(LockFreeQueueThreadSingle, InterleavedPushPop) {
|
||||
esphome::LockFreeQueue<uint32_t, 7> q;
|
||||
uint32_t v[64];
|
||||
uint32_t popped = 0;
|
||||
for (uint32_t i = 0; i < 64; i++) {
|
||||
v[i] = i;
|
||||
ASSERT_TRUE(q.push(&v[i]));
|
||||
if (i % 2 == 1) {
|
||||
uint32_t *first = q.pop();
|
||||
ASSERT_NE(first, nullptr);
|
||||
EXPECT_EQ(*first, popped++);
|
||||
uint32_t *second = q.pop();
|
||||
ASSERT_NE(second, nullptr);
|
||||
EXPECT_EQ(*second, popped++);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(q.empty());
|
||||
EXPECT_EQ(popped, 64u);
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,91 @@
|
||||
// Pins the preferences contract concepts so the surface they enforce cannot
|
||||
// drift unnoticed: a minimal conforming type must satisfy each concept, and a
|
||||
// type missing a method or returning the wrong type must not.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/preference_backend.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
struct MinimalBackend {
|
||||
bool save(const uint8_t *, size_t) { return true; }
|
||||
bool load(uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(PreferenceBackendContract<MinimalBackend>);
|
||||
|
||||
struct BackendMissingLoad {
|
||||
bool save(const uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(!PreferenceBackendContract<BackendMissingLoad>);
|
||||
|
||||
struct BackendWrongReturn {
|
||||
void save(const uint8_t *, size_t) {}
|
||||
bool load(uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(!PreferenceBackendContract<BackendWrongReturn>);
|
||||
|
||||
struct MinimalPreferences : public PreferencesMixin<MinimalPreferences> {
|
||||
using PreferencesMixin<MinimalPreferences>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(PreferencesContract<MinimalPreferences>);
|
||||
|
||||
struct PreferencesMissingTwoArgForm : public PreferencesMixin<PreferencesMissingTwoArgForm> {
|
||||
using PreferencesMixin<PreferencesMissingTwoArgForm>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesMissingTwoArgForm>);
|
||||
|
||||
struct PreferencesMissingReset : public PreferencesMixin<PreferencesMissingReset> {
|
||||
using PreferencesMixin<PreferencesMissingReset>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesMissingReset>);
|
||||
|
||||
struct PreferencesWrongSyncReturn : public PreferencesMixin<PreferencesWrongSyncReturn> {
|
||||
using PreferencesMixin<PreferencesWrongSyncReturn>::make_preference;
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
void sync() {}
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesWrongSyncReturn>);
|
||||
|
||||
// Forgot `using PreferencesMixin<X>::make_preference;`, so the derived
|
||||
// overloads hide the template forms (see the PreferencesContract note in
|
||||
// preference_backend.h); the concept must reject the class.
|
||||
struct PreferencesForgotUsingDeclaration : public PreferencesMixin<PreferencesForgotUsingDeclaration> {
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; }
|
||||
ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; }
|
||||
bool sync() { return true; }
|
||||
bool reset() { return true; }
|
||||
};
|
||||
static_assert(!PreferencesContract<PreferencesForgotUsingDeclaration>);
|
||||
|
||||
struct MinimalKeyLookup {
|
||||
bool load_from_key(uint32_t, uint8_t *, size_t) { return true; }
|
||||
};
|
||||
static_assert(PreferencesKeyLookupContract<MinimalKeyLookup>);
|
||||
|
||||
struct KeyLookupMissingMethod {};
|
||||
static_assert(!PreferencesKeyLookupContract<KeyLookupMissingMethod>);
|
||||
|
||||
TEST(PreferenceContract, NullBackendRefusesBothOperations) {
|
||||
// ESPPreferenceObject forwards to whichever backend the platform binds; a
|
||||
// default-constructed object has no backend and must refuse both operations
|
||||
// instead of crashing.
|
||||
ESPPreferenceObject without_backend;
|
||||
uint32_t value = 42;
|
||||
EXPECT_FALSE(without_backend.save(&value));
|
||||
EXPECT_FALSE(without_backend.load(&value));
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,62 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
TEST(StringRefStartsWith, ProperPrefixMatches) {
|
||||
StringRef ref("FR:R20:12345", 12);
|
||||
EXPECT_TRUE(ref.starts_with("FR:"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, WholeStringIsAPrefixOfItself) {
|
||||
StringRef ref("TP96", 4);
|
||||
EXPECT_TRUE(ref.starts_with("TP96"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, PrefixLongerThanViewFails) {
|
||||
StringRef ref("TP", 2);
|
||||
EXPECT_FALSE(ref.starts_with("TP96"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, DifferentContentFails) {
|
||||
StringRef ref("TP96", 4);
|
||||
EXPECT_FALSE(ref.starts_with("FR:"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, EmptyPrefixAlwaysMatches) {
|
||||
StringRef ref("abc", 3);
|
||||
EXPECT_TRUE(ref.starts_with(""));
|
||||
StringRef empty;
|
||||
EXPECT_TRUE(empty.starts_with(""));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, EmptyViewOnlyMatchesEmptyPrefix) {
|
||||
StringRef empty;
|
||||
EXPECT_FALSE(empty.starts_with("a"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, WorksOnANonTerminatedBuffer) {
|
||||
// The reason the helper exists: a bounded view over a buffer with no
|
||||
// terminator anywhere near the viewed bytes.
|
||||
const char raw[] = {'R', 'a', 'd', 'o', 'n', 'X'};
|
||||
StringRef ref(raw, 5);
|
||||
EXPECT_TRUE(ref.starts_with("Radon"));
|
||||
EXPECT_FALSE(ref.starts_with("RadonEye"));
|
||||
EXPECT_FALSE(ref.starts_with("adon"));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, StdStringOverload) {
|
||||
StringRef ref("TP96", 4);
|
||||
EXPECT_TRUE(ref.starts_with(std::string("TP")));
|
||||
EXPECT_FALSE(ref.starts_with(std::string("96")));
|
||||
}
|
||||
|
||||
TEST(StringRefStartsWith, RefOverloadComparesOnlyTheViewedLength) {
|
||||
// The prefix is a bounded view: bytes past its length must not be compared.
|
||||
StringRef ref("FR:123", 6);
|
||||
StringRef prefix("FR:xyz", 3);
|
||||
EXPECT_TRUE(ref.starts_with(prefix));
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::epaper_spi::testing {
|
||||
|
||||
/// SPI delegate that records transaction boundaries and burns wall-clock time on each
|
||||
/// row write, so a transfer can be driven past its MAX_TRANSFER_TIME yield deadline.
|
||||
class TimedSPIDelegate : public spi::SPIDelegate {
|
||||
public:
|
||||
explicit TimedSPIDelegate(uint32_t row_transfer_ms) : row_transfer_ms_(row_transfer_ms) {}
|
||||
|
||||
uint8_t transfer(uint8_t data) override { return 0; }
|
||||
|
||||
void write_array(const uint8_t *ptr, size_t length) override {
|
||||
// A row of pixel data is one "slow" write; single-byte writes are commands.
|
||||
if (length > 1) {
|
||||
const uint32_t until = millis() + this->row_transfer_ms_;
|
||||
while (millis() < until) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void begin_transaction() override { this->begin_count++; }
|
||||
void end_transaction() override { this->end_count++; }
|
||||
|
||||
int begin_count{0};
|
||||
int end_count{0};
|
||||
|
||||
protected:
|
||||
uint32_t row_transfer_ms_;
|
||||
};
|
||||
|
||||
/// GPIO pin that just remembers the last level written to it.
|
||||
class RecordingPin : public GPIOPin {
|
||||
public:
|
||||
void setup() override {}
|
||||
void pin_mode(gpio::Flags flags) override {}
|
||||
gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; }
|
||||
bool digital_read() override { return false; }
|
||||
void digital_write(bool value) override { this->level = value; }
|
||||
size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); }
|
||||
|
||||
bool level{true};
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi::testing
|
||||
@@ -0,0 +1,77 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "../common.h"
|
||||
#include "esphome/components/epaper_spi/epaper_spi_t133a01.h"
|
||||
|
||||
namespace esphome::epaper_spi::testing {
|
||||
|
||||
/// Exposes the protected transfer machinery so the yield behaviour can be driven directly.
|
||||
class TestableT133A01 : public EPaperT133A01 {
|
||||
public:
|
||||
TestableT133A01(uint16_t width, uint16_t height) : EPaperT133A01("test", width, height, nullptr, 0) {}
|
||||
|
||||
void install(spi::SPIDelegate *delegate) {
|
||||
this->delegate_ = delegate;
|
||||
this->set_dc_pin(&this->dc);
|
||||
this->set_cs_pins(&this->cs, &this->cs1);
|
||||
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
|
||||
}
|
||||
|
||||
using EPaperT133A01::transfer_data;
|
||||
|
||||
RecordingPin dc, cs, cs1;
|
||||
};
|
||||
|
||||
/// Regression test for the T133A01 transfer deadlock (issue #17668).
|
||||
///
|
||||
/// `transfer_data()` evaluates its yield deadline *after* incrementing the row counter, so the
|
||||
/// deadline can expire on a phase's final row. The phase is then complete but the function
|
||||
/// reports "not done"; on the next call the phase guard is false, so the `disable()` /
|
||||
/// CS-deassert epilogue is skipped permanently. The SPI transaction is never closed and the
|
||||
/// next `enable()` blocks forever, tripping the task watchdog.
|
||||
///
|
||||
/// Here the CS phase is two rows and every row write overruns the deadline, so the second call
|
||||
/// completes the phase exactly as the deadline expires, which is the failing alignment. The completed
|
||||
/// phase must still run its epilogue: end the transaction and deassert CS.
|
||||
TEST(EPaperT133A01, CompletedPhaseRunsEpilogueWhenDeadlineExpiresOnFinalRow) {
|
||||
// width 8 -> 4 bytes per row, 2 per half-row; height 2 -> a two-row CS phase
|
||||
TestableT133A01 display(8, 2);
|
||||
TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5);
|
||||
display.install(&delegate);
|
||||
|
||||
// First call performs the one-off CCSET setup (which opens and closes a transaction of its
|
||||
// own) and then writes row 0 of the CS phase before yielding on the deadline.
|
||||
ASSERT_FALSE(display.transfer_data()) << "transfer should have yielded after the first row";
|
||||
ASSERT_FALSE(display.cs.level) << "CS must stay asserted across a yield mid-phase";
|
||||
const int closed_after_setup = delegate.end_count;
|
||||
|
||||
// Second call writes the final row of the CS phase; the deadline expires as it lands.
|
||||
display.transfer_data();
|
||||
|
||||
EXPECT_EQ(delegate.end_count, closed_after_setup + 1)
|
||||
<< "completed CS phase skipped disable() -- SPI transaction left open";
|
||||
EXPECT_TRUE(display.cs.level) << "completed CS phase left CS asserted";
|
||||
}
|
||||
|
||||
/// The CS1 phase has the same off-by-one, but fails worse: after the skipped epilogue the
|
||||
/// function falls through to `return true`, reporting the transfer complete while the SPI
|
||||
/// transaction is still open and CS1 is still asserted. The next command's `enable()` then
|
||||
/// blocks forever. A transfer that reports done must have released the bus.
|
||||
TEST(EPaperT133A01, TransferReportsDoneOnlyAfterReleasingTheBus) {
|
||||
TestableT133A01 display(8, 2);
|
||||
TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5);
|
||||
display.install(&delegate);
|
||||
|
||||
// Both phases are two rows each and every row overruns the deadline, so the transfer needs
|
||||
// one call per row plus the setup call. Bound the loop so a regression fails rather than hangs.
|
||||
int calls = 0;
|
||||
while (!display.transfer_data()) {
|
||||
ASSERT_LT(++calls, 10) << "transfer never reported completion";
|
||||
}
|
||||
|
||||
EXPECT_TRUE(display.cs1.level) << "transfer reported done with CS1 still asserted";
|
||||
EXPECT_EQ(delegate.begin_count, delegate.end_count)
|
||||
<< "transfer reported done with an SPI transaction still open -- the next enable() would deadlock";
|
||||
}
|
||||
|
||||
} // namespace esphome::epaper_spi::testing
|
||||
@@ -0,0 +1,23 @@
|
||||
# External RSA signing mode with a declared trusted-key list enables ESPHome's
|
||||
# own multi-key OTA signature verifier (USE_OTA_SIGNED_VERIFICATION_MULTI_KEY),
|
||||
# which accepts an image whose signature block matches one of the compiled-in
|
||||
# trusted keys. wifi + ota pull in the ota component so CI actually compiles that
|
||||
# verifier; allow_partition_access exercises the bootloader-update path too.
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
verification_keys:
|
||||
- ../../components/esp32/dummy_signing_key.pem
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
allow_partition_access: true
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -1,11 +0,0 @@
|
||||
# Secure Boot V2 schemes carry the public key inside each image's signature
|
||||
# block, so verifying externally-signed binaries needs no key in the config:
|
||||
# a bare block enables verification with the default rsa3072 scheme.
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -12,18 +12,21 @@ esp32_ble_tracker:
|
||||
then:
|
||||
# yamllint disable rule:line-length
|
||||
- lambda: !lambda |-
|
||||
ESP_LOGD("main", "The device address (%s) exists in list", x.address_str().c_str());
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address (%s) exists in list", x.address_str_to(addr));
|
||||
# yamllint enable rule:line-length
|
||||
- mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
# yamllint disable rule:line-length
|
||||
- lambda: !lambda |-
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str().c_str());
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
# yamllint enable rule:line-length
|
||||
- then:
|
||||
# yamllint disable rule:line-length
|
||||
- lambda: !lambda |-
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str().c_str());
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
# yamllint enable rule:line-length
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: ABCD
|
||||
|
||||
@@ -9,7 +9,7 @@ light:
|
||||
id: led_strip2
|
||||
pin: ${pin2}
|
||||
num_leds: 60
|
||||
rgb_order: RGB
|
||||
rgbw_order: RWGB
|
||||
bit0_high: 100us
|
||||
bit0_low: 100us
|
||||
bit1_high: 100us
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
espnow:
|
||||
id: espnow_component
|
||||
auto_add_peer: true
|
||||
peers:
|
||||
- 11:22:33:44:55:66
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
espnow: !include common-wifi.yaml
|
||||
@@ -0,0 +1,19 @@
|
||||
ethernet:
|
||||
type: CH390
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
manual_ip:
|
||||
static_ip: 192.168.178.56
|
||||
gateway: 192.168.178.1
|
||||
subnet: 255.255.255.0
|
||||
domain: .local
|
||||
mac_address: "02:AA:BB:CC:DD:01"
|
||||
on_connect:
|
||||
- logger.log: "Ethernet connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "Ethernet disconnected!"
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-ch390.yaml
|
||||
@@ -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,72 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// Exposes the connection bookkeeping so a drop can be driven without waiting one out.
|
||||
class TestableHoermannHcp : public HoermannHcp {
|
||||
public:
|
||||
using HoermannHcp::set_valid_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected.
|
||||
TEST(HoermannHcpBinarySensorTest, StartsDisconnected) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpConnectedBinarySensor sensor(&door);
|
||||
sensor.setup();
|
||||
EXPECT_TRUE(sensor.has_state());
|
||||
EXPECT_FALSE(sensor.state);
|
||||
}
|
||||
|
||||
// The connection flag follows the bus controller in both directions.
|
||||
TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) {
|
||||
TestableHoermannHcp door;
|
||||
HoermannHcpConnectedBinarySensor sensor(&door);
|
||||
sensor.setup();
|
||||
ASSERT_FALSE(sensor.state);
|
||||
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
door.update();
|
||||
EXPECT_TRUE(sensor.state);
|
||||
|
||||
door.set_valid_(false);
|
||||
door.update();
|
||||
EXPECT_FALSE(sensor.state);
|
||||
}
|
||||
|
||||
// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice.
|
||||
TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpConnectedBinarySensor sensor(&door);
|
||||
sensor.setup();
|
||||
int publishes = 0;
|
||||
sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; });
|
||||
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
door.update();
|
||||
ASSERT_EQ(publishes, 1);
|
||||
|
||||
// A status broadcast changes the door state without touching the connection.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
door.update();
|
||||
EXPECT_EQ(publishes, 1);
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,13 @@
|
||||
hoermann_hcp:
|
||||
id: hoermann_hcp_hub
|
||||
modbus_id: modbus_server_bus
|
||||
|
||||
cover:
|
||||
- platform: hoermann_hcp
|
||||
name: Garage Door
|
||||
device_class: garage
|
||||
|
||||
binary_sensor:
|
||||
- platform: hoermann_hcp
|
||||
is_connected:
|
||||
name: Garage Connected
|
||||
@@ -0,0 +1,174 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t STATE_REG = 0x9CB9;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// The door only accepts commands once the bus controller has actually talked to it.
|
||||
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
|
||||
|
||||
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
|
||||
uint16_t poll_command(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_EQ(response.size(), 8u);
|
||||
return response.size() == 8u ? response[2] : 0xFFFF;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish.
|
||||
TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
int publishes = 0;
|
||||
cover.add_on_state_callback([&publishes]() { publishes++; });
|
||||
ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
|
||||
|
||||
// Any request marks the device connected, which is itself a state change.
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
door.update();
|
||||
|
||||
EXPECT_EQ(publishes, 1);
|
||||
EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED);
|
||||
}
|
||||
|
||||
// Venting and half-open moves report no direction, so one is only derived once the position has moved.
|
||||
TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
|
||||
// Position 100/200 = 0.5, state 0x80 -> resting half open.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000}));
|
||||
door.update();
|
||||
ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
|
||||
|
||||
// State 0x05 -> moving to half-open, but the position has not moved yet.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
|
||||
door.update();
|
||||
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
|
||||
|
||||
// Position 120/200 = 0.6 is higher than before, so the door is opening.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500}));
|
||||
door.update();
|
||||
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING);
|
||||
EXPECT_FLOAT_EQ(cover.position, 0.6f);
|
||||
}
|
||||
|
||||
// Booting while the door is already mid-move gives no baseline to compare against, so no direction
|
||||
// may be inferred from the first update.
|
||||
TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
|
||||
// The very first thing seen is a half-open move already at 100/200 = 0.5.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500}));
|
||||
door.update();
|
||||
EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE);
|
||||
}
|
||||
|
||||
// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather
|
||||
// than as a target the door would be stopped at.
|
||||
TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
|
||||
cover.make_call().set_command_open().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
}
|
||||
|
||||
// The same for cover.close, which arrives as a position of 0.0.
|
||||
TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
|
||||
cover.make_call().set_command_close().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
|
||||
}
|
||||
|
||||
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
|
||||
cover.make_call().set_command_toggle().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
// The door is opening, so it takes an impulse to stop it.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
|
||||
cover.make_call().set_command_stop().perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// A position between the end stops starts the door in the right direction; it is stopped there later.
|
||||
TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
|
||||
HoermannHcp door; // starts out fully closed
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
connect(door);
|
||||
|
||||
cover.make_call().set_position(0.5f).perform();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
}
|
||||
|
||||
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
|
||||
// to be published back over that assumption.
|
||||
TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) {
|
||||
HoermannHcp door; // never contacted by a bus controller
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
int publishes = 0;
|
||||
cover.add_on_state_callback([&publishes]() { publishes++; });
|
||||
|
||||
cover.make_call().set_command_close().perform();
|
||||
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
EXPECT_EQ(publishes, 1);
|
||||
EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
|
||||
}
|
||||
|
||||
// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would
|
||||
// otherwise sit at its fully open default and look healthy.
|
||||
TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) {
|
||||
HoermannHcp door;
|
||||
HoermannHcpCover cover(&door);
|
||||
cover.setup();
|
||||
EXPECT_TRUE(cover.status_has_warning());
|
||||
|
||||
connect(door);
|
||||
door.update();
|
||||
EXPECT_FALSE(cover.status_has_warning());
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,430 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
using modbus::RegisterValues;
|
||||
|
||||
namespace {
|
||||
|
||||
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
|
||||
constexpr uint16_t COMMAND_REG = 0x9C41;
|
||||
constexpr uint16_t STATE_REG = 0x9CB9;
|
||||
constexpr uint16_t BROADCAST_REG = 0x9D31;
|
||||
|
||||
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
|
||||
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
|
||||
|
||||
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
||||
RegisterValues registers;
|
||||
for (uint16_t value : values)
|
||||
registers.push_back(value);
|
||||
return registers;
|
||||
}
|
||||
|
||||
// The device only accepts commands once the bus controller has actually talked to it.
|
||||
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
|
||||
|
||||
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
|
||||
uint16_t poll_command(HoermannHcp &door) {
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_EQ(response.size(), 8u);
|
||||
return response.size() == 8u ? response[2] : 0xFFFF;
|
||||
}
|
||||
|
||||
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
|
||||
class TestableHoermannHcp : public HoermannHcp {
|
||||
public:
|
||||
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
|
||||
|
||||
using HoermannHcp::connection_timeout_ms_;
|
||||
using HoermannHcp::set_valid_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004.
|
||||
TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
|
||||
RegisterValues response;
|
||||
auto status = door.on_read_holding_registers(STATE_REG, 2, response);
|
||||
EXPECT_FALSE(status.has_value());
|
||||
ASSERT_EQ(response.size(), 2u);
|
||||
EXPECT_EQ(response[0], 0x0004);
|
||||
EXPECT_EQ(response[1], 0x0000);
|
||||
}
|
||||
|
||||
// A bus scan (write 3 / read 5) answers with the fixed device identification block.
|
||||
TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value());
|
||||
RegisterValues response;
|
||||
auto status = door.on_read_holding_registers(STATE_REG, 5, response);
|
||||
EXPECT_FALSE(status.has_value());
|
||||
ASSERT_EQ(response.size(), 5u);
|
||||
EXPECT_EQ(response[1], 0x0005);
|
||||
EXPECT_EQ(response[2], 0x0430);
|
||||
EXPECT_EQ(response[3], 0x10ff);
|
||||
EXPECT_EQ(response[4], 0xa845);
|
||||
}
|
||||
|
||||
// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press.
|
||||
TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
|
||||
HoermannHcp door;
|
||||
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
|
||||
RegisterValues response;
|
||||
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_FALSE(status.has_value());
|
||||
ASSERT_EQ(response.size(), 8u);
|
||||
EXPECT_EQ(response[1], 0x0001);
|
||||
EXPECT_EQ(response[2], 0x0000);
|
||||
EXPECT_EQ(response[3], 0x0000);
|
||||
}
|
||||
|
||||
// A queued control command is injected into the next command poll as a simulated key press.
|
||||
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
|
||||
HoermannHcp door;
|
||||
connect(door);
|
||||
door.open_door();
|
||||
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
|
||||
RegisterValues response;
|
||||
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
EXPECT_FALSE(status.has_value());
|
||||
ASSERT_EQ(response.size(), 8u);
|
||||
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
|
||||
EXPECT_EQ(response[3], 0x0000);
|
||||
}
|
||||
|
||||
// A read of any other block is an addressing error rather than a successful all-zero reply.
|
||||
TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
|
||||
HoermannHcp door;
|
||||
RegisterValues response;
|
||||
EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A command is held for the key-press duration, then released, and only then can the next one be queued.
|
||||
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.open_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
// Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120.
|
||||
door.close_door();
|
||||
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
// With the command gone, the next one is accepted again.
|
||||
door.close_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
|
||||
}
|
||||
|
||||
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
|
||||
TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) {
|
||||
HoermannHcp door;
|
||||
door.open_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
}
|
||||
|
||||
// Losing the controller must drop a command it never fetched, otherwise it blocks every later command
|
||||
// and fires unasked once the bus comes back.
|
||||
TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.open_door();
|
||||
ASSERT_TRUE(door.is_valid());
|
||||
|
||||
door.set_valid_(false);
|
||||
EXPECT_FALSE(door.is_valid());
|
||||
|
||||
// The reconnecting poll must not replay the dropped command.
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
// And the slot is free, so a new command is accepted.
|
||||
door.close_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0220);
|
||||
}
|
||||
|
||||
// The connection is dropped by update() once the controller stops polling, which is what releases a
|
||||
// command it never fetched in the field.
|
||||
TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
|
||||
TestableHoermannHcp door;
|
||||
// Wide enough that a stall cannot expire the connection before the check below runs.
|
||||
door.connection_timeout_ms_ = 10000;
|
||||
connect(door);
|
||||
door.open_door();
|
||||
|
||||
// Still inside the window: the controller counts as present.
|
||||
door.update();
|
||||
ASSERT_TRUE(door.is_valid());
|
||||
|
||||
// Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer.
|
||||
door.connection_timeout_ms_ = 20;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
||||
door.update();
|
||||
EXPECT_FALSE(door.is_valid());
|
||||
// The pending command went with the connection instead of firing on the reconnecting poll.
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
}
|
||||
|
||||
// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to
|
||||
// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely.
|
||||
TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect(door);
|
||||
door.open_door();
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
// A status broadcast refreshes the connection without ever fetching the command.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
door.update();
|
||||
ASSERT_TRUE(door.is_valid());
|
||||
|
||||
// With the stale command gone, the door accepts commands again.
|
||||
door.close_door();
|
||||
EXPECT_EQ(poll_command(door), 0x0220);
|
||||
}
|
||||
|
||||
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
|
||||
// differently per block length.
|
||||
TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) {
|
||||
HoermannHcp door;
|
||||
// Counter 0x34 in the high byte, command 0x07 in the low byte.
|
||||
door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000}));
|
||||
|
||||
RegisterValues command_poll;
|
||||
door.on_read_holding_registers(STATE_REG, 8, command_poll);
|
||||
ASSERT_EQ(command_poll.size(), 8u);
|
||||
EXPECT_EQ(command_poll[0], 0x3400); // counter alone
|
||||
EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low
|
||||
|
||||
RegisterValues empty_poll;
|
||||
door.on_read_holding_registers(STATE_REG, 2, empty_poll);
|
||||
ASSERT_EQ(empty_poll.size(), 2u);
|
||||
EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here
|
||||
EXPECT_EQ(empty_poll[1], 0x0700); // command alone
|
||||
|
||||
RegisterValues scan;
|
||||
door.on_read_holding_registers(STATE_REG, 5, scan);
|
||||
ASSERT_EQ(scan.size(), 5u);
|
||||
EXPECT_EQ(scan[0], 0x3400);
|
||||
EXPECT_EQ(scan[1], 0x0705);
|
||||
}
|
||||
|
||||
// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position.
|
||||
TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) {
|
||||
HoermannHcp door;
|
||||
// registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening).
|
||||
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_EQ(door.get_door_state(), DoorState::OPENING);
|
||||
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
|
||||
}
|
||||
|
||||
// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a
|
||||
// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed.
|
||||
TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) {
|
||||
HoermannHcp door;
|
||||
auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000}));
|
||||
EXPECT_FALSE(status.has_value());
|
||||
EXPECT_EQ(door.get_door_state(), DoorState::STOPPED);
|
||||
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
|
||||
}
|
||||
|
||||
// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of
|
||||
// the state register still has to be decoded.
|
||||
TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) {
|
||||
HoermannHcp door;
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061}));
|
||||
EXPECT_EQ(door.get_door_state(), DoorState::VENT);
|
||||
}
|
||||
|
||||
// A door parking a count short of its end stop must still report exactly closed or open, because
|
||||
// Cover::is_fully_closed() compares against 0.0 exactly.
|
||||
TEST(HoermannHcpWrite, EndStopsReportExactPositions) {
|
||||
HoermannHcp door;
|
||||
// Position register 1 of 200 while the door reports itself closed.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
|
||||
EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f);
|
||||
|
||||
// Position register 199 of 200 while the door reports itself open.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
|
||||
EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f);
|
||||
|
||||
// Away from the end stops the raw count is reported as-is.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
|
||||
EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f);
|
||||
}
|
||||
|
||||
// A position request below the lower snap threshold becomes a plain close command.
|
||||
TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
|
||||
HoermannHcp door;
|
||||
connect(door);
|
||||
door.set_position(0.02f);
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
ASSERT_EQ(response.size(), 8u);
|
||||
EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value
|
||||
}
|
||||
|
||||
// A half-open target starts the door moving towards the requested position.
|
||||
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
|
||||
HoermannHcp door; // starts out fully closed
|
||||
connect(door);
|
||||
door.set_position(0.5f);
|
||||
RegisterValues response;
|
||||
door.on_read_holding_registers(STATE_REG, 8, response);
|
||||
ASSERT_EQ(response.size(), 8u);
|
||||
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
|
||||
}
|
||||
|
||||
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
|
||||
TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
|
||||
// Position 20/200 = 0.1 while opening: short of the target, so the door keeps going.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
|
||||
// Position 120/200 = 0.6 is past the target, so the door is stopped.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
|
||||
// must be read as "already stopped" rather than "still opening".
|
||||
TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
|
||||
|
||||
// Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
}
|
||||
|
||||
// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short.
|
||||
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
|
||||
// The door is stopped at 0.3 by a wall button, short of the requested 0.5.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
|
||||
|
||||
// A later manual open must run freely instead of being stopped at the abandoned target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
}
|
||||
|
||||
// A target armed while the door is still travelling the other way must not be judged by that old direction,
|
||||
// otherwise the very next position it reports counts as reached and stops the door where it stands.
|
||||
TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
// The door is closing, passing 60/200 = 0.3.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
|
||||
|
||||
// Still closing at 58/200 = 0.29: below the target, but not on the way to it.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
|
||||
// Now opening at 62/200 = 0.31, still short of the target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
|
||||
// Past the target at 110/200 = 0.55, so the door is stopped.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
|
||||
}
|
||||
|
||||
// A motor turning around can report a momentary stop; dropping the target there would let the door run on
|
||||
// to the end stop that the reversing command asked for.
|
||||
TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
|
||||
TestableHoermannHcp door;
|
||||
connect(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
|
||||
// The stop reported on the way from closing to opening.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
|
||||
|
||||
// The door then opens and still has to be stopped at the requested position.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0240);
|
||||
}
|
||||
|
||||
// A door that never turns around has to lose the target as well, otherwise it would cut a later move short.
|
||||
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
|
||||
TestableHoermannHcp door;
|
||||
door.connection_timeout_ms_ = 200;
|
||||
connect(door);
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
|
||||
|
||||
door.set_position(0.5f);
|
||||
EXPECT_EQ(poll_command(door), 0x0210);
|
||||
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
|
||||
EXPECT_EQ(poll_command(door), 0x0110);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(220));
|
||||
// The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the
|
||||
// target is the only thing that may expire here.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
|
||||
door.update();
|
||||
ASSERT_TRUE(door.is_valid());
|
||||
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
|
||||
|
||||
// A later manual open must run freely instead of being stopped at the abandoned target.
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
|
||||
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
|
||||
EXPECT_EQ(poll_command(door), 0x0000);
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
|
||||
hoermann_hcp: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml
|
||||
hoermann_hcp: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
sensor:
|
||||
- platform: inkbird_ibsth1_mini
|
||||
mac_address: 38:81:D7:0A:9C:11
|
||||
temperature:
|
||||
name: Inkbird IBS-TH1 Temperature
|
||||
humidity:
|
||||
name: Inkbird IBS-TH1 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: inkbird_ibsth1_mini
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: 38:81:D7:0A:9C:11
|
||||
temperature:
|
||||
name: Inkbird IBS-TH1 Temperature
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
inkbird_ibsth1_mini: !include common-ln.yaml
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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: inkbird_ibsth1_mini
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: 38:81:D7:0A:9C:11
|
||||
temperature:
|
||||
name: Inkbird IBS-TH1 Temperature
|
||||
humidity:
|
||||
name: Inkbird IBS-TH1 Humidity
|
||||
battery_level:
|
||||
name: Inkbird IBS-TH1 Battery Level
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: inkbird_ibsth1_mini
|
||||
mac_address: 38:81:D7:0A:9C:12
|
||||
temperature:
|
||||
name: BK Inkbird Implicit Temperature
|
||||
@@ -0,0 +1,167 @@
|
||||
ld6002b:
|
||||
id: ld6002b_radar
|
||||
wakeup_pin: GPIO14
|
||||
|
||||
sensor:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
target_count:
|
||||
name: Target Count
|
||||
point_count:
|
||||
name: Point Count
|
||||
target_1:
|
||||
x:
|
||||
name: Target-1 X
|
||||
y:
|
||||
name: Target-1 Y
|
||||
z:
|
||||
name: Target-1 Z
|
||||
doppler_index:
|
||||
name: Target-1 Dop
|
||||
cluster_id:
|
||||
name: Target-1 Cluster
|
||||
target_2:
|
||||
x:
|
||||
name: Target-2 X
|
||||
y:
|
||||
name: Target-2 Y
|
||||
z:
|
||||
name: Target-2 Z
|
||||
doppler_index:
|
||||
name: Target-2 Dop
|
||||
cluster_id:
|
||||
name: Target-2 Cluster
|
||||
target_3:
|
||||
x:
|
||||
name: Target-3 X
|
||||
y:
|
||||
name: Target-3 Y
|
||||
z:
|
||||
name: Target-3 Z
|
||||
doppler_index:
|
||||
name: Target-3 Dop
|
||||
cluster_id:
|
||||
name: Target-3 Cluster
|
||||
interference_area_0:
|
||||
x_min:
|
||||
name: Interference-0 X Min
|
||||
x_max:
|
||||
name: Interference-0 X Max
|
||||
y_min:
|
||||
name: Interference-0 Y Min
|
||||
y_max:
|
||||
name: Interference-0 Y Max
|
||||
z_min:
|
||||
name: Interference-0 Z Min
|
||||
z_max:
|
||||
name: Interference-0 Z Max
|
||||
detection_area_0:
|
||||
x_min:
|
||||
name: Detection-0 X Min
|
||||
x_max:
|
||||
name: Detection-0 X Max
|
||||
y_min:
|
||||
name: Detection-0 Y Min
|
||||
y_max:
|
||||
name: Detection-0 Y Max
|
||||
z_min:
|
||||
name: Detection-0 Z Min
|
||||
z_max:
|
||||
name: Detection-0 Z Max
|
||||
|
||||
binary_sensor:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
target:
|
||||
name: Presence
|
||||
target_1:
|
||||
name: Target-1 Presence
|
||||
detection_area_0:
|
||||
name: Detection Area-0 Presence
|
||||
|
||||
text_sensor:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
work_mode:
|
||||
name: Work Mode
|
||||
ota_version:
|
||||
name: OTA Version
|
||||
|
||||
number:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
hold_delay:
|
||||
name: Hold Delay
|
||||
z_min:
|
||||
name: Z Min
|
||||
z_max:
|
||||
name: Z Max
|
||||
low_power_sleep_time:
|
||||
name: Low Power Sleep
|
||||
area_config:
|
||||
x_min:
|
||||
name: Area X Min
|
||||
x_max:
|
||||
name: Area X Max
|
||||
y_min:
|
||||
name: Area Y Min
|
||||
y_max:
|
||||
name: Area Y Max
|
||||
z_min:
|
||||
name: Area Z Min
|
||||
z_max:
|
||||
name: Area Z Max
|
||||
|
||||
select:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
sensitivity:
|
||||
name: Sensitivity
|
||||
trigger_speed:
|
||||
name: Trigger Speed
|
||||
installation_mode:
|
||||
name: Installation
|
||||
area_id:
|
||||
name: Area ID
|
||||
|
||||
switch:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
low_power:
|
||||
name: Low Power
|
||||
point_cloud:
|
||||
name: Point Cloud
|
||||
target_display:
|
||||
name: Target Display
|
||||
|
||||
button:
|
||||
- platform: ld6002b
|
||||
ld6002b_id: ld6002b_radar
|
||||
apply_area:
|
||||
name: Apply Area
|
||||
auto_interference:
|
||||
name: Auto Interference
|
||||
get_areas:
|
||||
name: Get Areas
|
||||
clear_interference:
|
||||
name: Clear Interference
|
||||
reset_detection_area:
|
||||
name: Reset Detection
|
||||
get_delay:
|
||||
name: Get Delay
|
||||
get_sensitivity:
|
||||
name: Get Sensitivity
|
||||
get_trigger_speed:
|
||||
name: Get Trigger Speed
|
||||
get_z_range:
|
||||
name: Get Z Range
|
||||
get_installation:
|
||||
name: Get Installation
|
||||
get_low_power_mode:
|
||||
name: Get Low Power Mode
|
||||
get_low_power_sleep_time:
|
||||
name: Get Low Power Sleep
|
||||
reset_unattended:
|
||||
name: Reset Unattended
|
||||
wake:
|
||||
name: Wake
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
ld6002b: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
|
||||
ld6002b: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
|
||||
ld6002b: !include common.yaml
|
||||
@@ -1,19 +1,23 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- light.toggle: test_binary_light
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
id: light_test_binary
|
||||
pin: 0
|
||||
pin: 12
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_1
|
||||
pin: 13
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_2
|
||||
pin:
|
||||
number: 14
|
||||
inverted: true
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_3
|
||||
pin: 15
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_4
|
||||
pin: 16
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_5
|
||||
pin: 17
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
id: test_binary_light
|
||||
name: Binary Light
|
||||
output: light_test_binary
|
||||
effects:
|
||||
- strobe:
|
||||
on_state:
|
||||
- logger.log: Binary light state changed
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- light.toggle: test_binary_light
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
id: light_test_binary
|
||||
pin: 0
|
||||
pin: 12
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_1
|
||||
pin: 13
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_2
|
||||
pin:
|
||||
number: 14
|
||||
inverted: true
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_3
|
||||
pin: 15
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_4
|
||||
pin: 16
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_5
|
||||
pin: 17
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
id: test_binary_light
|
||||
name: Binary Light
|
||||
output: light_test_binary
|
||||
effects:
|
||||
- strobe:
|
||||
on_state:
|
||||
- logger.log: Binary light state changed
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ln882h_ble:
|
||||
enable_on_boot: true
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ln882h_ble: !include common.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
ln882h_ble_tracker:
|
||||
id: ble_tracker
|
||||
scan_parameters:
|
||||
# Boundary coverage: the documented 2.5 ms floor on window (expressible only
|
||||
# via the microsecond-accurate validation), a non-round interval exercising the
|
||||
# 0.625 ms unit conversion without collapsing onto the window's unit count,
|
||||
# and the non-continuous config path.
|
||||
interval: 5000us
|
||||
window: 2500us
|
||||
duration: 5min
|
||||
active: false
|
||||
continuous: false
|
||||
@@ -0,0 +1,16 @@
|
||||
ln882h_ble_tracker:
|
||||
id: ble_tracker
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 50ms
|
||||
duration: 5min
|
||||
continuous: true
|
||||
|
||||
# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI
|
||||
# (same coverage arrangement as the rp2_ble_tracker tests).
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,34 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include common.yaml
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- ln882h_ble_tracker.start_scan
|
||||
- ln882h_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
- ln882h_ble_tracker.start_scan:
|
||||
continuous: !lambda return false;
|
||||
- ln882h_ble_tracker.stop_scan
|
||||
|
||||
ln882h_ble_tracker:
|
||||
on_ble_advertise:
|
||||
- mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: |-
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: ABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of service data is %zu", x.size());
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- manufacturer_id: ABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
|
||||
on_scan_end:
|
||||
- then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Scan ended");
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include common-boundary.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-uart0_no_logging.yaml
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user