Compare commits

..
89 changed files with 1068 additions and 2994 deletions
+2 -2
View File
@@ -211,8 +211,8 @@ file does, and it is the authority when they disagree. The most useful starting
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`); pick the helper's
arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
the helper's arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use
`time::decode_bcd_calendar`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
+116 -316
View File
@@ -1,23 +1,11 @@
from collections.abc import Callable, Coroutine
import functools
from collections.abc import Coroutine
from typing import Any
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import ble_device_base, bluetooth_connection
from esphome.components.ble_device_base import (
BT_UUID16_FORMAT as bt_uuid16_format,
BT_UUID32_FORMAT as bt_uuid32_format,
BT_UUID128_FORMAT as bt_uuid128_format,
as_hex,
as_reversed_hex_array,
bt_uuid,
)
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
)
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
import esphome.config_validation as cv
from esphome.const import (
CONF_CHARACTERISTIC_UUID,
@@ -30,55 +18,15 @@ from esphome.const import (
CONF_SERVICE_UUID,
CONF_TRIGGER_ID,
CONF_VALUE,
PLATFORM_ESP32,
PlatformFramework,
)
from esphome.core import CORE, ID
from esphome.enum import StrEnum
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.core import ID
from esphome.types import ConfigType
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
# the esp32 schema/codegen arms: importing those modules registers esp32-only
# automations as a side effect, which must not leak into the neutral
# platforms' registries (the bluetooth_proxy pattern).
def _legacy_engine() -> bool:
"""True when the build uses the legacy raw-gattc engine - one line to
flip when esp32 moves to the neutral engine (with
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
return CORE.is_esp32
def AUTO_LOAD() -> list[str]:
"""The engine's closure per platform: the legacy esp32 engine builds on
esp32_ble_client plus bluetooth_connection (the shared service-table
materializer; its sources compile empty in builds without a neutral
node), the neutral engine on the bluetooth_connection backend. The
platform-less arm is the union for manifest-resolving tooling."""
if _legacy_engine() or CORE.target_platform is None:
return ["bluetooth_connection", "esp32_ble_client"]
return ["bluetooth_connection"]
AUTO_LOAD = ["esp32_ble_client"]
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
DEPENDENCIES = ["esp32_ble_tracker"]
DOMAIN = "ble_client"
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"ble_client.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every framework of every non-esp32 registry platform: a platform
# that validates the neutral arm must also compile the neutral engine.
"ble_client_gatt.cpp": frameworks_for_platforms(
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
),
}
)
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
@@ -114,12 +62,16 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
ble_client_ns = cg.esphome_ns.namespace("ble_client")
# One codegen class for both engines: the exclusively-gated headers resolve
# the name to exactly one C++ definition per build.
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
BLEClientNode = ble_client_ns.class_("BLEClientNode")
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
# Triggers
BLEClientConnectTrigger = ble_client_ns.class_(
"BLEClientConnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
BLEClientDisconnectTrigger = ble_client_ns.class_(
"BLEClientDisconnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
BLEClientPasskeyRequestTrigger = ble_client_ns.class_(
"BLEClientPasskeyRequestTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
@@ -157,175 +109,62 @@ CONF_AUTO_CONNECT = "auto_connect"
MULTI_CONF = True
# Keys shared by both engines' schemas.
_COMMON_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
# Both engines register these on the client; the Bluedroid-security triggers below stay trigger classes
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientConnectTrigger
),
}
),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientDisconnectTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
esp32_ble.consume_connection_slots(1, "ble_client"),
)
@functools.cache
def _esp32_config_schema() -> cv.All:
"""The legacy engine's schema, byte-compatible with what esp32 always had
(including the Bluedroid security triggers)."""
from esphome.components import esp32_ble_tracker
return cv.All(
_COMMON_SCHEMA.extend(
{
# Accepted-but-unused legacy key; not propagated to the
# neutral schema.
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(
CONF_ON_PASSKEY_NOTIFICATION
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
@functools.cache
def _gatt_config_schema(platform: str) -> cv.All:
"""The neutral engine's schema: the shared keys plus the hub reference
(parsed-advertisement sightings) and the GATT backend declaration.
Keyed by platform - the backend fragment differs per platform."""
return cv.All(
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
bluetooth_connection.gatt_client_schema(platform)
),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
@schema_extractor("schema")
def _validate_platform(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
# this shape, so the neutral arm's ble_hub_id is absent from editor
# schemas and the esp32-only keys are advertised on every platform.
# The language-schema dumper runs without a platform; expose the
# esp32 (legacy-engine) shape.
return _esp32_config_schema()
if _legacy_engine():
return _esp32_config_schema()(config)
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return _gatt_config_schema(CORE.target_platform)(config)
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
CONFIG_SCHEMA = _validate_platform
CONF_BLE_CLIENT_ID = "ble_client_id"
class BLEClientFeatures(StrEnum):
"""Per-platform engine capabilities consumers declare against."""
# The platform-neutral node interface (on_connected/table + completion
# callbacks) - every platform with a ble_client engine.
GATT_NODE = "gatt_node"
# The raw esp32 GATT client event stream (gattc/gap handlers,
# node_state) - the legacy engine only.
RAW_GATTC = "raw_gattc"
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
SECURITY = "security"
def _engine_features() -> set[BLEClientFeatures]:
"""Features the validated platform's engine provides."""
if _legacy_engine():
return {
BLEClientFeatures.GATT_NODE,
BLEClientFeatures.RAW_GATTC,
BLEClientFeatures.SECURITY,
}
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return {BLEClientFeatures.GATT_NODE}
return set()
def requires_feature(
feature: BLEClientFeatures, description: str
) -> Callable[[Any], Any]:
"""Validator gating a consumer to platforms whose engine provides
`feature`, naming the missing capability in the error."""
def validator(value: Any) -> Any:
features = _engine_features()
if feature not in features:
available = (
f"; this platform's engine provides: {', '.join(sorted(features))}"
if features
else ""
)
raise cv.Invalid(
f"{description} requires the ble_client '{feature}' feature, "
f"which {CORE.target_platform} does not provide{available}"
)
return value
return validator
# The one choke point for every node component still on the raw esp32 event
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
# register_gatt_node) lifts it.
_legacy_engine_only = requires_feature(
BLEClientFeatures.RAW_GATTC,
"This component drives the raw ESP32 GATT client events and has not "
"been migrated to the platform-neutral node interface yet; it",
)
BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient), _legacy_engine_only
),
}
)
# For node components on the neutral interface: valid wherever ble_client
# itself is.
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient),
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
),
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
}
)
@@ -335,26 +174,6 @@ async def register_ble_node(var, config):
cg.add(parent.register_ble_node(var))
def _request_gatt_node_build() -> None:
"""Node storage and the one define meaning "the neutral node surface is
compiled in", plus the esp32 bridge/materializer defines."""
_request_node_slot()
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
if _legacy_engine():
# Deliberately not ble_device_base.request_gatt_client(): that would
# claim a phantom backend slot on combined proxy builds.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
async def register_gatt_node(var, config):
"""Register a node on the platform-neutral interface (both engines)."""
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
_request_gatt_node_build()
cg.add(parent.register_gatt_node(var))
def new_ble_node_component(
config: ConfigType, *args: Any
) -> Coroutine[Any, Any, cg.MockObj]:
@@ -365,8 +184,8 @@ def new_ble_node_component(
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_SERVICE_UUID): bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
}
)
@@ -377,34 +196,25 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
}
)
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
),
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
)
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
),
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
)
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
),
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
)
@@ -432,8 +242,6 @@ automation.register_simple_action(
)
async def ble_write_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
# The action registers itself as a neutral node in its constructor.
_request_gatt_node_build()
var = cg.new_Pvariable(action_id, template_arg, parent)
value = config[CONF_VALUE]
@@ -448,20 +256,38 @@ async def ble_write_to_code(config, action_id, template_arg, args):
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
cg.add(var.set_value_simple(arr, len(value)))
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
cg.add(var.set_service_uuid128(uuid128))
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_char_uuid16(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid32_format
):
cg.add(
var.set_char_uuid32(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid128_format
):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
config[CONF_CHARACTERISTIC_UUID]
)
cg.add(var.set_char_uuid128(uuid128))
return var
@@ -515,48 +341,22 @@ automation.register_simple_action(
)
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
from esphome.components import esp32_ble, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
async def to_code(config):
# Register the loggers this component needs
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
cg.add_define("USE_ESP32_BLE_UUID")
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_client(var, config)
return var
# Sizes the neutral client's node storage; the client itself requests a
# baseline slot so the define exists on every build that compiles the engine.
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
# The engine always carries the node surface (the client itself owns the
# baseline slot).
_request_gatt_node_build()
backend = await bluetooth_connection.new_gatt_backend(config)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_backend(backend))
# Sighting-gated connects: the client listens for the peer's parsed
# advertisements through the hub.
await ble_device_base.register_ble_device(var, config)
return var
async def to_code(config: ConfigType) -> None:
if _legacy_engine():
var = await _to_code_esp32(config)
else:
var = await _to_code_gatt(config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
for conf in config.get(CONF_ON_CONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DISCONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_PASSKEY_REQUEST, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
@@ -0,0 +1,11 @@
#ifdef USE_ESP32
#include "automation.h"
namespace esphome::ble_client {
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
} // namespace esphome::ble_client
#endif
+192 -2
View File
@@ -1,14 +1,27 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include <utility>
#include <vector>
#include "esphome/core/automation.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
namespace esphome::ble_client {
// placeholder class for static TAG .
class Automation {
public:
// could be made inline with C++17
static const char *const TAG;
};
// Base for nodes that never read the parent's services.
// The parent releases its services only once every node reports Established, so a node that never
// reports it keeps that memory allocated for the life of the connection.
@@ -26,6 +39,45 @@ class BLEClientServicelessNode : public BLEClientNode {
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
};
// implement on_connect automation.
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
public:
explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
this->node_state = espbt::ClientState::ESTABLISHED;
this->trigger();
}
}
};
// on_disconnect automation
class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode {
public:
explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
// test for CLOSE and not DISCONNECT - DISCONNECT can occur even if no virtual connection (OPEN event) occurred.
// So this will not trigger unless a complete open has previously succeeded.
switch (event) {
case ESP_GATTC_SEARCH_CMPL_EVT: {
this->node_state = espbt::ClientState::ESTABLISHED;
break;
}
case ESP_GATTC_CLOSE_EVT: {
this->trigger();
break;
}
default: {
break;
}
}
}
};
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
public:
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
@@ -58,6 +110,144 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
}
};
// implement the ble_client.ble_write action.
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
// Static mode: write directly from flash pointer
result = this->write(this->value_.data, this->len_);
} else {
// Template mode: call function and write the vector
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value);
}
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
/**
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
* macros in header files (Can't even write it in a comment!)
* Not sure why, because they seem to work just fine.
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
* errors.
*/
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
bool write(const uint8_t *data, size_t len) {
if (this->node_state != espbt::ClientState::ESTABLISHED) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
esp_err_t err =
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
if (err != ESP_OK) {
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
return false;
}
return true;
}
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
switch (event) {
case ESP_GATTC_WRITE_CHAR_EVT:
// upstream code checked the MAC address, verify the characteristic.
if (param->write.handle == this->char_handle_)
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
break;
case ESP_GATTC_DISCONNECT_EVT:
if (this->num_running_ != 0)
this->stop_complex();
break;
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
if (chr == nullptr) {
char char_buf[esp32_ble::UUID_STR_LEN];
char service_buf[esp32_ble::UUID_STR_LEN];
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
break;
}
this->char_handle_ = chr->handle;
this->char_props_ = chr->properties;
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
} else {
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
break;
}
this->node_state = espbt::ClientState::ESTABLISHED;
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
ble_client_->address_str());
break;
}
default:
break;
}
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
espbt::ESPBTUUID service_uuid_;
espbt::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
esp_gatt_char_prop_t char_props_{};
esp_gatt_write_type_t write_type_{};
};
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
public:
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
@@ -1,103 +0,0 @@
// Neutral twins of the shared ble_client automations. Class names, namespace,
// and codegen-visible signatures are IDENTICAL to automation.h so generated
// main.cpp compiles against whichever engine the build gates in; only the
// internals differ (client callbacks and the neutral node interface instead
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include <tuple>
#include "ble_client_gatt.h"
#include "esphome/core/automation.h"
namespace esphome::ble_client {
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
public:
BLEClientConnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
ble_client->add_on_connect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
// A connect attempt that dies (or a later disconnect) terminates the
// chain - unless a fresh attempt is already in flight, meaning a user
// trigger on this same list started this action (an on_disconnect that
// reconnects) and the wait belongs to that attempt. Legacy split these:
// it aborted on DISCONNECT_EVT, the trigger fired on the later CLOSE_EVT.
auto terminate = [this]() {
if (this->num_running_ != 0 && this->ble_client_->idle())
this->stop_complex();
};
ble_client->add_on_connect_failed_callback(terminate);
ble_client->add_on_disconnect_callback(terminate);
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
// it makes no sense to have multiple instances of this running at the
// same time; cancel a re-trigger while still running.
if (this->num_running_ != 0) {
this->stop_complex();
return;
}
this->num_running_++;
if (this->ble_client_->connected()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
// No-op while already connecting; the callback resolves the wait.
this->ble_client_->connect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
public:
BLEClientDisconnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
// Both terminal outcomes resolve the wait: a completed teardown and a
// connect attempt that died on the way down.
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
if (this->ble_client_->idle()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
this->ble_client_->disconnect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
+3 -278
View File
@@ -2,16 +2,10 @@
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
#endif
#ifdef USE_ESP32
namespace esphome::ble_client {
@@ -36,10 +30,6 @@ void BLEClient::dump_config() {
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
if (!this->enabled)
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
return false;
#endif
return BLEClientBase::parse_device(device);
}
@@ -50,65 +40,24 @@ void BLEClient::set_enabled(bool enabled) {
if (!enabled) {
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
this->disconnect();
return;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
// A re-enable clears the backoff (neutral-engine parity).
this->gatt_backoff_.reset();
#endif
}
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
#ifdef USE_BLE_CLIENT_GATT_NODES
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
// its automatic CCCD write would double the node's own.
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
// are unsupported during the migration window.
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
if (this->pending_notify_regs_ > 0)
this->pending_notify_regs_--;
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
// A retiring last registration must still release the cache.
this->maybe_release_services_();
return true;
}
#endif
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
if (!this->gatt_nodes_.empty()) {
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
// A failed discovery tears the link down; the on_connect trigger must
// not fire into the teardown.
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
return true;
} else {
this->dispatch_gatt_event_(event, param);
}
}
#endif
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
// The automations run after the nodes; on_connect runs before the release so it can still use the services
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
this->connect_callbacks_.call();
this->maybe_release_services_();
if (event == ESP_GATTC_CLOSE_EVT)
this->disconnect_callbacks_.call();
return true;
}
void BLEClient::maybe_release_services_() {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
}
return true;
}
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
@@ -116,19 +65,10 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
for (auto *node : this->nodes_)
node->gap_event_handler(event, param);
#ifdef USE_BLE_CLIENT_GATT_NODES
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
for (auto *node : this->gatt_nodes_)
node->on_pairing_result(status);
}
#endif
}
void BLEClient::set_state(espbt::ClientState state) {
BLEClientBase::set_state(state);
// ESTABLISHED never flows through here; gatt nodes are promoted after the
// on_connected fan-out.
for (auto &node : nodes_)
node->node_state = state;
}
@@ -143,221 +83,6 @@ bool BLEClient::all_nodes_established_() {
return true;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
void BLEClient::register_gatt_node(BLEClientNode *node) {
// Parent before the capacity check so a dropped node still has a usable
// parent() (neutral-engine parity).
node->set_ble_client_parent(this);
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->gatt_nodes_.push_back(node);
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
// fan-out subset.
this->register_ble_node(node);
}
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
if (this->pending_gatt_regs_[i] == handle)
return i;
}
return -1;
}
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
int i = this->find_pending_gatt_reg_(handle);
if (i < 0)
return false;
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
return true;
}
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->gatt_nodes_)
node->on_notify_state(handle, enabled, error);
}
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT: {
bool ok = param->read.status == ESP_GATT_OK;
if (!ok) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
param->read.status);
}
for (auto *node : this->gatt_nodes_) {
node->on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
ok ? 0 : param->read.status);
}
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT:
if (param->write.status != ESP_GATT_OK) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
param->write.status);
}
for (auto *node : this->gatt_nodes_) {
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
}
break;
case ESP_GATTC_NOTIFY_EVT:
for (auto *node : this->gatt_nodes_) {
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
}
break;
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
// The base does no CCCD work for unregister; no interception needed.
this->notify_state_to_gatt_nodes_(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
default:
break;
}
}
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
// The base ignores the search status; the neutral contract must not.
uint16_t service_total = 0;
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
this->gattc_if_, this->conn_id_, &service_total);
if (!counted || service_total == 0) {
// A failed search poisons the whole discovery, legacy nodes included.
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Stack-owned; nodes copy their handles during on_connected().
bluetooth_connection::BluedroidServiceTable table;
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
if (!this->has_legacy_nodes_()) {
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Only the table build failed; legacy nodes read the base's services_
// and keep the link. Gatt nodes catch the next connection.
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
// A peer whose database never walks must not retry the build at
// advertisement cadence once the legacy nodes drop the link.
this->gatt_backoff_.register_failure(this->address_str());
} else {
this->gatt_connected_ = true;
auto view = table.view();
for (auto *node : this->gatt_nodes_) {
node->on_connected(view);
if (this->state() != espbt::ClientState::ESTABLISHED) {
// The node tore the link down; remaining nodes get on_disconnected
// with no preceding on_connected, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
return false;
}
}
this->gatt_backoff_.reset();
this->status_clear_warning();
}
// Promote so the legacy release condition can fire.
for (auto *node : this->gatt_nodes_)
node->node_state = espbt::ClientState::ESTABLISHED;
return true;
}
void BLEClient::on_disconnect_complete(esp_err_t reason) {
this->pending_gatt_reg_count_ = 0;
if (!this->gatt_connected_)
return; // Never-established links report nothing (neutral parity).
this->gatt_connected_ = false;
for (auto *node : this->gatt_nodes_)
node->on_disconnected();
}
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK)
this->log_gattc_warning_(operation, err);
return err;
}
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_write_char",
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_characteristic(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_descriptor(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
if (enable) {
if (this->find_pending_gatt_reg_(handle) >= 0) {
// ESP_OK: the in-flight registration's completion fans out to all nodes.
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
return ESP_OK;
}
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
// An untracked registration would let the base's auto-CCCD through.
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
return ble_device_base::GATT_ERR_NO_MEMORY;
}
// The base helper's pending count holds the service-release until the
// (intercepted) completion.
esp_err_t err = this->register_for_notify(handle);
if (err == ESP_OK)
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
}
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
int BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
#endif // USE_BLE_CLIENT_GATT_NODES
} // namespace esphome::ble_client
#endif
+33 -63
View File
@@ -1,20 +1,18 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include <esp_bt_defs.h>
#include <esp_gap_ble_api.h>
#include <esp_gatt_common_api.h>
#include <esp_gattc_api.h>
#include <array>
#include <string>
#include <vector>
namespace esphome::ble_client {
@@ -23,6 +21,34 @@ namespace espbt = esphome::esp32_ble_tracker;
using namespace esp32_ble_client;
class BLEClient;
class BLEClientNode {
public:
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {};
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
void set_address(uint64_t address) { address_ = address; }
espbt::ESPBTClient *client;
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_;
uint64_t address_;
};
class BLEClient final : public BLEClientBase {
public:
void setup() override;
@@ -38,75 +64,19 @@ class BLEClient final : public BLEClientBase {
void set_enabled(bool enabled);
void register_ble_node(BLEClientNode *node) {
node->client = this;
node->set_ble_client_parent(this);
this->nodes_.push_back(node);
}
// on_connect fires once discovery completes; on_disconnect on CLOSE_EVT, so only after a completed open
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
bool enabled;
void set_state(espbt::ClientState state) override;
#ifdef USE_BLE_CLIENT_GATT_NODES
// ---- the neutral node surface (signatures shared with the non-esp32
// engine, so nodes on the neutral interface compile against either) ----
void register_gatt_node(BLEClientNode *node);
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_characteristic(uint16_t handle);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
/// Local registration only; per the neutral contract the CCCD write is the
/// node's job (the legacy auto-CCCD is suppressed for these handles).
int notify_characteristic(uint16_t handle, bool enable);
// pair() comes from BLEClientBase, matching the neutral engine's.
int unpair();
#endif
protected:
bool all_nodes_established_();
void maybe_release_services_();
#ifdef USE_BLE_CLIENT_GATT_NODES
int check_and_log_error_(const char *operation, esp_err_t err);
int find_pending_gatt_reg_(uint16_t handle) const;
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
// False = failed discovery: the link comes down and the caller suppresses
// the legacy fan-out.
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
bool take_pending_gatt_reg_(uint16_t handle);
void on_disconnect_complete(esp_err_t reason) override;
#endif
std::vector<BLEClientNode *> nodes_;
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Raise if a migrated node needs more concurrent registrations.
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
// Nodes on the neutral surface; fed the translated callbacks and
// auto-established after the on_connected fan-out. Every gatt node is
// also in nodes_ (registration pushes into both).
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
// Reconnect backoff after materializer failures.
ConnectBackoff gatt_backoff_;
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
uint8_t pending_gatt_reg_count_{0};
// on_connected fan-out started; on_disconnected is owed at teardown.
bool gatt_connected_{false};
#endif
};
} // namespace esphome::ble_client
@@ -1,261 +0,0 @@
#include "ble_client_gatt.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
ESPHOME_LOG_TAG(TAG, "ble_client");
void BLEClient::register_ble_node(BLEClientNode *node) {
node->set_ble_client_parent(this);
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->nodes_.push_back(node);
}
void BLEClient::set_address(uint64_t address) {
this->address_ = address;
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BLEClient::set_enabled(bool enabled) {
if (enabled == this->enabled)
return;
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
this->enabled = enabled;
if (!enabled) {
this->disconnect();
return;
}
// A re-enable clears the backoff; the next sighting connects (legacy
// parity: enabling does not itself connect).
this->backoff_.reset();
}
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
// The sighting is the source of truth for the address type.
this->address_type_ = device.get_address_type();
this->address_type_known_ = true;
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
return true;
if (this->backoff_.holding_off())
return true;
this->attempt_connect_();
return true;
}
void BLEClient::connect() {
if (this->state_ != State::IDLE) {
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
return;
}
// An absent peer can inhibit scanning for the backend's full connect
// timeout, so this is worth a breadcrumb - but it is a supported action.
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
if (!this->address_type_known_) {
// Legacy parity: without a sighting the address type defaults to
// public, which never matches a random-static peer.
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
}
this->attempt_connect_();
}
void BLEClient::attempt_connect_() {
int err = this->backend_->connect(this->address_, this->address_type_);
if (err != 0) {
// A refused connect never produces a callback: stay idle, charge the
// backoff, and resolve any waiting connect action through the failure
// path so its chain terminates.
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
this->backoff_.register_failure(this->address_str_);
this->defer([this]() { this->connect_failed_callbacks_.call(); });
return;
}
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
this->state_ = State::CONNECTING;
}
void BLEClient::disconnect() {
if (this->state_ == State::IDLE) {
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
return;
}
if (this->cancel_requested_) {
// The accepted teardown still reports; a refused second gatt_disconnect()
// would settle the link twice (phantom connect failure, backoff charge).
ESP_LOGD(TAG, "[%s] Disconnect already in flight, ignoring", this->address_str_);
return;
}
// A deliberate teardown's failure report must not feed the backoff.
this->cancel_requested_ = true;
int err = this->backend_->gatt_disconnect();
if (err != 0) {
// Refused synchronously: backend and client disagree about the link
// state. Warn, then settle through the deliberate-cancel path.
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
this->on_connection_state(false, 0, err);
}
}
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected) {
this->state_ = State::DISCOVERING;
int discover_err = this->backend_->discover_services();
if (discover_err != 0) {
// Synchronous refusal: no discovery completion will follow.
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
this->backoff_.register_failure(this->address_str_);
// Deliberate teardown: its report must not charge the backoff again.
this->disconnect();
}
return;
}
bool was_connected = this->state_ == State::CONNECTED;
bool cancelled = this->cancel_requested_;
this->cancel_requested_ = false;
this->state_ = State::IDLE;
if (was_connected) {
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
for (auto *node : this->nodes_) {
node->on_disconnected();
}
// Continuations leave the backend's event-drain stack first.
this->defer([this]() { this->disconnect_callbacks_.call(); });
} else {
if (cancelled) {
// status carries the refusal code when the teardown settled
// synchronously; 0 on a backend-completed cancel.
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
} else {
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
}
this->defer([this]() { this->connect_failed_callbacks_.call(); });
}
}
void BLEClient::on_service_discovery_done(int error) {
if (this->state_ != State::DISCOVERING || this->cancel_requested_) {
// Raced a teardown; the terminal report settles the link.
ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_);
this->backend_->release_services();
return;
}
if (error != 0) {
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
// The teardown is deliberate: do not charge the backoff again for its
// connection report.
this->disconnect();
return;
}
ble_device_base::GattServiceTable table{};
if (!this->nodes_.empty()) {
// Materialize only when a node will read it: a client with no nodes
// would pay the build/free cycle on every (re)connect for nothing.
table = this->backend_->get_service_table();
if (table.service_count == 0) {
// A failed materialization is indistinguishable from a service-less
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
// the discovery before CONNECTED so the teardown resolves through
// connect_failed, never a spurious on_disconnect.
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
this->backend_->release_services();
this->backoff_.register_failure(this->address_str_);
this->disconnect();
return;
}
}
// CONNECTED before the fan-out so nodes may consult connected() from
// their own on_connected().
this->state_ = State::CONNECTED;
for (auto *node : this->nodes_) {
node->on_connected(table);
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
// A node tore the link down mid-fan-out: on_disconnect fires with no
// preceding on_connect, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
this->backend_->release_services();
return;
}
}
this->backend_->release_services();
this->backoff_.reset();
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
this->defer([this]() { this->connect_callbacks_.call(); });
}
void BLEClient::on_write_result(uint16_t handle, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_write_result(handle, error);
}
}
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_read_result(handle, data, len, error);
}
}
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
// Every node sees every notification and filters by handle (legacy parity).
for (auto *node : this->nodes_) {
node->on_notify(handle, data, len);
}
}
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->nodes_) {
node->on_notify_state(handle, enabled, error);
}
}
void BLEClient::on_pairing_result(int status) {
if (status != 0) {
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
} else {
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
}
for (auto *node : this->nodes_) {
node->on_pairing_result(status);
}
}
void BLEClient::dump_config() {
ESP_LOGCONFIG(TAG,
"BLE Client:\n"
" Address: %s\n"
" Auto connect: %s",
this->address_str_, YESNO(this->auto_connect_));
if (this->enabled && this->state_ == State::IDLE) {
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
}
}
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -1,151 +0,0 @@
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
//
// Compiled on every platform with a GATT backend except esp32, which keeps
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
// family migrates - the exclusive gates make the same class names resolve to
// exactly one definition per build, so codegen is shared.
//
// Connects are sighting-gated like the legacy engine: the client is a parsed
// advertisement listener, captures the peer's address type from the sighting,
// and asks the backend to connect only when enabled and idle.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <functional>
namespace esphome::ble_client {
class BLEClient : public Component,
public ble_device_base::ESPBTDeviceListener,
public ble_device_base::GattClientListener {
public:
void dump_config() override;
// Public field for legacy parity (the switch platform republishes it).
bool enabled{true};
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
void set_address(uint64_t address);
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
void set_enabled(bool enabled);
const char *address_str() const { return this->address_str_; }
void register_ble_node(BLEClientNode *node);
// One registration spelling shared with the esp32 engine's bridge.
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
// An accepted teardown already owns the link: the pending report cannot be
// a connect. state_ stays put so that report still fires on_disconnect.
bool connected() const { return this->state_ == State::CONNECTED && !this->cancel_requested_; }
bool idle() const { return this->state_ == State::IDLE; }
/// Action-initiated connect (no sighting needed; uses the last captured
/// address type, public until a sighting arrives). No-op unless idle.
void connect();
void disconnect();
/// Legacy-named deferral used by the automation twins: neutral listener
/// callbacks run inside the backend's event drain, so automation chain
/// continuations must leave that stack first.
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
// Backend ops for nodes and actions - the frozen node-facing surface.
// Only write_characteristic has an in-tree caller; subscribing means
// notify_characteristic plus a CCCD write_descriptor (the caller's job
// per the contract).
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return this->backend_->write_characteristic(handle, data, len, response);
}
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->backend_->write_descriptor(handle, data, len);
}
int notify_characteristic(uint16_t handle, bool enable) {
return this->backend_->notify_characteristic(handle, enable);
}
int pair() { return this->backend_->pair(); }
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
// Automation callback registration.
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
// Fired when a connect attempt dies before being established; the user
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
template<typename F> void add_on_connect_failed_callback(F &&callback) {
this->connect_failed_callbacks_.add(std::forward<F>(callback));
}
// ---- ble_device_base::ESPBTDeviceListener ----
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
// ---- ble_device_base::GattClientListener ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_pairing_result(int status) override;
protected:
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
void attempt_connect_();
// Group 1: pointers / containers
ble_device_base::BLEGattConnection *backend_{nullptr};
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
// Group 2: 8-byte types
uint64_t address_{0};
// Group 3: callback managers (pointer-sized when empty)
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
LazyCallbackManager<void()> connect_failed_callbacks_;
// Group 4: 4-byte types
// Backoff so an undiscoverable database or a dead peer cannot produce a
// battery-draining connect loop.
ConnectBackoff backoff_;
// Group 5: arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 6: 1-byte types
State state_{State::IDLE};
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
// Distinguishes a captured public type from the never-sighted default.
bool address_type_known_{false};
bool auto_connect_{true};
// A user-initiated teardown in flight; its failure report is not a
// connect failure and must not feed the backoff.
bool cancel_requested_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -1,68 +0,0 @@
// The single BLEClientNode both ble_client engines share. The neutral
// callback surface is the one interface node components build on; the raw
// esp32 surface below it remains for components that have not migrated yet.
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#endif
#include <cstdint>
namespace esphome::ble_client {
class BLEClient;
class BLEClientNode {
public:
#ifdef USE_BLE_CLIENT_GATT_NODES
// Neutral surface, delivered by both engines. The table is borrowed: copy
// handles during on_connected(). All nodes see all completions; filter by
// handle.
// A node that disconnects from inside on_connected() aborts the fan-out;
// the user's on_disconnect may then fire without a preceding on_connect.
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
virtual void on_disconnected() {}
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
// One in-flight registration per handle; its completion fans out to every
// node, so a refused duplicate request still sees on_notify_state.
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
virtual void on_pairing_result(int status) {}
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
// Legacy raw surface; components overriding these need the legacy engine
// until migrated to the neutral surface above.
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {}
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
esp32_ble_tracker::ClientState node_state;
#endif
BLEClient *parent() const { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_{nullptr};
};
} // namespace esphome::ble_client
@@ -1,172 +0,0 @@
// The ble_client.ble_write action: a node on the platform-neutral interface,
// so one implementation serves both engines (the esp32 bridge and the
// neutral engine).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include <tuple>
#include <vector>
// One of the two engine headers resolves per build.
#include "ble_client.h"
#include "ble_client_gatt.h"
#include "ble_client_node.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
static const char *const BLE_WRITE_TAG = "ble_client.automation";
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_gatt_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
result = this->write(this->value_.data, this->len_);
} else {
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value.data(), value.size());
}
// on write failure, continue the automation chain rather than stopping so
// that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
// Initiate the write; the completion arrives in on_write_result. The
// response-less path can complete synchronously inside the call, so the
// handle is armed before the backend is touched.
bool write(const uint8_t *data, size_t len) {
if (!this->ble_client_->connected()) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
if (!this->resolved_) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
if (err != 0) {
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
return false;
}
return true;
}
void on_connected(const ble_device_base::GattServiceTable &table) override {
// Resolution comes from this table only; a re-discovery must not keep a
// stale handle.
this->resolved_ = false;
this->char_handle_ = 0;
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
const auto *chr =
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
if (chr == nullptr) {
char char_buf[ble_device_base::UUID_STR_LEN];
char service_buf[ble_device_base::UUID_STR_LEN];
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
this->service_uuid_.to_str(service_buf));
return;
}
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
this->write_response_ = true;
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
this->write_response_ = false;
} else {
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
return;
}
this->char_handle_ = chr->value_handle;
this->resolved_ = true;
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
this->ble_client_->address_str());
}
void on_disconnected() override {
this->resolved_ = false;
this->char_handle_ = 0;
if (this->num_running_ != 0)
this->stop_complex();
}
void on_write_result(uint16_t handle, int error) override {
if (this->num_running_ == 0) {
return;
}
if (!this->resolved_ || handle != this->char_handle_) {
// A parked chain waiting on a completion that never matches would
// otherwise stall silently until disconnect.
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
this->char_handle_);
return;
}
if (error != 0) {
// Continue the chain (legacy parity) but leave a breadcrumb.
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
}
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
ble_device_base::ESPBTUUID service_uuid_;
ble_device_base::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
bool write_response_{false};
bool resolved_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_CLIENT_GATT_NODES
@@ -1,43 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstdint>
namespace esphome::ble_client {
/// Reconnect backoff after repeated connect/discovery failures, shared by
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
/// wrap can at worst reinstate one stale hold-off of a minute.
class ConnectBackoff {
public:
bool holding_off() const {
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
}
void register_failure(const char *address_str) {
if (this->failures_ < MAX_STEPS)
this->failures_++;
this->start_ = now();
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
}
void reset() { this->failures_ = 0; }
private:
// ~10 s per consecutive failure, capped so a flapping peer retries within
// a minute at worst.
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
static constexpr uint8_t MAX_STEPS = 6;
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
uint16_t start_{0};
uint8_t failures_{0};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT
@@ -1,48 +0,0 @@
#include "ble_gatt_client.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/log.h"
namespace esphome::ble_device_base {
ESPHOME_LOG_TAG(TAG, "ble_device_base");
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
if (end > table.characteristic_count) {
ESP_LOGW(TAG, "characteristic range out of bounds");
return nullptr;
}
for (uint32_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid) {
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
if (end > table.descriptor_count) {
// Corrupt range, not a missing descriptor.
ESP_LOGW(TAG, "descriptor range out of bounds");
return nullptr;
}
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == uuid)
return &table.descriptors[i];
}
return nullptr;
}
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
return desc != nullptr ? desc->handle : 0;
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -11,16 +11,13 @@
// interface. All listener calls are delivered on the ESPHome main loop;
// borrowed data pointers are valid only for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation, so the
// values are wire-frozen - API clients interpret them):
// Error domain (plain int, forwarded to the API without translation):
// 0 success
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
// native error codes land in this window must remap them out
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
// ESP_FAIL from the stack shares this value; both read as a
// failed, unusable connection on the client side)
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
// -1..-15 reserved for future contract sentinels
// anything else: platform stack error/status code, surfaced opaquely.
// Connection events carry HCI status/disconnect reason codes (same code
// space on every controller).
@@ -102,23 +99,9 @@ class GattClientListener {
// The BLEGattConnection op surface, asserted where the alias binds
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the listener) or a synchronous error (busy, not connected, stack
// rejection); one operation may be outstanding at a time. A connection-state
// or discovery completion always arrives from the event loop, never
// synchronously from inside the op call - a synchronous terminal
// on_connection_state from within gatt_disconnect() would re-enter the
// consumer mid-teardown. A local-only completion may instead report inline,
// before the op returns: notify_characteristic (a subscription-list edit on
// the rp2 backend) and a write without response whose payload the stack took
// immediately. Consumers must therefore arm whatever state their
// on_write_result/on_notify_state reads before issuing the op, never after
// the call returns. Semantics beyond the signatures:
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// Returning 0 means the request is accepted, not that the radio acted: the
// backend owns integration with its platform's scan/connect arbitration
// (Bluedroid parks the request for the tracker's promote loop, which owns
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
// and relies on sighting-gated consumers). Consumers must not assume
// connect timing.
// - gatt_disconnect: also cancels a connect in progress (named to coexist
// with a platform stack's own void disconnect() on one backend class).
// Nonzero means nothing to tear down and no completion will follow; an
@@ -160,41 +143,6 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
// Characteristic property bits (the Bluetooth-spec declaration byte carried
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
// exist on the other platforms).
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)
return &table.services[i];
}
return nullptr;
}
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid);
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid);
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -1,14 +1,12 @@
"""Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
consumer's codegen declares and registers the backend instances — the
Bluetooth proxy through its per-slot connection wrappers (a streaming
consumer), and the neutral ble_client through gatt_client_schema() +
new_gatt_backend().
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
Bluetooth proxy's codegen declares and registers the backend instances
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components import rp2040_ble
@@ -25,8 +23,7 @@ from esphome.types import ConfigType
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with (the Bluedroid header includes the tracker's), so
consumers stay platform-blind. The platform-less arm serves tooling that
resolves the manifest without a target."""
consumers need not know. The platform-less arm serves manifest tooling."""
if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.is_rp2:
@@ -66,8 +63,6 @@ DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
# GATT connection slots claimed this run, for the platform cap check.
slot_consumers: list[str] = field(default_factory=list)
def _get_data() -> _ConnectionData:
@@ -124,10 +119,6 @@ class _PlatformBackend:
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
# Selects the backend's alias-ladder arm (order-independent arms).
define: str
# The backend's on-demand materializer gate, when it has one.
materializer_define: str | None = None
# The single registry of platforms with a GATT client backend; a platform
@@ -135,20 +126,11 @@ class _PlatformBackend:
# platform's arm.
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
PLATFORM_ESP32: _PlatformBackend(
BluedroidGattClient,
_esp32_schema_fragment,
_esp32_register,
"USE_BLE_GATT_BACKEND_BLUEDROID",
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
),
PLATFORM_RP2: _PlatformBackend(
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
),
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
}
# Gates dedicated-backend consumers (cv.only_on).
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
key = platform if platform is not None else CORE.target_platform
@@ -183,97 +165,21 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
)
def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None:
rp2040_ble.consume_connection_slots(count, consumer)(config)
# Platforms whose BLE stack owns its own connection budget: the claim is
# charged there and that stack's final validation is the one place an
# overcommit is reported (never two messages for one misconfiguration). One
# mapping drives both, so a platform can never be skipped without a charge.
_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = {
PLATFORM_ESP32: _charge_esp32_budget,
PLATFORM_RP2: _charge_rp2_budget,
}
def consume_gatt_slot(
consumer: str, count: int = 1
) -> Callable[[ConfigType], ConfigType]:
"""Validator claiming GATT connection slots - the one spelling for every
claimant. The neutral ledger covers backend platforms without a stack
budget (the cap check in FINAL_VALIDATE_SCHEMA)."""
def validator(config: ConfigType) -> ConfigType:
_get_data().slot_consumers.extend([consumer] * count)
if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None:
charge(count, consumer, config)
return config
return validator
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# Skipped in testing mode so grouped component builds can co-exist
# (mirrors esp32_ble.validate_connection_slots).
if CORE.testing_mode:
return config
if CORE.target_platform in _STACK_BUDGETS:
return config
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
# Any backend platform without a stack budget must carry a cap here
# or fail loudly, never fail open.
if CORE.target_platform in _PLATFORM_BACKENDS:
raise cv.Invalid(
f"{CORE.target_platform} has a GATT backend but no slot cap "
"in HUB_MAX_CONNECTIONS"
)
return config
claimed = _get_data().slot_consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
async def new_gatt_backend(
config: ConfigType, *, service_table: bool = True
) -> cg.MockObj:
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema() and register
it with its platform stack. The connection slot is claimed at validation
(the consume_gatt_slot validators), not here.
service_table is honored by the Bluedroid backend only: forward
scaffolding for the first esp32 direct consumer, load-bearing on no
current build (rp2 ignores the define and always materializes - its
proxy hub streams through get_service_table(), so it must keep the
materializer regardless of the flag).
(the proxy's slot validators), not here.
"""
from esphome.components import ble_device_base
entry = _backend_entry()
ble_device_base.request_gatt_client()
cg.add_define(entry.define)
if service_table and entry.materializer_define is not None:
cg.add_define(entry.materializer_define)
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend is the slot's real Component: component keys from the
# connection entry (setup_priority, ...) apply to it. Consumers whose own
# schema carries keys that register_component would misapply to the
# backend (e.g. a polling interval) must not put them in this config.
await cg.register_component(backend, config)
await entry.register(backend, config)
await _backend_entry().register(backend, config)
return backend
@@ -281,7 +187,6 @@ async def new_gatt_backend(
# list (this module cannot import bluetooth_proxy to derive it).
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
"gatt_service_table_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
@@ -46,7 +46,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
namespace esphome::bluetooth_connection {
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
@@ -65,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
@@ -49,16 +49,15 @@ static constexpr conn_err_t CONN_OK = 0;
// GATT contract so backend and wrapper cannot drift.
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
// What the build's connection backend supports beyond GATT operations; the
// proxy derives its feature flags and legacy version from these. Keyed on
// the backend define, never the platform, so a second backend on one
// platform carries its own facts.
#if defined(USE_BLE_GATT_BACKEND_BLUEDROID)
// What the platform's connection backend supports beyond GATT operations;
// the proxy derives its feature flags and legacy version from these.
#if defined(USE_ESP32)
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
#elif defined(USE_BLE_GATT_BACKEND_RP2)
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
// The rp2 BTstack backend pairs (just works + bonding); it has no service
// cache to clear.
// cache to clear. Keyed on the backend, not the generic client define, so a
// future backend without pairing keeps the stub arm below.
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#else
@@ -66,14 +65,13 @@ static constexpr bool SUPPORTS_PAIRING = false;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#endif
// Address-scoped (not connection-scoped) maintenance requests; keyed on the
// stack (the calls need no backend instance).
#if (defined(USE_ESP32_BLE) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
// Address-scoped (not connection-scoped) maintenance requests.
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
conn_err_t unpair_device(uint64_t address);
#else
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
#endif
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
conn_err_t clear_gatt_cache(uint64_t address);
#else
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
@@ -2,11 +2,10 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -345,9 +344,6 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
this->table_.free();
#endif
// Always set: terminates any in-flight stream on every cache config.
this->services_released_ = true;
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
@@ -360,24 +356,6 @@ void BluedroidGattClient::release_services() {
#endif
}
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
// Lifetime: every teardown path (CLOSE_EVT, the safety timeout, stack-down,
// passive DISCONNECT) routes through release_services(), so a materialized
// table cannot outlive its link.
if (this->table_.empty() &&
(this->services_released_ || this->service_total_ == 0 ||
!this->table_.build(this->gattc_if_, this->conn_id_, this->service_total_, this->connection_index_))) {
// Released / no services / failed build all collapse to empty; the
// build failures warned above, log the quiet two.
ESP_LOGD(TAG, "[%d] No service table (released=%d, services=%u)", this->connection_index_, this->services_released_,
this->service_total_);
return {};
}
return this->table_.view();
}
#endif // USE_BLUEDROID_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
@@ -424,11 +402,6 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
// ---- service streaming ----
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
// Re-discovery moves the counts the table view derives offsets from; free
// the stale table.
this->table_.free();
#endif
// Step down from the fast discovery params.
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
if (status != ESP_GATT_OK) {
@@ -11,9 +11,6 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
#include "gatt_service_table_bluedroid.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
@@ -79,16 +76,11 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// On-demand table for direct consumers; the proxy streams instead, so the
// materializer compiles only under USE_BLUEDROID_GATT_SERVICE_TABLE (emitted by
// direct-consumer codegen, never by the proxy).
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
// A direct consumer reaching this stub misconfigured its codegen
// (service_table=False): the empty table reads as a service-less peer.
// Contract stub: the proxy streams in place; the on-demand materializer
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
// this stub gets an empty table indistinguishable from a service-less
// peer - do not ship one against this backend before the materializer.
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -118,9 +110,6 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
// Group 1: pointers / composed objects
ble_device_base::GattClientListener *listener_{nullptr};
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
BluedroidServiceTable table_;
#endif
// Group 2: 4-byte types
uint32_t disconnecting_started_{0};
@@ -12,12 +12,10 @@
#include "esphome/components/ble_device_base/ble_gatt_client.h"
// Arms are keyed on codegen-emitted per-backend defines (_PLATFORM_BACKENDS
// in __init__.py), so they are order-independent.
#if defined(USE_BLE_GATT_BACKEND_RP2)
#if defined(USE_RP2040_BLE)
#include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_BLE_GATT_BACKEND_BLUEDROID)
#elif defined(USE_ESP32_BLE)
#include "bluetooth_connection_bluedroid.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
@@ -1,198 +0,0 @@
#include "gatt_service_table_bluedroid.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
#include "esphome/core/log.h"
namespace esphome::bluetooth_connection {
ESPHOME_LOG_TAG(TAG, "bluetooth_connection");
// A stack that never reports end-of-range would otherwise walk forever.
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
// Shared enumeration for both build passes: an identical walk order is what
// lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
auto svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s);
if (svc_status != ESP_GATT_OK || svc_count == 0) {
this->log_walk_warning_("esp_ble_gattc_get_service", svc_status);
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
auto count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
svc.start_handle, svc.end_handle, 0, &svc_chars);
if (count_status != ESP_GATT_OK) {
this->log_walk_warning_("esp_ble_gattc_get_attr_count", count_status);
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status != ESP_GATT_OK || char_count == 0) {
// An early terminator contradicts svc_chars from the same cache;
// never build a silently truncated table.
this->log_walk_warning_("esp_ble_gattc_get_all_char", status);
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
if (d == MAX_DESCRIPTORS_PER_CHARACTERISTIC) {
// A stack that never reports end-of-range; fail like every other
// inconsistency instead of truncating the table silently.
ESP_LOGW(TAG, "[%d] Descriptor walk exceeded %u entries", this->log_index_,
MAX_DESCRIPTORS_PER_CHARACTERISTIC);
return false;
}
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
this->log_walk_warning_("esp_ble_gattc_get_all_descr", desc_status);
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
uint16_t primary = 0;
uint16_t secondary = 0;
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
ESP_GATT_OK ||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
ESP_GATT_OK) {
// A failed count must not read as an authoritative empty database.
return false;
}
*total = primary + secondary;
return true;
}
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
this->free();
this->gattc_if_ = gattc_if;
this->conn_id_ = conn_id;
this->service_total_ = service_total;
this->log_index_ = log_index;
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
ESP_LOGW(TAG, "[%d] Service table walk failed during count", this->log_index_);
this->free();
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
static_assert(alignof(ble_device_base::GattService) >= alignof(ble_device_base::GattCharacteristic) &&
alignof(ble_device_base::GattCharacteristic) >= alignof(ble_device_base::GattDescriptor));
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->storage_ = allocator.allocate(total_bytes);
if (this->storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->log_index_,
static_cast<unsigned>(total_bytes));
this->free();
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes);
auto *descriptors = reinterpret_cast<ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
// Walk error or the database changed between passes; better an empty
// table than a corrupt one.
ESP_LOGW(TAG, "[%d] Service table walk mismatch, discarding", this->log_index_);
this->free();
return false;
}
this->char_total_ = char_total;
this->desc_total_ = desc_total;
return true;
}
void BluedroidServiceTable::log_walk_warning_(const char *operation, int code) {
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->log_index_, operation, code);
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
@@ -1,82 +0,0 @@
// Owning two-pass materializer of one Bluedroid GATT database snapshot into
// the neutral GattServiceTable layout, shared by the BluedroidGattClient
// backend and ble_client's esp32 engine.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/core/helpers.h"
#include <esp_gattc_api.h>
namespace esphome::bluetooth_connection {
class BluedroidServiceTable {
public:
~BluedroidServiceTable() { this->free(); }
// Owns storage_; a copy would double-free. The default constructor is user
// provided, not "= default", so value-initializing the enclosing backend
// cannot zero-fill .bss that is already zero.
BluedroidServiceTable() {}
BluedroidServiceTable(const BluedroidServiceTable &) = delete;
BluedroidServiceTable &operator=(const BluedroidServiceTable &) = delete;
/// The service count build() requires: the stack's PRIMARY+SECONDARY
/// attribute totals, never the SEARCH_RES event count.
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
/// Two-pass build from the stack's cached database (service_total from
/// count_services()). log_index labels warnings. Frees any previous table
/// first; on failure the table is left empty.
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable view() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes),
this->service_total_,
this->char_total_,
this->desc_total_};
}
// Always resets the counts: a failed build must never leave a non-zero
// service_total_ behind a null table.
void free() {
if (this->storage_ != nullptr) {
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->storage_, 0);
this->storage_ = nullptr;
}
this->service_total_ = 0;
this->char_total_ = 0;
this->desc_total_ = 0;
}
bool empty() const { return this->storage_ == nullptr; }
private:
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
void log_walk_warning_(const char *operation, int code);
uint8_t *storage_{nullptr};
uint16_t service_total_{0};
uint16_t char_total_{0};
uint16_t desc_total_{0};
// Walk context, set by build().
uint16_t conn_id_{0};
esp_gatt_if_t gattc_if_{}; // uint8_t width
uint8_t log_index_{0};
};
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
+6 -18
View File
@@ -94,20 +94,10 @@ def _esp32_config_schema() -> cv.All:
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
def validate_connections(config: ConfigType) -> ConfigType:
if CONF_CONNECTIONS in config:
# Explicit entries claim slots like the generated ones; dev
# historically skipped this, letting an explicit-connections
# config evade the controller budget.
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", len(config[CONF_CONNECTIONS])
)(config)
return config
if not config[CONF_ACTIVE]:
if CONF_CONNECTIONS in config or not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config
)
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
return {
**config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
@@ -159,14 +149,14 @@ def _rp2_config_schema() -> cv.All:
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
def populate_connections(config: ConfigType) -> ConfigType:
from esphome.components import rp2040_ble
# One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config
)
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
return {
**config,
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
@@ -216,9 +206,7 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
# sends those requests and their handlers and encoders are dead.
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
for connection_conf in connections:
backend = await bluetooth_connection.new_gatt_backend(
connection_conf, service_table=False
)
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
-8
View File
@@ -463,11 +463,6 @@
#define USE_ESP32_BLE_TRACKER
#define USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
#define USE_BLE_GATT_CLIENT
#define USE_BLE_GATT_BACKEND_BLUEDROID
#define USE_BLUEDROID_GATT_SERVICE_TABLE
#define USE_BLE_CLIENT_GATT_NODES
#define USE_BLE_CLIENT_LEGACY_ENGINE
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
@@ -700,10 +695,7 @@
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_GATT_CLIENT
#define USE_BLE_GATT_BACKEND_RP2
#define USE_BLE_CLIENT_GATT_NODES
#define ESPHOME_BLE_GATT_CLIENT_COUNT 3
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
#define USE_RP2040_VARIANT_RP2040
#define USE_SPI
#ifndef USE_ETHERNET
+10
View File
@@ -1067,6 +1067,16 @@ constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x
/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59.
constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); }
/// Set the bits of \p mask in \p value when \p on is true, clear them otherwise.
/// Use an unsigned mask for a register wider than int; a negative int mask is sign-extended.
template<typename T> ESPHOME_ALWAYS_INLINE constexpr void assign_bits(T &value, std::type_identity_t<T> mask, bool on) {
if (on) {
value |= mask;
} else {
value &= ~mask;
}
}
/// Reverse the order of 8 bits.
inline uint8_t reverse_bits(uint8_t x) {
x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1);
@@ -19,21 +19,9 @@ from esphome.const import (
CONF_NOTIFY,
CONF_SERVICE_UUID,
CONF_TYPE,
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def esp32_platform(set_core_config: SetCoreConfigCallable) -> None:
# The raw-gattc node family gates through BLE_CLIENT_SCHEMA's
# _legacy_engine_only choke point; these schema tests exercise the esp32 arm.
set_core_config(PlatformFramework.ESP32_IDF)
DESCRIPTOR_CONFIG: ConfigType = {
CONF_NAME: "test",
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
@@ -96,72 +84,3 @@ def test_on_notify_implies_notify() -> None:
def test_notify_unchanged_without_on_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False}
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
def test_legacy_node_choke_point_rejects_other_platforms(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
from esphome.core import ID
set_core_config(PlatformFramework.RP2_ARDUINO)
with pytest.raises(cv.Invalid, match="not been migrated"):
ble_client._legacy_engine_only(ID("x"))
# Through the public schema too, so removing the cv.All wiring fails here.
with pytest.raises(cv.Invalid, match="not been migrated"):
ble_client.BLE_CLIENT_SCHEMA({})
def test_neutral_arm_rejects_esp32_only_keys(
set_core_config: SetCoreConfigCallable,
) -> None:
# Pins the schema split's rejection side: the legacy-only keys must not
# leak into the neutral arm. The hub is registered so the extra key is
# the only error - without it the missing-tracker error would satisfy
# the raises vacuously.
from esphome.components import ble_client, ble_device_base
set_core_config(PlatformFramework.RP2_ARDUINO)
ble_device_base.register_hub_provider("rp2_ble_tracker")
CORE.loaded_integrations.add("rp2_ble_tracker")
for key in ("name", "on_passkey_request", "on_passkey_notification"):
with pytest.raises(cv.Invalid, match="extra keys not allowed"):
ble_client.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF", key: "x"})
def test_security_actions_reject_platforms_without_the_feature(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
set_core_config(PlatformFramework.RP2_ARDUINO)
for schema in (
ble_client.BLE_PASSKEY_REPLY_ACTION_SCHEMA,
ble_client.BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA,
ble_client.BLE_REMOVE_BOND_ACTION_SCHEMA,
):
with pytest.raises(cv.Invalid, match="'security' feature, which rp2"):
schema({})
def test_node_schema_passes_on_every_gatt_platform(
set_core_config: SetCoreConfigCallable,
) -> None:
# The neutral node schema carries no engine gate: raw_gattc components
# stay choked, gatt_node components validate wherever ble_client does.
from esphome.components import ble_client
for pf in (PlatformFramework.ESP32_IDF, PlatformFramework.RP2_ARDUINO):
set_core_config(pf)
assert ble_client.NODE_BLE_CLIENT_SCHEMA({})
def test_feature_error_names_the_available_features(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
from esphome.core import ID
set_core_config(PlatformFramework.RP2_ARDUINO)
with pytest.raises(cv.Invalid, match="provides: gatt_node"):
ble_client._legacy_engine_only(ID("x"))
@@ -1,79 +0,0 @@
"""Tests for the cross-component GATT slot ledger."""
import pytest
from esphome import config_validation as cv
from esphome.components import (
ble_client,
ble_device_base,
bluetooth_connection,
bluetooth_proxy,
rp2040_ble,
)
from esphome.const import CONF_MAC_ADDRESS, PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
def test_gatt_slot_ledger_rejects_overcommit_on_rp2(
set_core_config: SetCoreConfigCallable,
) -> None:
# rp2 owns its budget: the stack's validation reports the overcommit and
# the neutral cap check stays silent (one message per misconfiguration).
set_core_config(PlatformFramework.RP2_ARDUINO)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
with pytest.raises(cv.Invalid, match="rp2 maximum is 3"):
rp2040_ble.validate_connection_slots()
def test_gatt_slot_ledger_skipped_in_testing_mode(
set_core_config: SetCoreConfigCallable,
) -> None:
# Grouped component builds merge fixtures past the cap; the check defers
# to testing mode like esp32_ble.validate_connection_slots.
set_core_config(PlatformFramework.RP2_ARDUINO)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
CORE.testing_mode = True
try:
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
rp2040_ble.validate_connection_slots()
finally:
CORE.testing_mode = False
def test_real_validators_charge_the_ledger_on_rp2(
set_core_config: SetCoreConfigCallable,
) -> None:
# End to end through the component CONFIG_SCHEMAs (no hand charges):
# removing either consumer's consume_gatt_slot call fails this test.
set_core_config(PlatformFramework.RP2_ARDUINO)
ble_device_base.register_hub_provider("rp2_ble_tracker")
CORE.loaded_integrations.add("rp2_ble_tracker")
bluetooth_proxy.CONFIG_SCHEMA({})
ble_client.CONFIG_SCHEMA({CONF_MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"})
# The proxy defaults to 3 slots on rp2; ble_client's claim overcommits
# and rp2's own budget names every claimant.
with pytest.raises(
cv.Invalid,
match="Components: bluetooth_proxy, bluetooth_proxy, bluetooth_proxy, "
"ble_client",
):
rp2040_ble.validate_connection_slots()
def test_neutral_cap_check_guards_future_hub_platforms(
set_core_config: SetCoreConfigCallable, monkeypatch: pytest.MonkeyPatch
) -> None:
# Both current platforms defer to their stack budgets; pin the message
# and boundary of the branch a future budget-less hub platform takes.
set_core_config(PlatformFramework.RP2_ARDUINO)
monkeypatch.setattr(bluetooth_connection, "_STACK_BUDGETS", {})
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
with pytest.raises(cv.Invalid, match="supports at most 3 GATT client connection"):
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
@@ -186,18 +186,6 @@ def test_rp2_rejects_esp32_only_keys_by_name(
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_esp32_explicit_connections_claim_gatt_slots(
set_core_config: SetCoreConfigCallable,
) -> None:
# Explicit `connections:` entries must charge the slot ledger like the
# generated ones; dev historically let them evade the budget.
set_core_config(PlatformFramework.ESP32_IDF)
bluetooth_proxy.CONFIG_SCHEMA({"active": True, "connections": [{}, {}]})
# Exact match (one entry per slot): catches a missed charge and a
# double charge alike.
assert bluetooth_connection._get_data().slot_consumers == ["bluetooth_proxy"] * 2
def test_hub_source_filter_covers_every_hub_platform() -> None:
# bluetooth_connection cannot import this module to derive the hub.cpp
# framework set, so pin it here: a platform admitted to the proxy but
@@ -241,12 +229,6 @@ def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# Hub platforms must also be in the backend registry the shared codegen
# helpers dispatch on.
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
# Every non-esp32 backend platform must carry a slot cap: without one the
# ledger's FINAL_VALIDATE accepts unlimited claims silently (esp32's cap
# is the controller budget in esp32_ble).
assert set(bluetooth_connection._PLATFORM_BACKENDS) - {"esp32"} <= set(
bluetooth_connection.HUB_MAX_CONNECTIONS
)
# The outer walkable schema's bound must stay the loosest platform cap.
assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
@@ -1,28 +0,0 @@
ble_client:
- mac_address: 01:02:03:04:05:06
id: test_blec
on_connect:
then:
- ble_client.ble_write:
id: test_blec
service_uuid: '1802'
characteristic_uuid: '2a06'
value: [0x04, 0x05, 0x06]
on_disconnect:
then:
# Reconnect from the disconnect trigger: the connect action must
# survive the same dispatch that started it, so the log still runs.
- ble_client.connect: test_blec
- logger.log: reconnected
button:
- platform: template
name: Connect button
on_press:
- ble_client.connect: test_blec
- ble_client.ble_write:
id: test_blec
service_uuid: '1802'
characteristic_uuid: '2a06'
value: !lambda return {0x01, 0x02};
- ble_client.disconnect: test_blec
@@ -1,6 +0,0 @@
# The neutral engine: the BTstack backend and rp2040_ble come in through
# bluetooth_connection's auto-load; the tracker hub supplies the sightings.
packages:
common: !include common-gatt.yaml
rp2_ble_tracker:
@@ -10,9 +10,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# and the listener vector it dispatches into (codegen-sized by consumers).
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
# The gatt contract test exercises the gated lookup helpers; compile
# their definitions (ble_gatt_client.cpp) into the test build.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
@@ -79,54 +79,4 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
EXPECT_EQ(table.descriptor_count, 0);
}
// A radon_eye_rd200-shaped table: two services, the second holding a
// notifying characteristic with a CCCD and a bare write characteristic.
class ServiceTableLookup : public ::testing::Test {
protected:
void SetUp() override {
this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1};
this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2};
this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0};
this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1};
this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0};
this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013};
this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1};
}
GattService services_[2];
GattCharacteristic characteristics_[3];
GattDescriptor descriptors_[1];
GattServiceTable table_;
};
TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
ASSERT_NE(service, nullptr);
EXPECT_EQ(service->start_handle, 0x0010);
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
const GattCharacteristic *characteristic =
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
ASSERT_NE(characteristic, nullptr);
EXPECT_EQ(characteristic->value_handle, 0x0012);
// The lookup is scoped to the service: 0x2A00 lives in the other service.
EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr);
}
TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013);
const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524));
EXPECT_EQ(find_cccd(this->table_, *write_char), 0);
}
TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) {
// A corrupt index range must fail the lookup, not walk out of bounds.
GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5};
EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr);
GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9};
EXPECT_EQ(find_cccd(this->table_, bad_char), 0);
}
} // namespace esphome::ble_device_base::testing
@@ -1,6 +1,5 @@
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
# compiled (USE_BLE_GATT_CLIENT unset), which pins the
# USE_BLUETOOTH_PROXY_CONNECTIONS gating and the
# 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.
@@ -0,0 +1,30 @@
#include <gtest/gtest.h>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
static_assert([] {
uint8_t v = 0;
assign_bits(v, 1 << 3, true);
return v;
}() == 0x08);
TEST(AssignBits, SetsAndClearsOnlyTheMask) {
uint8_t reg = 0b1010'0001;
assign_bits(reg, 0b0000'0110, true);
EXPECT_EQ(reg, 0b1010'0111);
assign_bits(reg, 0b1000'0010, false);
EXPECT_EQ(reg, 0b0010'0101);
}
TEST(AssignBits, ClearsAtTheTargetWidth) {
uint16_t reg16 = 0xFFFF;
assign_bits(reg16, 1 << 15, false);
EXPECT_EQ(reg16, 0x7FFF);
uint64_t reg64 = UINT64_MAX;
assign_bits(reg64, uint32_t{1} << 31, false);
EXPECT_EQ(reg64, UINT64_MAX & ~(uint64_t{1} << 31));
}
} // namespace esphome::core::testing
+1 -2
View File
@@ -622,7 +622,6 @@ class SensorTracker:
if match_initial_states:
for state in initial_state_helper.initial_states.values():
self.on_state(state, first_pending_only=True)
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
return entities
@@ -15,7 +15,7 @@ from aioesphomeapi import (
)
import pytest
from .state_utils import InitialStateHelper
from .state_utils import InitialStateHelper, require_entity, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -79,23 +79,13 @@ async def test_alarm_control_panel_state_transitions(
):
entities, _ = await client.list_entities_services()
# Find entities
alarm_info: AlarmControlPanelInfo | None = None
door_switch_info: SwitchInfo | None = None
chime_switch_info: SwitchInfo | None = None
for entity in entities:
if isinstance(entity, AlarmControlPanelInfo):
alarm_info = entity
elif isinstance(entity, SwitchInfo):
if entity.name == "Door Sensor Switch":
door_switch_info = entity
elif entity.name == "Chime Sensor Switch":
chime_switch_info = entity
assert alarm_info is not None, "Alarm control panel not found"
assert door_switch_info is not None, "Door sensor switch not found"
assert chime_switch_info is not None, "Chime sensor switch not found"
alarm_info = require_only_entity(entities, AlarmControlPanelInfo)
door_switch_info = require_entity(
entities, "door_sensor_switch", SwitchInfo, exact=True
)
chime_switch_info = require_entity(
entities, "chime_sensor_switch", SwitchInfo, exact=True
)
# Track state changes
states_received: list[AlarmControlPanelState] = []
@@ -13,7 +13,7 @@ import asyncio
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
import pytest
from .state_utils import assert_device_name
from .state_utils import assert_device_name, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -71,7 +71,7 @@ async def test_api_homeassistant_action_no_subscriber(
client.subscribe_service_calls(on_service_call)
entities, _ = await client.list_entities_services()
button = next(e for e in entities if isinstance(e, ButtonInfo))
button = require_only_entity(entities, ButtonInfo)
client.button_command(button.key)
button_call = await asyncio.wait_for(button_action_future, timeout=5.0)
+4 -4
View File
@@ -8,6 +8,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -69,10 +70,9 @@ async def test_crc8_helper(
await assert_device_name(client, "crc8-helper-test")
# Wait for tests to complete with timeout
try:
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("CRC8 integration test timed out after 5 seconds")
await wait_or_fail(
test_complete, 5.0, "CRC8 integration test timed out after 5 seconds"
)
# Verify all tests passed
assert test_results["setup_started"], "CRC8 test setup never started"
@@ -9,6 +9,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -124,10 +125,7 @@ async def test_gpio_expander_cache(
# Verify device info
await assert_device_name(client, "gpio-expander-cache")
try:
await asyncio.wait_for(logs_done.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("Timeout waiting for logs to complete")
await wait_or_fail(logs_done, 5.0, "Timeout waiting for logs to complete")
assert index == len(log_order), (
f"Expected {len(log_order)} log entries, but got {index}"
@@ -11,7 +11,7 @@ import re
import pytest
from .state_utils import assert_device_name
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -62,11 +62,8 @@ async def test_host_logger_thread_safety(
# Verify connection works
await assert_device_name(client, "host-logger-thread-test")
# Get the button entity - find by name
entities, _ = await client.list_entities_services()
button_entities = [e for e in entities if e.name == "Start Thread Race Test"]
assert button_entities, "Could not find Start Thread Race Test button"
button_key = button_entities[0].key
button_key = require_entity(entities, "start_thread_race_test", exact=True).key
# Press the button to start the thread race test
client.button_command(button_key)
@@ -12,7 +12,7 @@ from aioesphomeapi import (
)
import pytest
from .state_utils import wait_for_state
from .state_utils import require_only_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -25,9 +25,7 @@ async def test_host_mode_climate_basic_state(
"""Test basic climate state reporting."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
test_climate = climate_infos[0]
test_climate = require_only_entity(entities, ClimateInfo)
# The thermostat publishes multiple states during setup as the
# temperature/humidity sensors come online. Wait for the state to
@@ -8,7 +8,7 @@ import aioesphomeapi
from aioesphomeapi import ClimateInfo, ClimateMode, EntityState
import pytest
from .state_utils import InitialStateHelper
from .state_utils import InitialStateHelper, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -39,8 +39,8 @@ async def test_host_mode_climate_control(
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
test_climate = require_only_entity(entities, ClimateInfo)
assert test_climate.name == "Dual-mode Thermostat"
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
@@ -51,13 +51,6 @@ async def test_host_mode_climate_control(
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
test_climate = next(
(c for c in climate_infos if c.name == "Dual-mode Thermostat"), None
)
assert test_climate is not None, (
"Dual-mode Thermostat thermostat climate not found"
)
# Adjust setpoints
client.climate_command(
test_climate.key,
@@ -20,6 +20,7 @@ from aioesphomeapi import (
)
import pytest
from .state_utils import require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -120,10 +121,7 @@ async def test_host_mode_many_entities(
# Get entity info to verify climate entity details
entities = await client.list_entities_services()
climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
climate_info = climate_infos[0]
climate_info = require_only_entity(entities[0], ClimateInfo)
# Verify feature flags set as expected
assert climate_info.feature_flags == (
@@ -177,27 +175,18 @@ async def test_host_mode_many_entities(
assert "AWAY" in preset_names, f"Expected 'AWAY' preset, got {preset_names}"
assert "SLEEP" in preset_names, f"Expected 'SLEEP' preset, got {preset_names}"
# Verify datetime entities exist
date_infos = [e for e in entities[0] if isinstance(e, DateInfo)]
time_infos = [e for e in entities[0] if isinstance(e, TimeInfo)]
datetime_infos = [e for e in entities[0] if isinstance(e, DateTimeInfo)]
assert len(date_infos) >= 1, "Expected at least 1 date entity"
assert len(time_infos) >= 1, "Expected at least 1 time entity"
assert len(datetime_infos) >= 1, "Expected at least 1 datetime entity"
# Verify the entity names
date_info = date_infos[0]
# Verify the datetime entities and their names
date_info = require_only_entity(entities[0], DateInfo)
assert date_info.name == "Test Date", (
f"Expected date entity name 'Test Date', got {date_info.name}"
)
time_info = time_infos[0]
time_info = require_only_entity(entities[0], TimeInfo)
assert time_info.name == "Test Time", (
f"Expected time entity name 'Test Time', got {time_info.name}"
)
datetime_info = datetime_infos[0]
datetime_info = require_only_entity(entities[0], DateTimeInfo)
assert datetime_info.name == "Test DateTime", (
f"Expected datetime entity name 'Test DateTime', got {datetime_info.name}"
)
+25 -1
View File
@@ -13,7 +13,9 @@ from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
import functools
import logging
from pathlib import Path
import shutil
import socket
import zlib
@@ -34,6 +36,8 @@ from .host_prefs import force_safe_mode
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
_LOGGER = logging.getLogger(__name__)
DEVICE_NAME = "host-ota-test"
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@@ -80,6 +84,10 @@ def _udp_mode(
real_ack(self, data)
with monkeypatch.context() as m:
# Loopback answers in about a millisecond; resend lost datagrams
# sooner so the loss runs do not dominate the test time
m.setattr(espota2, "UDP_RESEND_INITIAL", 0.05)
m.setattr(espota2, "UDP_RESEND_MIN", 0.02)
if force:
m.setattr(espota2, "UDP_SLOW_EXCHANGE", -1.0)
if blocked:
@@ -142,7 +150,23 @@ async def _build(
binary_path = await compile_esphome(config_path)
api_socket.close()
ota_socket.close()
return api_port, ota_port, binary_path
return api_port, ota_port, await _strip_debug(binary_path)
async def _strip_debug(binary_path: Path) -> Path:
"""The fixtures build with -g; every upload sends the image, so drop the
debug info (GNU and Apple strip both take -S)."""
if (strip := shutil.which("strip")) is None:
_LOGGER.warning(
"strip not found; uploading the unstripped binary, which is much slower"
)
return binary_path
stripped = binary_path.with_name(f"{binary_path.name}.stripped")
proc = await asyncio.create_subprocess_exec(
strip, "-S", "-o", str(stripped), str(binary_path)
)
assert await proc.wait() == 0, "strip failed"
return stripped
async def _run_ota(
+13 -23
View File
@@ -4,24 +4,15 @@ from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import ButtonInfo, EntityInfo, NumberInfo, SwitchInfo
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
def find_entity_by_name(
entities: list[EntityInfo], entity_type: type, name: str
) -> Any:
"""Helper to find an entity by type and name."""
return next(
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
)
@pytest.mark.asyncio
async def test_host_preferences_save_load(
yaml_config: str,
@@ -84,18 +75,17 @@ async def test_host_preferences_save_load(
# Get entity list
entities, _ = await client.list_entities_services()
# Find our test entities using helper
test_switch = find_entity_by_name(entities, SwitchInfo, "Test Switch")
test_number = find_entity_by_name(entities, NumberInfo, "Test Number")
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preferences")
load_button = find_entity_by_name(entities, ButtonInfo, "Load Preferences")
verify_button = find_entity_by_name(entities, ButtonInfo, "Verify Preferences")
assert test_switch is not None, "Test Switch not found"
assert test_number is not None, "Test Number not found"
assert save_button is not None, "Save Preferences button not found"
assert load_button is not None, "Load Preferences button not found"
assert verify_button is not None, "Verify Preferences button not found"
test_switch = require_entity(entities, "test_switch", SwitchInfo, exact=True)
test_number = require_entity(entities, "test_number", NumberInfo, exact=True)
save_button = require_entity(
entities, "save_preferences", ButtonInfo, exact=True
)
load_button = require_entity(
entities, "load_preferences", ButtonInfo, exact=True
)
verify_button = require_entity(
entities, "verify_preferences", ButtonInfo, exact=True
)
# Set initial values
client.switch_command(test_switch.key, True)
@@ -5,26 +5,17 @@ from __future__ import annotations
import asyncio
from pathlib import Path
import re
from typing import Any
from aioesphomeapi import ButtonInfo, EntityInfo
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
DEVICE_NAME = "test_suspend_resume_device"
def find_entity_by_name(
entities: list[EntityInfo], entity_type: type, name: str
) -> Any:
"""Helper to find an entity by type and name."""
return next(
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
)
async def _poll_until_exists(path: Path) -> None:
"""Poll for a file to appear, rather than guessing a sleep duration."""
while not path.exists():
@@ -64,12 +55,15 @@ async def test_host_preferences_suspend_resume(
):
entities, _ = await client.list_entities_services()
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preference")
suspend_button = find_entity_by_name(entities, ButtonInfo, "Suspend Syncer")
resume_button = find_entity_by_name(entities, ButtonInfo, "Resume Syncer")
assert save_button is not None, "Save Preference button not found"
assert suspend_button is not None, "Suspend Syncer button not found"
assert resume_button is not None, "Resume Syncer button not found"
save_button = require_entity(
entities, "save_preference", ButtonInfo, exact=True
)
suspend_button = require_entity(
entities, "suspend_syncer", ButtonInfo, exact=True
)
resume_button = require_entity(
entities, "resume_syncer", ButtonInfo, exact=True
)
# --- Positive control: a running syncer flushes to disk. Without this,
# the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). ---
@@ -15,7 +15,7 @@ from aioesphomeapi import InfraredInfo, RadioFrequencyInfo
from aioesphomeapi.api_pb2 import InfraredRFTransmitCompleteResponse
import pytest
from .state_utils import find_entity
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
FRAME_COUNT = 5
@@ -37,9 +37,9 @@ async def test_ir_rf_transmit_complete_boot(
"""The host build with the mock transmitters boots and lists all entities on any client."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
assert find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) is not None
assert find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) is not None
assert find_entity(entities, "ir_transmitter", InfraredInfo) is not None
require_entity(entities, "rf_transmitter_a", RadioFrequencyInfo)
require_entity(entities, "rf_transmitter_b", RadioFrequencyInfo)
require_entity(entities, "ir_transmitter", InfraredInfo)
@pytest.mark.shared_yaml("ir_rf_transmit_complete")
@@ -92,11 +92,9 @@ async def test_ir_rf_transmit_complete(
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
rf = find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo)
rf_b = find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo)
ir = find_entity(entities, "ir_transmitter", InfraredInfo)
assert rf is not None and rf_b is not None, "RF transmitter entities not found"
assert ir is not None, "IR transmitter entity not found"
rf = require_entity(entities, "rf_transmitter_a", RadioFrequencyInfo)
rf_b = require_entity(entities, "rf_transmitter_b", RadioFrequencyInfo)
ir = require_entity(entities, "ir_transmitter", InfraredInfo)
client._connection.add_message_callback(
on_complete, (InfraredRFTransmitCompleteResponse,)
@@ -19,8 +19,9 @@ from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -61,9 +62,8 @@ async def test_light_constant_brightness(
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
lights = [e for e in entities if isinstance(e, LightInfo)]
cb_light = next(e for e in lights if e.object_id.endswith("cb_light"))
ncb_light = next(e for e in lights if e.object_id.endswith("ncb_light"))
cb_light = require_entity(entities, "test_cb_light", LightInfo, exact=True)
ncb_light = require_entity(entities, "test_ncb_light", LightInfo, exact=True)
# Use InitialStateHelper to wait for initial state broadcast
initial_state_helper = InitialStateHelper(entities)
@@ -90,10 +90,11 @@ async def test_light_constant_brightness(
"""Send a light command and wait for the state response."""
state_futures[light_key] = loop.create_future()
client.light_command(key=light_key, **kwargs)
try:
return await asyncio.wait_for(state_futures[light_key], timeout=timeout)
except TimeoutError:
pytest.fail(f"Timeout waiting for light state after command: {kwargs}")
return await wait_or_fail(
state_futures[light_key],
timeout,
f"Timeout waiting for light state after command: {kwargs}",
)
# --- Test constant_brightness: true ---
@@ -29,7 +29,7 @@ import re
from aioesphomeapi import LogLevel
import pytest
from .state_utils import assert_device_name
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
@@ -73,9 +73,8 @@ async def test_logger_buffered_recursion_guard(
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
entities, _ = await client.list_entities_services()
buttons = [e for e in entities if e.name == "Start Race Test"]
assert buttons, "Could not find Start Race Test button"
client.button_command(buttons[0].key)
button = require_entity(entities, "start_race_test", exact=True)
client.button_command(button.key)
# Wait until every buffered thread message has been delivered over the API.
try:
+52 -46
View File
@@ -9,6 +9,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -136,10 +137,11 @@ async def test_loop_disable_enable(
await assert_device_name(client, "loop-test")
# Wait for self_disable_10 to disable itself
try:
await asyncio.wait_for(self_disable_10_disabled.wait(), timeout=10.0)
except TimeoutError:
pytest.fail("self_disable_10 did not disable itself within 10 seconds")
await wait_or_fail(
self_disable_10_disabled,
10.0,
"self_disable_10 did not disable itself within 10 seconds",
)
# Verify it ran at least 10 times before disabling
assert len([c for c in self_disable_10_counts if c <= 10]) == 10, (
@@ -150,31 +152,32 @@ async def test_loop_disable_enable(
)
# Wait for normal_component to run at least 10 times
try:
await asyncio.wait_for(normal_component_10_loops.wait(), timeout=10.0)
except TimeoutError:
pytest.fail(
f"normal_component did not reach 10 loops within timeout, got {len(normal_component_counts)}"
)
await wait_or_fail(
normal_component_10_loops,
10.0,
"normal_component did not reach 10 loops within timeout",
normal_component_counts=normal_component_counts,
)
# Wait for redundant operation tests
try:
await asyncio.wait_for(redundant_enable_tested.wait(), timeout=10.0)
except TimeoutError:
pytest.fail("redundant_enable did not test enabling when already enabled")
await wait_or_fail(
redundant_enable_tested,
10.0,
"redundant_enable did not test enabling when already enabled",
)
try:
await asyncio.wait_for(redundant_disable_tested.wait(), timeout=10.0)
except TimeoutError:
pytest.fail(
"redundant_disable did not test disabling when will be disabled"
)
await wait_or_fail(
redundant_disable_tested,
10.0,
"redundant_disable did not test disabling when will be disabled",
)
# Wait to see if self_disable_10 gets re-enabled
try:
await asyncio.wait_for(self_disable_10_re_enabled.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("self_disable_10 was not re-enabled within 5 seconds")
await wait_or_fail(
self_disable_10_re_enabled,
5.0,
"self_disable_10 was not re-enabled within 5 seconds",
)
# Component was re-enabled - verify it ran more times
later_self_disable_counts = [c for c in self_disable_10_counts if c > 10]
@@ -184,10 +187,11 @@ async def test_loop_disable_enable(
# Test ISR component functionality
# Wait for ISR component to disable itself after 5 loops
try:
await asyncio.wait_for(isr_component_disabled.wait(), timeout=3.0)
except TimeoutError:
pytest.fail("ISR component did not disable itself within 3 seconds")
await wait_or_fail(
isr_component_disabled,
3.0,
"ISR component did not disable itself within 3 seconds",
)
# Verify it ran exactly 5 times before disabling
first_run_counts = [c for c in isr_component_counts if c <= 5]
@@ -196,10 +200,11 @@ async def test_loop_disable_enable(
)
# Wait for component to be re-enabled by periodic ISR simulation and run again
try:
await asyncio.wait_for(isr_component_re_enabled.wait(), timeout=2.0)
except TimeoutError:
pytest.fail("ISR component was not re-enabled after ISR call")
await wait_or_fail(
isr_component_re_enabled,
2.0,
"ISR component was not re-enabled after ISR call",
)
# Verify it's running again after ISR enable
count_after_isr = len(isr_component_counts)
@@ -208,10 +213,11 @@ async def test_loop_disable_enable(
)
# Wait for pure ISR enable (no main loop enable) to work
try:
await asyncio.wait_for(isr_component_pure_re_enabled.wait(), timeout=2.0)
except TimeoutError:
pytest.fail("ISR component was not re-enabled by pure ISR call")
await wait_or_fail(
isr_component_pure_re_enabled,
2.0,
"ISR component was not re-enabled by pure ISR call",
)
# Verify it ran after pure ISR enable
final_count = len(isr_component_counts)
@@ -221,10 +227,11 @@ async def test_loop_disable_enable(
# Test component.update functionality when loop is disabled
# Wait for update component to disable its loop
try:
await asyncio.wait_for(update_component_loop_disabled.wait(), timeout=3.0)
except TimeoutError:
pytest.fail("Update component did not disable its loop within 3 seconds")
await wait_or_fail(
update_component_loop_disabled,
3.0,
"Update component did not disable its loop within 3 seconds",
)
# Verify it ran exactly 3 loops before disabling
assert update_component_loop_count == 3, (
@@ -232,12 +239,11 @@ async def test_loop_disable_enable(
)
# Wait for manual component.update to be called
try:
await asyncio.wait_for(
update_component_manual_update_called.wait(), timeout=5.0
)
except TimeoutError:
pytest.fail("Manual component.update was not called within 5 seconds")
await wait_or_fail(
update_component_manual_update_called,
5.0,
"Manual component.update was not called within 5 seconds",
)
# The key test: verify that manual component.update worked after loop was disabled
assert update_component_manual_update_count >= 1, (
+4 -6
View File
@@ -9,6 +9,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -35,11 +36,8 @@ async def test_micros_to_millis(
):
await assert_device_name(client, "micros-to-millis-test")
try:
await asyncio.wait_for(all_passed.wait(), timeout=2.0)
except TimeoutError:
if failures:
pytest.fail(f"micros_to_millis failures: {failures}")
pytest.fail("micros_to_millis test timed out")
await wait_or_fail(
all_passed, 2.0, "micros_to_millis test timed out", failures=failures
)
assert not failures, f"micros_to_millis failures: {failures}"
@@ -8,6 +8,7 @@ import re
from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -97,9 +98,9 @@ async def test_multi_device_preferences(
assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}"
# Find the test button entity to trigger preference logging
buttons = [e for e in entities if isinstance(e, ButtonInfo)]
test_button = next((b for b in buttons if b.name == "Test Preferences"), None)
assert test_button is not None, "Test Preferences button not found"
test_button = require_entity(
entities, "test_preferences", ButtonInfo, exact=True
)
# Press the button to trigger logging
client.button_command(test_button.key)
@@ -14,6 +14,7 @@ import pytest
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -57,12 +58,11 @@ async def test_scheduler_background_wake(
)
client.button_command(start_button.key)
try:
elapsed, loop_delta = await asyncio.wait_for(
results[delay], timeout=10.0
)
except TimeoutError:
pytest.fail(f"background insert with delay={delay} did not fire")
elapsed, loop_delta = await wait_or_fail(
results[delay],
10.0,
f"background insert with delay={delay} did not fire",
)
assert elapsed < 1000, (
f"background insert with delay={delay} should interrupt the "
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -80,10 +81,7 @@ async def test_scheduler_bulk_cleanup(
await client.execute_service(trigger_bulk_cleanup_service, {})
# Wait for test completion
try:
await asyncio.wait_for(test_complete_future, timeout=10.0)
except TimeoutError:
pytest.fail("Bulk cleanup test timed out")
await wait_or_fail(test_complete_future, 10.0, "Bulk cleanup test timed out")
# Verify bulk cleanup was triggered
assert bulk_cleanup_triggered, (
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_entity, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -70,11 +71,12 @@ async def test_scheduler_defer_cancel(
await client.execute_service(test_defer_cancel_service, {})
# Wait for test completion
try:
await asyncio.wait_for(test_complete_future, timeout=10.0)
executed_defer = await asyncio.wait_for(test_result_future, timeout=1.0)
except TimeoutError:
pytest.fail("Test did not complete within timeout")
await wait_or_fail(
test_complete_future, 10.0, "Test did not complete within timeout"
)
executed_defer = await wait_or_fail(
test_result_future, 1.0, "Test did not complete within timeout"
)
# Verify that only defer 10 was executed
assert executed_defer == 10, (
@@ -6,6 +6,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -54,10 +55,9 @@ async def test_scheduler_defer_cancels_regular(
await client.execute_service(test_service, {})
# Wait for test completion
try:
await asyncio.wait_for(test_complete_future, timeout=5.0)
except TimeoutError:
pytest.fail(f"Test timed out. Log messages: {log_messages}")
await wait_or_fail(
test_complete_future, 5.0, "Test timed out", log_messages=log_messages
)
# Verify results
assert not error_detected, (
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_entity, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -70,11 +71,16 @@ async def test_scheduler_defer_fifo_simple(
await client.execute_service(test_set_timeout_service, {})
# Wait for first test completion
try:
await asyncio.wait_for(test_complete_future, timeout=5.0)
test1_passed = await asyncio.wait_for(test_result_future, timeout=1.0)
except TimeoutError:
pytest.fail("Test set_timeout(0) did not complete within 5 seconds")
await wait_or_fail(
test_complete_future,
5.0,
"Test set_timeout(0) did not complete within 5 seconds",
)
test1_passed = await wait_or_fail(
test_result_future,
1.0,
"Test set_timeout(0) did not complete within 5 seconds",
)
assert test1_passed is True, (
"set_timeout(0) FIFO test failed - items executed out of order"
@@ -88,11 +94,12 @@ async def test_scheduler_defer_fifo_simple(
await client.execute_service(test_defer_service, {})
# Wait for second test completion
try:
await asyncio.wait_for(test_complete_future, timeout=5.0)
test2_passed = await asyncio.wait_for(test_result_future, timeout=1.0)
except TimeoutError:
pytest.fail("Test defer() did not complete within 5 seconds")
await wait_or_fail(
test_complete_future, 5.0, "Test defer() did not complete within 5 seconds"
)
test2_passed = await wait_or_fail(
test_result_future, 1.0, "Test defer() did not complete within 5 seconds"
)
# Verify the test passed
assert test2_passed is True, (
@@ -13,6 +13,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -77,10 +78,11 @@ async def test_scheduler_internal_id_no_collision(
):
await assert_device_name(client, "scheduler-internal-id-test")
try:
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("Not all collision tests completed within 5 seconds")
await wait_or_fail(
all_tests_complete,
5.0,
"Not all collision tests completed within 5 seconds",
)
# Test 1: Both timeout types with same ID 0 must fire
assert internal_timeout_0_fired.is_set(), (
@@ -21,6 +21,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -85,17 +86,15 @@ async def test_scheduler_interval_reschedule(
await assert_device_name(client, "sched-interval-resched")
# 1. Fast interval (50ms) should reach 10 fires within ~600ms
try:
await asyncio.wait_for(fast_10_future, timeout=5.0)
except TimeoutError:
pytest.fail("Fast interval (50ms) did not fire 10 times")
await wait_or_fail(
fast_10_future, 5.0, "Fast interval (50ms) did not fire 10 times"
)
# 2. Medium interval (100ms) should reach 5 fires
# At that point, fast_count should be roughly 2x medium_count
try:
result = await asyncio.wait_for(medium_5_future, timeout=5.0)
except TimeoutError:
pytest.fail("Medium interval (100ms) did not fire 5 times")
result = await wait_or_fail(
medium_5_future, 5.0, "Medium interval (100ms) did not fire 5 times"
)
fast_at_medium_5 = result[0]
# Fast runs at 50ms, medium at 100ms, so fast should be ~2x medium
@@ -107,10 +106,9 @@ async def test_scheduler_interval_reschedule(
# 3. Slow interval (200ms) should reach 3 fires
# At that point, both fast and medium should have proportionally more fires
try:
result = await asyncio.wait_for(slow_3_future, timeout=5.0)
except TimeoutError:
pytest.fail("Slow interval (200ms) did not fire 3 times")
result = await wait_or_fail(
slow_3_future, 5.0, "Slow interval (200ms) did not fire 3 times"
)
fast_at_slow_3, medium_at_slow_3 = result
# At 600ms: fast ~12, medium ~6, slow 3
@@ -124,10 +122,9 @@ async def test_scheduler_interval_reschedule(
)
# 4. Self-cancelling interval should have stopped after exactly 3 fires
try:
await asyncio.wait_for(self_cancel_stopped_future, timeout=5.0)
except TimeoutError:
pytest.fail("Self-cancelling interval did not stop")
await wait_or_fail(
self_cancel_stopped_future, 5.0, "Self-cancelling interval did not stop"
)
# Wait a bit to ensure it doesn't fire again
await asyncio.sleep(0.3)
@@ -137,18 +134,18 @@ async def test_scheduler_interval_reschedule(
)
# 5. Timeout scheduled from interval callback should have fired
try:
await asyncio.wait_for(callback_timeout_future, timeout=5.0)
except TimeoutError:
pytest.fail("Timeout scheduled from interval callback did not fire")
await wait_or_fail(
callback_timeout_future,
5.0,
"Timeout scheduled from interval callback did not fire",
)
# 6. Interval that replaces itself via set_interval from within callback
# The original fires once, sets up a new named interval, then stops itself.
# The replacement interval should fire 3 times then cancel itself.
try:
await asyncio.wait_for(replace_original_future, timeout=5.0)
except TimeoutError:
pytest.fail("Replace-test original interval did not fire")
await wait_or_fail(
replace_original_future, 5.0, "Replace-test original interval did not fire"
)
try:
await asyncio.wait_for(replaced_stopped_future, timeout=5.0)
@@ -19,6 +19,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -48,19 +49,19 @@ async def test_scheduler_interval_zero_coerced(
await assert_device_name(client, "sched-interval-zero")
# Coerce warning must fire at registration
try:
await asyncio.wait_for(coerce_warning, timeout=5.0)
except TimeoutError:
pytest.fail("Expected coerce warning 'would spin main loop' not seen")
await wait_or_fail(
coerce_warning,
5.0,
"Expected coerce warning 'would spin main loop' not seen",
)
# The coerced 1ms interval should fire 50 times quickly — this
# confirms the callback actually runs (not just registered) and the
# scheduler yields back to the main loop each time.
try:
await asyncio.wait_for(reached_50, timeout=5.0)
except TimeoutError:
pytest.fail(
"Coerced interval=0→1ms did not reach 50 fires within 5s, "
"which would indicate either the coercion failed or the "
"main loop is still being starved."
)
await wait_or_fail(
reached_50,
5.0,
"Coerced interval=0→1ms did not reach 50 fires within 5s, "
"which would indicate either the coercion failed or the "
"main loop is still being starved.",
)
@@ -9,6 +9,7 @@ import pytest
from .conftest import run_binary
from .types import CompileFunction, ConfigWriter
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -29,10 +30,9 @@ async def test_scheduler_loop_phase_timeout(
timeout_result.set_result(int(match.group(1)))
async with run_binary(binary_path, line_callback=on_log_line):
try:
elapsed = await asyncio.wait_for(timeout_result, timeout=15.0)
except TimeoutError:
pytest.fail("loop-phase timeout did not fire within 15 seconds")
elapsed = await wait_or_fail(
timeout_result, 15.0, "loop-phase timeout did not fire within 15 seconds"
)
assert elapsed < 1000, (
f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms"
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -47,9 +48,8 @@ async def test_scheduler_null_name(
await client.execute_service(test_null_name_service, {})
# Wait for test completion
try:
await asyncio.wait_for(test_complete_future, timeout=10.0)
except TimeoutError:
pytest.fail(
"Test did not complete within timeout - likely crashed due to NULL name"
)
await wait_or_fail(
test_complete_future,
10.0,
"Test did not complete within timeout - likely crashed due to NULL name",
)
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -124,25 +125,29 @@ async def test_scheduler_numeric_id_test(
await assert_device_name(client, "scheduler-numeric-id-test")
# Wait for numeric timeout tests
try:
await asyncio.wait_for(numeric_timeout_1001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric timeout 1001 did not fire within 0.5 seconds")
await wait_or_fail(
numeric_timeout_1001_fired,
0.5,
"Numeric timeout 1001 did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(numeric_timeout_1002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric timeout 1002 did not fire within 0.5 seconds")
await wait_or_fail(
numeric_timeout_1002_fired,
0.5,
"Numeric timeout 1002 did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(numeric_interval_2001_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Numeric interval 2001 did not fire within 1 second")
await wait_or_fail(
numeric_interval_2001_fired,
1.0,
"Numeric interval 2001 did not fire within 1 second",
)
try:
await asyncio.wait_for(numeric_interval_cancelled.wait(), timeout=2.0)
except TimeoutError:
pytest.fail("Numeric interval 2001 was not cancelled within 2 seconds")
await wait_or_fail(
numeric_interval_cancelled,
2.0,
"Numeric interval 2001 was not cancelled within 2 seconds",
)
# Verify numeric interval ran at least twice
assert numeric_interval_count >= 2, (
@@ -155,55 +160,60 @@ async def test_scheduler_numeric_id_test(
)
# Wait for duplicate timeout (only last one should fire)
try:
await asyncio.wait_for(duplicate_timeout_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Duplicate numeric timeout did not fire within 1 second")
await wait_or_fail(
duplicate_timeout_fired,
1.0,
"Duplicate numeric timeout did not fire within 1 second",
)
# Wait for component method tests
try:
await asyncio.wait_for(component_timeout_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Component numeric timeout did not fire within 0.5 seconds")
await wait_or_fail(
component_timeout_fired,
0.5,
"Component numeric timeout did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(component_interval_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Component numeric interval did not fire within 1 second")
await wait_or_fail(
component_interval_fired,
1.0,
"Component numeric interval did not fire within 1 second",
)
# Wait for edge case tests
try:
await asyncio.wait_for(zero_id_timeout_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Zero ID timeout did not fire within 0.5 seconds")
await wait_or_fail(
zero_id_timeout_fired,
0.5,
"Zero ID timeout did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(max_id_timeout_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Max ID timeout did not fire within 0.5 seconds")
await wait_or_fail(
max_id_timeout_fired, 0.5, "Max ID timeout did not fire within 0.5 seconds"
)
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds")
await wait_or_fail(
numeric_defer_7001_fired,
0.5,
"Numeric defer 7001 did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds")
await wait_or_fail(
numeric_defer_7002_fired,
0.5,
"Numeric defer 7002 did not fire within 0.5 seconds",
)
# Verify numeric defer was cancelled
try:
await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 8001 cancel confirmation not received")
await wait_or_fail(
numeric_defer_cancelled,
0.5,
"Numeric defer 8001 cancel confirmation not received",
)
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
except TimeoutError:
pytest.fail("Final results were not logged within 3 seconds")
await wait_or_fail(
final_results_logged, 3.0, "Final results were not logged within 3 seconds"
)
# Verify results
assert timeout_count >= 6, f"Expected at least 6 timeouts, got {timeout_count}"
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -90,10 +91,9 @@ async def test_scheduler_rapid_cancellation(
await client.execute_service(run_test_service, {})
# Wait for test to complete with timeout
try:
await asyncio.wait_for(test_complete_future, timeout=10.0)
except TimeoutError:
pytest.fail(f"Test timed out. Stats: {test_stats}")
await wait_or_fail(
test_complete_future, 10.0, "Test timed out", test_stats=test_stats
)
# Check for any errors
assert len(test_stats["errors"]) == 0, (
@@ -6,6 +6,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -61,12 +62,12 @@ async def test_scheduler_recursive_timeout(
await client.execute_service(run_test_service, {})
# Wait for test to complete
try:
await asyncio.wait_for(test_complete_future, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Recursive timeout test timed out. Got sequence: {execution_sequence}"
)
await wait_or_fail(
test_complete_future,
10.0,
"Recursive timeout test timed out",
execution_sequence=execution_sequence,
)
# Verify execution sequence
assert execution_sequence == expected_sequence, (
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -82,10 +83,9 @@ async def test_scheduler_removed_item_race(
await client.execute_service(run_test_service, {})
# Wait for test completion
try:
await asyncio.wait_for(test_complete_future, timeout=5.0)
except TimeoutError:
pytest.fail("Test did not complete within timeout")
await wait_or_fail(
test_complete_future, 5.0, "Test did not complete within timeout"
)
# Verify results
assert test_passed, (
@@ -14,6 +14,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -64,10 +65,11 @@ async def test_scheduler_self_keyed(
):
await assert_device_name(client, "scheduler-self-keyed-test")
try:
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("Not all self-keyed tests completed within 5 seconds")
await wait_or_fail(
all_tests_complete,
5.0,
"Not all self-keyed tests completed within 5 seconds",
)
# Test 1: self-keyed timeout fires
assert self_a_fired.is_set(), "Self timeout A should have fired"
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -80,10 +81,12 @@ async def test_scheduler_simultaneous_callbacks(
await client.execute_service(run_test_service, {})
# Wait for test to complete
try:
await asyncio.wait_for(test_complete_future, timeout=30.0)
except TimeoutError:
pytest.fail(f"Simultaneous callbacks test timed out. Stats: {test_stats}")
await wait_or_fail(
test_complete_future,
30.0,
"Simultaneous callbacks test timed out",
test_stats=test_stats,
)
# Check for any errors
assert len(test_stats["errors"]) == 0, (
+42 -44
View File
@@ -7,6 +7,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -119,25 +120,27 @@ async def test_scheduler_string_test(
await assert_device_name(client, "scheduler-string-test")
# Wait for static string tests
try:
await asyncio.wait_for(static_timeout_1_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static timeout 1 did not fire within 0.5 seconds")
await wait_or_fail(
static_timeout_1_fired,
0.5,
"Static timeout 1 did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(static_timeout_2_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static timeout 2 did not fire within 0.5 seconds")
await wait_or_fail(
static_timeout_2_fired,
0.5,
"Static timeout 2 did not fire within 0.5 seconds",
)
try:
await asyncio.wait_for(static_interval_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Static interval did not fire within 1 second")
await wait_or_fail(
static_interval_fired, 1.0, "Static interval did not fire within 1 second"
)
try:
await asyncio.wait_for(static_interval_cancelled.wait(), timeout=2.0)
except TimeoutError:
pytest.fail("Static interval was not cancelled within 2 seconds")
await wait_or_fail(
static_interval_cancelled,
2.0,
"Static interval was not cancelled within 2 seconds",
)
# Verify static interval ran at least 3 times
assert static_interval_count >= 2, (
@@ -150,44 +153,39 @@ async def test_scheduler_string_test(
)
# Wait for static defer tests
try:
await asyncio.wait_for(static_defer_1_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static defer 1 did not fire within 0.5 seconds")
await wait_or_fail(
static_defer_1_fired, 0.5, "Static defer 1 did not fire within 0.5 seconds"
)
try:
await asyncio.wait_for(static_defer_2_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static defer 2 did not fire within 0.5 seconds")
await wait_or_fail(
static_defer_2_fired, 0.5, "Static defer 2 did not fire within 0.5 seconds"
)
# Wait for dynamic string tests
try:
await asyncio.wait_for(dynamic_timeout_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Dynamic timeout did not fire within 1 second")
await wait_or_fail(
dynamic_timeout_fired, 1.0, "Dynamic timeout did not fire within 1 second"
)
try:
await asyncio.wait_for(dynamic_interval_fired.wait(), timeout=1.5)
except TimeoutError:
pytest.fail("Dynamic interval did not fire within 1.5 seconds")
await wait_or_fail(
dynamic_interval_fired,
1.5,
"Dynamic interval did not fire within 1.5 seconds",
)
# Wait for dynamic defer test
try:
await asyncio.wait_for(dynamic_defer_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Dynamic defer did not fire within 1 second")
await wait_or_fail(
dynamic_defer_fired, 1.0, "Dynamic defer did not fire within 1 second"
)
# Wait for cancel test
try:
await asyncio.wait_for(cancel_test_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Cancel test did not complete within 1 second")
await wait_or_fail(
cancel_test_done, 1.0, "Cancel test did not complete within 1 second"
)
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=4.0)
except TimeoutError:
pytest.fail("Final results were not logged within 4 seconds")
await wait_or_fail(
final_results_logged, 4.0, "Final results were not logged within 4 seconds"
)
# Verify results
assert timeout_count >= 6, (
@@ -7,7 +7,7 @@ import re
import pytest
from .state_utils import assert_device_name
from .state_utils import assert_device_name, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -95,14 +95,11 @@ async def test_select_stringref_trigger(
# List entities to find our select
entities, _ = await client.list_entities_services()
select_entity = next((e for e in entities if e.name == "Test Select"), None)
assert select_entity is not None, "Test Select entity not found"
select_entity = require_entity(entities, "test_select", exact=True)
baud_entity = next((e for e in entities if e.name == "Baud Rate"), None)
assert baud_entity is not None, "Baud Rate entity not found"
baud_entity = require_entity(entities, "baud_rate", exact=True)
action_entity = next((e for e in entities if e.name == "Action Select"), None)
assert action_entity is not None, "Action Select entity not found"
action_entity = require_entity(entities, "action_select", exact=True)
# Change select to Option B - this should trigger on_value with StringRef
client.select_command(select_entity.key, "Option B")
@@ -8,7 +8,7 @@ import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -43,10 +43,8 @@ async def test_sensor_timeout_throttle_filter(
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
button = next(
e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive"
)
client.button_command(button)
button = require_entity(entities, "drive", ButtonInfo, exact=True)
client.button_command(button.key)
await wait_or_fail(
asyncio.gather(*done.values()), 5.0, "Timed out", values=values
)
@@ -85,11 +85,9 @@ async def test_socket_tcp_client_link(
assert echoed and echoed[0] == PAYLOAD, "Echo payload mismatch"
await wait_or_fail(link_down, 15.0, "Link never reported the drop")
try:
await asyncio.wait_for(reconnected.wait(), timeout=15.0)
await asyncio.wait_for(second_link_up.wait(), timeout=15.0)
except TimeoutError:
pytest.fail("Link did not reconnect after the server dropped it")
msg = "Link did not reconnect after the server dropped it"
await wait_or_fail(reconnected, 15.0, msg)
await wait_or_fail(second_link_up, 15.0, msg)
assert second_echoed == [SECOND_PAYLOAD], (
"Second session echo wrong; stale bytes from the first session?"
)
+6 -8
View File
@@ -15,6 +15,7 @@ import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
class ParsedSyslogMessage(TypedDict):
@@ -69,10 +70,6 @@ class SyslogReceiver:
if pattern.search(msg):
event.set()
async def wait_for_messages(self, timeout: float = 10.0) -> None:
"""Wait for at least one message to be received."""
await asyncio.wait_for(self.message_received.wait(), timeout=timeout)
async def wait_for_pattern(self, pattern: str, timeout: float = 5.0) -> str:
"""Wait for a message matching the pattern."""
compiled = re.compile(pattern)
@@ -176,10 +173,11 @@ async def test_syslog(
await assert_device_name(client, "syslog-test")
# Wait for syslog messages (ESPHome logs during startup)
try:
await receiver.wait_for_messages(timeout=10.0)
except TimeoutError:
pytest.fail("No syslog messages received within timeout")
await wait_or_fail(
receiver.message_received,
10.0,
"No syslog messages received within timeout",
)
# Give it a moment to collect more messages
await asyncio.sleep(0.5)
@@ -6,7 +6,7 @@ import aioesphomeapi
from aioesphomeapi.model import APIIntEnum
import pytest
from .state_utils import InitialStateHelper
from .state_utils import InitialStateHelper, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -32,17 +32,12 @@ async def test_template_alarm_control_panel_many_sensors(
# Get entity info first
entities, _ = await client.list_entities_services()
# Find the alarm control panel and binary sensors
alarm_info: aioesphomeapi.AlarmControlPanelInfo | None = None
binary_sensors: list[aioesphomeapi.BinarySensorInfo] = []
for entity in entities:
if isinstance(entity, aioesphomeapi.AlarmControlPanelInfo):
alarm_info = entity
elif isinstance(entity, aioesphomeapi.BinarySensorInfo):
binary_sensors.append(entity)
assert alarm_info is not None, "Alarm control panel entity info not found"
alarm_info = require_only_entity(entities, aioesphomeapi.AlarmControlPanelInfo)
binary_sensors = [
entity
for entity in entities
if isinstance(entity, aioesphomeapi.BinarySensorInfo)
]
assert alarm_info.name == "Test Alarm"
assert alarm_info.requires_code is True
assert alarm_info.requires_code_to_arm is True
@@ -13,6 +13,7 @@ import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -63,10 +64,7 @@ async def test_uart_mock_dfrobot_sen0609(
assert state_of(min_range) == pytest.approx(0.6)
client.number_command(max_range.key, 8.0)
try:
await asyncio.wait_for(saved, timeout=10.0)
except TimeoutError:
pytest.fail("The new range was not written and saved")
await wait_or_fail(saved, 10.0, "The new range was not written and saved")
await wait_for(
lambda: state_of(max_range) == pytest.approx(8.0), "new maximum range"
)
+16 -19
View File
@@ -23,8 +23,9 @@ import asyncio
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -95,8 +96,7 @@ async def test_uart_mock_ld2410(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -118,13 +118,12 @@ async def test_uart_mock_ld2410(
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
try:
await asyncio.wait_for(recovery_received, timeout=15.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
recovery_received,
15.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
@@ -221,8 +220,7 @@ async def test_uart_mock_ld2410_engineering(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -245,12 +243,11 @@ async def test_uart_mock_ld2410_engineering(
assert collector.binary_states["out_pin_presence"][0] is True
# Wait for Phase 3 frame (still_distance = 291cm, multi-byte)
try:
await asyncio.wait_for(phase3_received, timeout=15.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 3 frame. Received:\n"
f" still_distance: {collector.sensor_states['still_distance']}"
)
await wait_or_fail(
phase3_received,
15.0,
"Timeout waiting for Phase 3 frame",
still_distance=collector.sensor_states["still_distance"],
)
assert pytest.approx(291.0) in collector.sensor_states["still_distance"]
@@ -20,8 +20,9 @@ from aioesphomeapi import (
)
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Number object_id: (value from the seed ACK, value from the ACK under test)
EXPECTED: dict[str, tuple[float, float]] = {
@@ -124,26 +125,22 @@ async def test_uart_mock_ld2410_query_ack(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
try:
await asyncio.wait_for(data_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the data frame values. Received:\n"
f" sensor_states: {sensor_states}"
)
try:
await asyncio.wait_for(final_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the query ACK values. Received:\n"
f" number_states: {number_states}\n"
f" ack_errors: {ack_errors}"
)
await wait_or_fail(
data_received,
5.0,
"Timeout waiting for the data frame values",
sensor_states=sensor_states,
)
await wait_or_fail(
final_received,
5.0,
"Timeout waiting for the query ACK values",
number_states=number_states,
ack_errors=ack_errors,
)
# One data frame, so each sensor is published once
for name, value in SENSOR_EXPECTED.items():
+40 -39
View File
@@ -43,6 +43,7 @@ import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01"
VERSION_READ_TX = "FD:FC:FB:FA:02:00:00:00:04:03:02:01"
@@ -127,13 +128,12 @@ async def _start_scenario(client: APIClient, collector: SensorStateCollector) ->
async def _wait_for_presence(
collector: SensorStateCollector, waiter: asyncio.Future[bool]
) -> None:
try:
await asyncio.wait_for(waiter, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for presence updates. Received: "
f"{collector.binary_states['has_target']}"
)
await wait_or_fail(
waiter,
5.0,
"Timeout waiting for presence updates",
has_target=collector.binary_states["has_target"],
)
@pytest.mark.asyncio
@@ -184,10 +184,12 @@ async def test_uart_mock_ld2410s(
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
try:
await asyncio.wait_for(asyncio.gather(setup_done, warning_cleared), 5.0)
except TimeoutError:
pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}")
await wait_or_fail(
asyncio.gather(setup_done, warning_cleared),
5.0,
"Init sequence did not finish",
tx_frames=tx_frames,
)
assert tx_frames[:6] == INIT_TX
assert parsed_answers == PARSED_ANSWERS
@@ -205,10 +207,9 @@ async def test_uart_mock_ld2410s(
assert initial[no_delay_key].state == pytest.approx(10)
client.number_command(max_distance_key, 7.0)
try:
await asyncio.wait_for(write_done, 5.0)
except TimeoutError:
pytest.fail(f"Parameter write not sent. TX frames: {tx_frames}")
await wait_or_fail(
write_done, 5.0, "Parameter write not sent", tx_frames=tx_frames
)
assert tx_frames[6:9] == WRITE_TX, tx_frames
assert number_states == [pytest.approx(7.0)]
@@ -279,10 +280,12 @@ async def test_uart_mock_ld2410s_recovery(
):
await _start_scenario(client, collector)
await _wait_for_presence(collector, all_presence)
try:
await asyncio.wait_for(asyncio.gather(resent, gave_up), 5.0)
except TimeoutError:
pytest.fail(f"No resend or give-up warning. TX frames: {tx_frames}")
await wait_or_fail(
asyncio.gather(resent, gave_up),
5.0,
"No resend or give-up warning",
tx_frames=tx_frames,
)
# Let any unexpected extra update arrive before comparing
await asyncio.sleep(0.3)
@@ -358,30 +361,28 @@ async def test_uart_mock_ld2410s_link_lost(
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
try:
await asyncio.wait_for(setup_done, 5.0)
except TimeoutError:
pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}")
await wait_or_fail(
setup_done, 5.0, "Init sequence did not finish", tx_frames=tx_frames
)
await _start_scenario(client, collector)
try:
await asyncio.wait_for(all_presence, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for presence updates. Received: "
f"{collector.binary_states['has_target']}"
)
await wait_or_fail(
all_presence,
10.0,
"Timeout waiting for presence updates",
has_target=collector.binary_states["has_target"],
)
assert collector.binary_states["has_target"] == EXPECTED_LINK_PRESENCE
try:
lost_at = await asyncio.wait_for(link_lost, timeout=7.0)
await asyncio.wait_for(
asyncio.gather(warning_set_after_loss, restarted), timeout=1.0
)
except TimeoutError:
pytest.fail(
f"No link-lost warning, warning flag or restart. TX frames: {tx_frames}"
)
lost_at = await wait_or_fail(
link_lost, 7.0, "No link-lost warning", tx_frames=tx_frames
)
await wait_or_fail(
asyncio.gather(warning_set_after_loss, restarted),
1.0,
"No warning flag or restart after the link was lost",
tx_frames=tx_frames,
)
# The stream ran past the link timeout, so only the silence after it counts
silence = lost_at - rx_injected_at[-1]
+57 -70
View File
@@ -46,10 +46,10 @@ from .state_utils import (
InitialStateHelper,
SensorStateCollector,
StateWaiter,
find_entity,
require_entity,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line
MOTION_COMMAND = "10:00:03:00"
@@ -193,8 +193,7 @@ async def test_uart_mock_ld2412(
await _wait_initial(client, entities, collector.on_state)
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -216,13 +215,12 @@ async def test_uart_mock_ld2412(
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
try:
await asyncio.wait_for(recovery_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
recovery_received,
3.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
@@ -301,8 +299,7 @@ async def test_uart_mock_ld2412_engineering(
await _wait_initial(client, entities, collector.on_state)
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -324,24 +321,22 @@ async def test_uart_mock_ld2412_engineering(
assert collector.sensor_states["light"][0] == pytest.approx(87.0)
# Wait for Phase 3 frame: still_distance = 291cm (multi-byte)
try:
await asyncio.wait_for(phase3_still_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 3 still_distance. Received:\n"
f" still_distance: {collector.sensor_states['still_distance']}"
)
await wait_or_fail(
phase3_still_received,
3.0,
"Timeout waiting for Phase 3 still_distance",
still_distance=collector.sensor_states["still_distance"],
)
assert pytest.approx(291.0) in collector.sensor_states["still_distance"]
# Wait for Phase 3: detection_distance = 291 (still-only target)
try:
await asyncio.wait_for(phase3_detect_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for detection_distance=291. "
f"Received: {collector.sensor_states['detection_distance']}"
)
await wait_or_fail(
phase3_detect_received,
3.0,
"Timeout waiting for detection_distance=291",
detection_distance=collector.sensor_states["detection_distance"],
)
assert pytest.approx(291.0) in collector.sensor_states["detection_distance"]
@@ -407,8 +402,7 @@ async def test_uart_mock_ld2412_engineering_truncated(
await _wait_initial(client, entities, collector.on_state)
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 — valid engineering frame establishes baseline
@@ -426,14 +420,13 @@ async def test_uart_mock_ld2412_engineering_truncated(
assert collector.sensor_states["light"][0] == pytest.approx(87.0)
# Wait for Phase 3 recovery frame (gate_0_move=50)
try:
await asyncio.wait_for(recovery_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" gate_0_move_energy: {collector.sensor_states['gate_0_move_energy']}\n"
f" light: {collector.sensor_states['light']}"
)
await wait_or_fail(
recovery_received,
3.0,
"Timeout waiting for recovery frame",
gate_0_move_energy=collector.sensor_states["gate_0_move_energy"],
light=collector.sensor_states["light"],
)
# Verify the truncated frame warning was logged
assert truncated_warning_seen.done(), (
@@ -490,13 +483,12 @@ async def test_uart_mock_ld2412_footer_in_payload(
motion_written = events.next_line_with(expected_motion)
client.number_command(numbers["gate_0_move_threshold"].key, 77.0)
try:
await asyncio.wait_for(motion_written, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for the motion threshold command.\n"
f" expected motion payload: {expected_motion}"
)
await wait_or_fail(
motion_written,
5.0,
"Timeout waiting for the motion threshold command",
expected_payload=expected_motion,
)
assert events.count(STILL_COMMAND) == 0, (
"still threshold command was sent for a motion write"
@@ -504,13 +496,12 @@ async def test_uart_mock_ld2412_footer_in_payload(
still_written = events.next_line_with(expected_still)
client.number_command(numbers["gate_5_still_threshold"].key, 77.0)
try:
await asyncio.wait_for(still_written, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for the still threshold command.\n"
f" expected still payload: {expected_still}"
)
await wait_or_fail(
still_written,
5.0,
"Timeout waiting for the still threshold command",
expected_payload=expected_still,
)
assert events.count(MOTION_COMMAND) == 1
assert events.count(STILL_COMMAND) == 1
@@ -519,24 +510,20 @@ async def test_uart_mock_ld2412_footer_in_payload(
# The reset discards the module's thresholds, so the next write is held back and re-queried
reset_sent = events.next_line_with(FACTORY_RESET)
client.button_command(require_entity(entities, "factory_reset", ButtonInfo).key)
try:
await asyncio.wait_for(reset_sent, timeout=5.0)
except TimeoutError:
pytest.fail("Timeout waiting for the factory reset command")
await wait_or_fail(
reset_sent, 5.0, "Timeout waiting for the factory reset command"
)
held_back = events.next_line_with(MOTION_HELD_BACK)
written_after_reset = events.next_line_with(expected_motion_after_reset)
client.number_command(numbers["gate_0_move_threshold"].key, 50.0)
try:
await asyncio.wait_for(
asyncio.gather(held_back, written_after_reset), timeout=5.0
)
except TimeoutError:
pytest.fail(
"Timeout waiting for the write after the factory reset.\n"
f" expected motion payload: {expected_motion_after_reset}\n"
f" logged: {events.lines}"
)
await wait_or_fail(
asyncio.gather(held_back, written_after_reset),
5.0,
"Timeout waiting for the write after the factory reset",
expected_payload=expected_motion_after_reset,
logged=events.lines,
)
reset_at = events.index_after(-1, FACTORY_RESET)
warning_at = events.index_after(reset_at, MOTION_HELD_BACK)
query_at = events.index_after(warning_at, MOTION_QUERY)
@@ -658,12 +645,12 @@ async def test_uart_mock_ld2412_requery_after_giveup(
for key in (gate_0_key, gate_5_key)
]
client.number_command(gate_5_key, 20.0)
try:
await asyncio.wait_for(
asyncio.gather(held_back, config_mode_left), timeout=5.0
)
except TimeoutError:
pytest.fail(f"Timeout waiting for the write to be handled: {events.lines}")
await wait_or_fail(
asyncio.gather(held_back, config_mode_left),
5.0,
"Timeout waiting for the write to be handled",
logged=events.lines,
)
# The unanswered query times out after 2 s
try:
await asyncio.wait_for(given_up, timeout=3.0)
+25 -29
View File
@@ -24,8 +24,9 @@ import asyncio
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -86,8 +87,7 @@ async def test_uart_mock_ld2420(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -106,13 +106,12 @@ async def test_uart_mock_ld2420(
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
try:
await asyncio.wait_for(recovery_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
recovery_received,
5.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
@@ -198,8 +197,7 @@ async def test_uart_mock_ld2420_simple(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -217,13 +215,12 @@ async def test_uart_mock_ld2420_simple(
assert collector.binary_states["has_target"][0] is True
# Wait for Phase 4 recovery (distance=50) after overflow
try:
await asyncio.wait_for(recovery_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" moving_distance: {collector.sensor_states['moving_distance']}"
)
await wait_or_fail(
recovery_received,
5.0,
"Timeout waiting for recovery frame",
moving_distance=collector.sensor_states["moving_distance"],
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
@@ -234,16 +231,15 @@ async def test_uart_mock_ld2420_simple(
# This assertion FAILS before PR #14458 because the 16-digit frame
# in Phase 5 causes an infinite loop in handle_simple_mode_ pre-fix.
# The binary hangs, Phase 6 never fires, and this wait times out.
try:
await asyncio.wait_for(post_bug_received, timeout=8.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for post-bug recovery (distance=25). "
f"This likely means Phase 5 (16-digit frame) caused an infinite "
f"loop in handle_simple_mode_, indicating PR #14458 bug #1 fix "
f"is missing.\n"
f" moving_distance values: {collector.sensor_states['moving_distance']}"
)
await wait_or_fail(
post_bug_received,
8.0,
"Timeout waiting for post-bug recovery (distance=25). "
"This likely means Phase 5 (16-digit frame) caused an infinite "
"loop in handle_simple_mode_, indicating PR #14458 bug #1 fix "
"is missing.",
moving_distance=collector.sensor_states["moving_distance"],
)
# Verify post-bug value
post_bug_values = [
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import ButtonInfo
@@ -10,6 +9,7 @@ import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
EXIT_SENT = re.compile(r"Config exit sent (\d+)")
@@ -62,14 +62,13 @@ async def test_uart_mock_ld2420_config_exit(
resumed = collector.add_waiter(
lambda: len(collector.sensor_states["moving_distance"]) >= 2
)
try:
await asyncio.wait_for(resumed, timeout=5.0)
except TimeoutError:
pytest.fail(
"Energy reports did not resume after the last config exit.\n"
f" exit sends: {exit_sends}\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
resumed,
5.0,
"Energy reports did not resume after the last config exit",
exit_sends=exit_sends,
sensor_states=collector.sensor_states,
)
# The sensor skips repeated values, so each distinct distance is one report
assert collector.sensor_states["moving_distance"] == pytest.approx(
+9 -10
View File
@@ -22,8 +22,9 @@ import asyncio
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -108,8 +109,7 @@ async def test_uart_mock_ld2450(
pytest.fail("Timeout waiting for initial states")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Wait for Phase 1 - all sensors and binary sensors have at least one value
@@ -159,13 +159,12 @@ async def test_uart_mock_ld2450(
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated + overflow
try:
await asyncio.wait_for(recovery_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
recovery_received,
5.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify overflow warning was logged
assert overflow_seen.done(), (
+21 -24
View File
@@ -19,8 +19,9 @@ import asyncio
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -89,8 +90,7 @@ async def test_uart_mock_ld2460(
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
# Phase 1: the datasheet example, one target at (1.5, 2.3)
@@ -101,13 +101,12 @@ async def test_uart_mock_ld2460(
in collector.sensor_states["target_1_angle"]
)
)
try:
await asyncio.wait_for(phase1, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
phase1,
5.0,
"Timeout waiting for the phase 1 frame",
sensor_states=collector.sensor_states,
)
assert collector.sensor_states["target_1_x"][0] == pytest.approx(1.5, abs=0.01)
assert collector.sensor_states["target_1_y"][0] == pytest.approx(2.3, abs=0.01)
assert collector.sensor_states["target_1_distance"][0] == pytest.approx(
@@ -119,13 +118,12 @@ async def test_uart_mock_ld2460(
assert collector.sensor_states["target_count"][0] == pytest.approx(1.0)
# Phase 5: parsing survives garbage, a truncated frame and an overflow
try:
await asyncio.wait_for(recovery_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the recovery frame. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
recovery_received,
5.0,
"Timeout waiting for the recovery frame",
sensor_states=collector.sensor_states,
)
assert overflow_seen.done(), "Expected 'Max frame length exceeded' in the log"
assert (
pytest.approx(3.606, abs=0.01)
@@ -137,13 +135,12 @@ async def test_uart_mock_ld2460(
assert pytest.approx(26.6, abs=0.1) in collector.sensor_states["target_2_angle"]
# Phase 6: the top-mount receipt changes the angle for the same coordinates
try:
await asyncio.wait_for(top_mount_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the top-mount angle. Received:\n"
f" sensor_states: {collector.sensor_states}"
)
await wait_or_fail(
top_mount_received,
5.0,
"Timeout waiting for the top-mount angle",
sensor_states=collector.sensor_states,
)
# The hub sent the installation-mode query from the datasheet (table 11)
tx_data = " ".join(tx_log_lines)
+12 -23
View File
@@ -23,8 +23,9 @@ from collections.abc import Callable
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState
import pytest
from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state
from .state_utils import SensorTracker, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
def _swap16(value: int) -> int:
@@ -832,10 +833,8 @@ async def test_uart_mock_modbus_continuous(
# Recovery path: a live continuous poll that starts failing goes offline, and the next update()
# re-arms it once the device answers again. Silence the server so the poll's reads time out; with
# max_cmd_retries=1 and send_wait_time=100ms the device trips offline quickly and streaming stops.
silence = find_entity(entities, "silence_server", SwitchInfo)
assert silence is not None, "Silence Server switch not found"
start = find_entity(entities, "start_scenario", ButtonInfo)
assert start is not None, "Start Scenario button not found"
silence = require_entity(entities, "silence_server", SwitchInfo)
start = require_entity(entities, "start_scenario", ButtonInfo)
client.switch_command(silence.key, True)
await asyncio.sleep(1.0) # let the poll fail and the device trip offline
@@ -890,8 +889,7 @@ async def test_uart_mock_modbus_offline(
# Register the recovery expectations before waking the device so no update is missed.
online_future = tracker.expect("link_state", 1)
value_future = tracker.expect("reg", 259)
serve_btn = find_entity(entities, "serve", ButtonInfo)
assert serve_btn is not None, "Serve button not found"
serve_btn = require_entity(entities, "serve", ButtonInfo)
client.button_command(serve_btn.key)
# The next offline-cadence retry gets an answer: back online, value published.
@@ -929,8 +927,7 @@ async def test_uart_mock_modbus_fairness(
# Stop polling so the counters settle to a final, stable value (state
# coalescing means intermediate values may be skipped, but the final
# value is always delivered once changes stop).
stop_btn = find_entity(entities, "stop_scenario", ButtonInfo)
assert stop_btn is not None, "Stop Scenario button not found"
stop_btn = require_entity(entities, "stop_scenario", ButtonInfo)
client.button_command(stop_btn.key)
await asyncio.sleep(0.5)
@@ -1050,8 +1047,7 @@ async def test_uart_mock_modbus_register_offset(
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_all(initial, timeout=4.0)
switch = find_entity(entities, "offset_switch", SwitchInfo)
assert switch is not None, "offset_switch not found"
switch = require_entity(entities, "offset_switch", SwitchInfo)
client.switch_command(switch.key, True)
# reg_11 (0x10 + offset 2/2) must receive the write; if the write went to 0x12 this times out.
@@ -1066,8 +1062,7 @@ async def test_uart_mock_modbus_register_offset(
# into its address (0x10 + 6/2 = 0x13, residual byte 0) and joins the 0x10..0x13 range, so the
# read lands in-bounds on 0xABCD (bit 0 set) -> ON. Pre-fix the whole byte offset folded into the
# address (0x16); the server answers ILLEGAL_DATA_ADDRESS there and the switch never publishes.
read_switch = find_entity(entities, "read_offset_switch", SwitchInfo)
assert read_switch is not None, "read_offset_switch not found"
read_switch = require_entity(entities, "read_offset_switch", SwitchInfo)
# The ON transition happened at the first poll and switch states are deduped, so this relies on
# wait_for_state's fresh subscribe_states re-dumping every entity's current state.
await wait_for_state(
@@ -1107,8 +1102,7 @@ async def test_uart_mock_modbus_lambda_write(
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_change(initial, "reg_30", timeout=4.0)
switch = find_entity(entities, "cross_switch", SwitchInfo)
assert switch is not None, "cross_switch not found"
switch = require_entity(entities, "cross_switch", SwitchInfo)
client.switch_command(switch.key, True)
# The coil switch's lambda wrote register 0x30 via item->write_single_register(); reg_30 must
@@ -1144,8 +1138,7 @@ async def test_uart_mock_modbus_lambda_invert(
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_change(initial, "reg_40", timeout=4.0)
switch = find_entity(entities, "invert_switch", SwitchInfo)
assert switch is not None, "invert_switch not found"
switch = require_entity(entities, "invert_switch", SwitchInfo)
client.switch_command(switch.key, True)
# The wire byte carries the inverted value...
@@ -1202,8 +1195,7 @@ async def test_uart_mock_modbus_deprecated_write_buffer(
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
number = find_entity(entities, "buf_number", NumberInfo)
assert number is not None, "buf_number not found"
number = require_entity(entities, "buf_number", NumberInfo)
# First write via the deprecated buffer path.
client.number_command(number.key, 111)
@@ -1289,8 +1281,5 @@ async def test_uart_mock_modbus_ranges(
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
# text_block is not tracker-registered (non-numeric), so time out explicitly.
try:
await asyncio.wait_for(text_future, timeout=5.0)
except TimeoutError:
pytest.fail("text_block never published 'ABCDEF'")
await wait_or_fail(text_future, 5.0, "text_block never published 'ABCDEF'")
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
+10 -11
View File
@@ -16,8 +16,9 @@ import asyncio
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
SENSORS = [
"bat1_voltage",
@@ -102,18 +103,16 @@ async def test_uart_mock_pylontech(
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
try:
await asyncio.wait_for(recovery_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for the recovery reply. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" text_states: {collector.text_sensor_states}"
)
await wait_or_fail(
recovery_received,
5.0,
"Timeout waiting for the recovery reply",
sensor_states=collector.sensor_states,
text_states=collector.text_sensor_states,
)
# Let any stray publish from the bad phases arrive before the exact checks
await asyncio.sleep(0.2)
+2 -4
View File
@@ -40,6 +40,7 @@ import pytest
from .state_utils import InitialStateHelper, StateWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16
RESET_DEVICE = "FE:FE:11:5D:CB:28:16"
@@ -199,10 +200,7 @@ async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None:
async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float:
try:
return await asyncio.wait_for(future, timeout)
except TimeoutError:
pytest.fail(f"Timed out; log: {log.events}")
return await wait_or_fail(future, timeout, "Timed out", log=log.events)
@pytest.mark.shared_yaml("uart_mock_ufm01")
+11 -11
View File
@@ -19,6 +19,7 @@ import pytest
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -76,14 +77,13 @@ async def test_wait_until_on_boot(
# If the fix works, wait_until's loop() will check the condition and proceed
# If the bug exists, wait_until is stuck with disabled loop and will timeout
try:
await asyncio.wait_for(on_boot_complete_future, timeout=2.0)
assert on_boot_completed, (
"on_boot wait_until did not complete after flag was set"
)
except TimeoutError:
pytest.fail(
"wait_until in on_boot did not complete within 2s after condition became true. "
"This indicates the bug where WaitUntilAction::setup() disables the loop "
"after play_complex() has already enabled it."
)
await wait_or_fail(
on_boot_complete_future,
2.0,
"wait_until in on_boot did not complete within 2s after condition became true. "
"This indicates the bug where WaitUntilAction::setup() disables the loop "
"after play_complex() has already enabled it.",
)
assert on_boot_completed, (
"on_boot wait_until did not complete after flag was set"
)
@@ -26,6 +26,7 @@ import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -51,10 +52,9 @@ async def test_wake_loop_forces_phase_b(
):
await assert_device_name(client, "wake-loop-phase-b")
try:
delta, elapsed = await asyncio.wait_for(result, timeout=15.0)
except TimeoutError:
pytest.fail("WAKE_RESULT marker never appeared")
delta, elapsed = await wait_or_fail(
result, 15.0, "WAKE_RESULT marker never appeared"
)
# Without the fix, delta would be 0 — loop_interval_=2000ms held
# Phase B off for the full 500ms observation window. With the fix