mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 20:16:01 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b6fa0245 | ||
|
|
d5cab16b0b | ||
|
|
ad06db6ca8 | ||
|
|
59365beaf6 | ||
|
|
92b57d1851 | ||
|
|
2bc5a695cc | ||
|
|
c46365a95d | ||
|
|
5cf1ff9798 | ||
|
|
0df09adebb | ||
|
|
ad9ef273da | ||
|
|
22aa570b47 | ||
|
|
8d43616507 | ||
|
|
a5bbac084a | ||
|
|
44cea5c0ed | ||
|
|
28ab280c7b | ||
|
|
f2f9b9cbff | ||
|
|
f32e3e75f4 | ||
|
|
5494190d97 | ||
|
|
36468022ea | ||
|
|
19f5641706 | ||
|
|
900dd63883 | ||
|
|
bca4c9dbba | ||
|
|
e1595cb7ef | ||
|
|
86c10ac0c8 | ||
|
|
7b17651837 | ||
|
|
cb7cfa5340 | ||
|
|
9ca77cd7c7 | ||
|
|
da5f43ce41 | ||
|
|
736218e747 | ||
|
|
6fce48f1d5 | ||
|
|
e3820e62bc | ||
|
|
92e663821c |
@@ -1,23 +1,8 @@
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
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,53 +15,13 @@ 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"]
|
||||
|
||||
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}
|
||||
),
|
||||
}
|
||||
)
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
@@ -113,9 +58,7 @@ 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
|
||||
@@ -162,179 +105,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.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
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@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"),
|
||||
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
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
esp32_ble.consume_connection_slots(1, "ble_client"),
|
||||
)
|
||||
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -344,31 +170,11 @@ 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))
|
||||
|
||||
|
||||
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)),
|
||||
}
|
||||
)
|
||||
@@ -379,34 +185,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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -440,8 +237,6 @@ async def ble_connect_to_code(config, action_id, template_arg, args):
|
||||
)
|
||||
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]
|
||||
@@ -456,20 +251,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
|
||||
@@ -526,45 +339,14 @@ async def remove_bond_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
|
||||
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]))
|
||||
for conf in config.get(CONF_ON_CONNECT, []):
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
const char *const Automation::TAG = "ble_client.automation";
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// implement on_connect automation.
|
||||
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
@@ -80,6 +93,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,118 +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 {
|
||||
|
||||
class BLEClientConnectTrigger final : public Trigger<> {
|
||||
public:
|
||||
explicit BLEClientConnectTrigger(BLEClient *parent) {
|
||||
parent->add_on_connect_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientDisconnectTrigger final : public Trigger<> {
|
||||
public:
|
||||
explicit BLEClientDisconnectTrigger(BLEClient *parent) {
|
||||
// Fires only after a completed connection (never for failed attempts),
|
||||
// matching the legacy CLOSE_EVT semantics.
|
||||
parent->add_on_disconnect_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
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, mirroring the legacy DISCONNECT_EVT handling.
|
||||
ble_client->add_on_connect_failed_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
});
|
||||
ble_client->add_on_disconnect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -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,60 +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);
|
||||
this->maybe_release_services_();
|
||||
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) {
|
||||
@@ -111,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;
|
||||
}
|
||||
@@ -138,218 +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"));
|
||||
} 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
|
||||
|
||||
@@ -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,6 +64,7 @@ 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);
|
||||
}
|
||||
@@ -46,57 +73,10 @@ class BLEClient final : public BLEClientBase {
|
||||
|
||||
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_;
|
||||
#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,249 +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 {
|
||||
|
||||
static const char *const 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;
|
||||
}
|
||||
// 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 (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,149 +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); }
|
||||
|
||||
bool connected() const { return this->state_ == State::CONNECTED; }
|
||||
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,168 +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 {
|
||||
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 {
|
||||
|
||||
static const char *const TAG = "ble_gatt_client";
|
||||
|
||||
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,18 +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. An accepted
|
||||
// operation's completion is delivered 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. 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
|
||||
@@ -155,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,89 +165,21 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
|
||||
)
|
||||
|
||||
|
||||
def consume_gatt_slot(
|
||||
consumer: str, count: int = 1
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Validator claiming GATT connection slots - the one spelling for every
|
||||
claimant. Platforms whose BLE stack owns a connection budget (esp32, rp2)
|
||||
are charged there and their stack's final validation reports an
|
||||
overcommit; the neutral ledger covers any future backend platform without
|
||||
one (the cap check in FINAL_VALIDATE_SCHEMA)."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_get_data().slot_consumers.extend([consumer] * count)
|
||||
if CORE.is_esp32:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
elif CORE.target_platform == PLATFORM_RP2:
|
||||
rp2040_ble.consume_connection_slots(count, consumer)(config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# Platforms whose BLE stack owns its own connection budget: consume_gatt_slot
|
||||
# charges it there, and the stack's final validation is the one place an
|
||||
# overcommit is reported (never two messages for one misconfiguration).
|
||||
_STACK_BUDGET_PLATFORMS = {PLATFORM_ESP32, PLATFORM_RP2}
|
||||
|
||||
|
||||
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_BUDGET_PLATFORMS:
|
||||
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
|
||||
|
||||
|
||||
@@ -273,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
|
||||
|
||||
@@ -48,16 +48,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
|
||||
@@ -65,14 +64,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"
|
||||
@@ -301,9 +300,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
|
||||
@@ -316,24 +312,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 {
|
||||
@@ -380,11 +358,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"
|
||||
@@ -75,16 +72,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
|
||||
@@ -113,9 +105,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 {
|
||||
|
||||
static const char *const TAG = "gatt_service_table";
|
||||
|
||||
// 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,80 +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.
|
||||
BluedroidServiceTable() = default;
|
||||
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
|
||||
@@ -98,15 +98,9 @@ def _esp32_config_schema() -> cv.All:
|
||||
raise cv.Invalid(
|
||||
"Connections can only be used if the proxy is set to active"
|
||||
)
|
||||
# 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)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
config
|
||||
)
|
||||
|
||||
@@ -163,14 +157,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)],
|
||||
@@ -220,9 +214,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))
|
||||
|
||||
@@ -330,11 +330,6 @@
|
||||
#define USE_ESP32_BLE_SERVER_ON_DISCONNECT
|
||||
#define USE_ESP32_BLE_TRACKER
|
||||
#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
|
||||
@@ -503,10 +498,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
|
||||
|
||||
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
|
||||
|
||||
|
||||
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
|
||||
# Any usage form (decorator, pytestmark assignment or list element); only
|
||||
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
|
||||
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
|
||||
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
|
||||
|
||||
|
||||
@cache
|
||||
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
||||
for func in _TEST_FUNC_RE.findall(content):
|
||||
base_name = func.replace("test_", "").partition("[")[0]
|
||||
result.setdefault(base_name, set()).add(rel_path)
|
||||
# Shared fixtures are named by marker, not by a test function; each
|
||||
# decorator must carry a string literal or its fixture would silently
|
||||
# map to no tests
|
||||
for use in _SHARED_YAML_USE_RE.finditer(content):
|
||||
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
|
||||
if arg is None:
|
||||
line = content.count("\n", 0, use.start()) + 1
|
||||
raise ValueError(
|
||||
f"{rel_path}:{line}: shared_yaml marker must take a "
|
||||
"single-line string literal so CI test selection can map "
|
||||
"its fixture"
|
||||
)
|
||||
result.setdefault(arg.group(1), set()).add(rel_path)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
@@ -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_BUDGET_PLATFORMS", set())
|
||||
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,24 +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:
|
||||
- ble_client.disconnect: test_blec
|
||||
|
||||
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};
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
|
||||
- The fixture file must exist or the test will fail with a clear error message
|
||||
- The fixture automatically injects a dynamic port number into the API configuration
|
||||
|
||||
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
|
||||
of the test-named file and compile it in a shared, hash-keyed build directory, so
|
||||
the whole group pays one full compile and each test only a relink. The marker
|
||||
argument must be a single-line string literal (CI test selection maps fixtures to
|
||||
test files by scanning for it), and marked tests must hand the `yaml_config`
|
||||
content to `run_compiled` unmodified.
|
||||
|
||||
### Key Fixtures
|
||||
|
||||
- `run_compiled` - Combines write, compile, and run operations into a single context manager
|
||||
|
||||
+335
-74
@@ -4,17 +4,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
import fcntl
|
||||
from functools import cache
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import TextIO
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
|
||||
@@ -23,7 +28,13 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.helpers import (
|
||||
get_usable_cpu_count,
|
||||
read_file,
|
||||
rmtree,
|
||||
write_file,
|
||||
write_file_if_changed,
|
||||
)
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
@@ -56,6 +67,21 @@ import pty # not available on Windows
|
||||
pytest.register_assert_rewrite("tests.integration.entity_utils")
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
|
||||
"hash-keyed incremental build directory",
|
||||
)
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
|
||||
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Get environment variables for PlatformIO with shared cache."""
|
||||
env = os.environ.copy()
|
||||
@@ -78,7 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
)
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
# install points (which may be a different git worktree or checkout).
|
||||
repo_root = str(Path(__file__).resolve().parent.parent.parent)
|
||||
repo_root = str(REPO_ROOT)
|
||||
existing = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
|
||||
return env
|
||||
@@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
def shared_platformio_cache() -> Generator[Path]:
|
||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
test_cache_dir = Path.home() / ".esphome-integration-tests"
|
||||
test_cache_dir = INTEGRATION_TESTS_ROOT
|
||||
cache_dir = test_cache_dir / "platformio"
|
||||
|
||||
# Use a lock file in the home directory to ensure only one process initializes the cache
|
||||
@@ -112,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]:
|
||||
init_dir = Path(tmpdir)
|
||||
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
||||
config_path = init_dir / "cache_init.yaml"
|
||||
config_path.write_text(fixture_path.read_text())
|
||||
config_path.write_text(
|
||||
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Run compilation to populate the cache
|
||||
# We must succeed here to avoid race conditions where multiple
|
||||
@@ -181,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
|
||||
return reserved_tcp_port[0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Give every test its own host prefs dir; prefs are keyed only by device
|
||||
name, which tests sharing a fixture also share."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
||||
"""Load YAML configuration based on test name."""
|
||||
# Get the test function name
|
||||
test_name: str = request.node.name
|
||||
# Extract the base test name (remove test_ prefix and any parametrization)
|
||||
base_name = test_name.replace("test_", "").partition("[")[0]
|
||||
shared_name = _shared_yaml_name(request)
|
||||
# Base test name: test_ prefix and any parametrization stripped
|
||||
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
# Load the fixture file
|
||||
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
|
||||
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
|
||||
if not fixture_path.exists():
|
||||
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
content = await loop.run_in_executor(None, fixture_path.read_text)
|
||||
content = await loop.run_in_executor(None, read_file, fixture_path)
|
||||
|
||||
# Replace the port in the config if it contains api section
|
||||
if "api:" in content:
|
||||
@@ -219,11 +254,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
||||
|
||||
if shared_name is not None:
|
||||
# _compile verifies the marked test compiles this content unmodified
|
||||
request.node._shared_yaml_content = content
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -233,24 +270,218 @@ async def write_yaml_config(
|
||||
) -> AsyncGenerator[ConfigWriter]:
|
||||
"""Write YAML configuration to a file."""
|
||||
# Get the test name for default filename
|
||||
test_name = request.node.name
|
||||
base_name = test_name.replace("test_", "").split("[")[0]
|
||||
base_name = request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
async def _write_config(content: str, filename: str | None = None) -> Path:
|
||||
if filename is None:
|
||||
filename = f"{base_name}.yaml"
|
||||
config_path = integration_test_dir / filename
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, config_path.write_text, content)
|
||||
await loop.run_in_executor(None, write_file, config_path, content)
|
||||
return config_path
|
||||
|
||||
yield _write_config
|
||||
|
||||
|
||||
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
|
||||
# dirs for a fixture are pruned when its content hash changes.
|
||||
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
|
||||
|
||||
# In the dir name (not just the hash) so pruning stays inside this checkout
|
||||
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
|
||||
|
||||
# Give a contended shared build lock time for a full cold compile ahead of us
|
||||
_SHARED_LOCK_TIMEOUT_S = 900
|
||||
_SHARED_LOCK_POLL_S = 0.1
|
||||
_SHARED_LOCK_REPORT_S = 30
|
||||
|
||||
# Reclaims dirs orphaned by fixture renames or deleted checkouts
|
||||
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
|
||||
|
||||
# ELF path per shared build dir; constant once compiled, so resolve it only once
|
||||
_shared_elf_paths: dict[Path, Path] = {}
|
||||
|
||||
# Dirs this process already swept; pruning is session-scoped work
|
||||
_pruned_dirs: set[Path] = set()
|
||||
|
||||
|
||||
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
|
||||
"""Name passed to the shared_yaml marker, or None when unmarked."""
|
||||
marker = request.node.get_closest_marker("shared_yaml")
|
||||
if marker is None:
|
||||
return None
|
||||
# Exactly one \w+ positional arg: the name doubles as a build dir
|
||||
# component, and CI test selection (script/helpers.py) parses the same shape
|
||||
if (
|
||||
len(marker.args) != 1
|
||||
or marker.kwargs
|
||||
or not re.fullmatch(r"\w+", str(marker.args[0]))
|
||||
):
|
||||
raise ValueError(
|
||||
"shared_yaml marker requires exactly one \\w+ fixture name literal"
|
||||
)
|
||||
return marker.args[0]
|
||||
|
||||
|
||||
def _shared_build_prefix(name: str) -> str:
|
||||
return f"{name}-{_REPO_KEY}-"
|
||||
|
||||
|
||||
@cache
|
||||
def _shared_build_dir(name: str) -> Path:
|
||||
"""Dir keyed by checkout and fixture source, before per-test injections."""
|
||||
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
|
||||
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
|
||||
|
||||
|
||||
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
|
||||
"""ELF path recorded by the last completed compile, or None."""
|
||||
try:
|
||||
text = stamp.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as err:
|
||||
print(f"Cannot read {stamp}: {err}")
|
||||
return None
|
||||
if not text:
|
||||
print(f"Ignoring empty stamp {stamp}")
|
||||
return None
|
||||
built = Path(text)
|
||||
# Never trust a stamp pointing outside its own build dir as an unlink target
|
||||
if shared_dir.resolve() in built.resolve().parents:
|
||||
return built
|
||||
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
|
||||
return None
|
||||
|
||||
|
||||
def _unused_since(stale: Path, cutoff: float) -> bool:
|
||||
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
|
||||
# Newest of the .built stamp (rewritten by every completed compile) and the
|
||||
# dir itself (freshened by a worker claiming the dir before locking)
|
||||
newest: float | None = None
|
||||
for probe in (stale / ".built", stale):
|
||||
try:
|
||||
mtime = probe.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except NotADirectoryError:
|
||||
return True # a stray file where a dir should be; reclaimable
|
||||
except OSError as err:
|
||||
print(f"Cannot age-probe {stale}: {err}")
|
||||
return False # unknown never authorizes deletion
|
||||
newest = mtime if newest is None else max(newest, mtime)
|
||||
return newest is not None and newest < cutoff
|
||||
|
||||
|
||||
def _prune_stale_builds(name: str, keep: Path) -> None:
|
||||
"""Remove outdated build dirs (blocking, run in executor): this checkout's
|
||||
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
|
||||
other workers pruning the same dirs concurrently."""
|
||||
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
|
||||
prefix = _shared_build_prefix(name)
|
||||
for stale in SHARED_BUILDS_ROOT.iterdir():
|
||||
if stale == keep:
|
||||
continue
|
||||
same_fixture = stale.name.startswith(prefix)
|
||||
if not same_fixture and not _unused_since(stale, cutoff):
|
||||
continue
|
||||
# Creating .lock bumps the dir mtime, so remember whether the re-probe
|
||||
# under the lock can trust it
|
||||
lock_preexisting = (stale / ".lock").exists()
|
||||
try:
|
||||
lock_file = (stale / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
continue # pruned by another worker meanwhile
|
||||
except NotADirectoryError:
|
||||
print(f"Removing stray file {stale}")
|
||||
stale.unlink(missing_ok=True)
|
||||
continue
|
||||
except OSError as err:
|
||||
print(f"Cannot prune {stale}: {err}")
|
||||
continue
|
||||
with lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
continue # still in use by another run
|
||||
# Re-probe under the lock: a worker freshens its dir before
|
||||
# locking, so a just-claimed dir no longer looks unused. A dir
|
||||
# whose .lock we just created cannot be held by anyone, and our
|
||||
# own open bumped its mtime, so its pre-open probe stands
|
||||
if (
|
||||
lock_preexisting
|
||||
and not same_fixture
|
||||
and not _unused_since(stale, cutoff)
|
||||
):
|
||||
continue
|
||||
# rmtree tolerates races; a leftover partial tree only costs a
|
||||
# rebuild, since the ELF is deleted before every compile
|
||||
try:
|
||||
rmtree(stale)
|
||||
except OSError as err:
|
||||
print(f"Failed to prune {stale}: {err}")
|
||||
|
||||
|
||||
async def _run_esphome_compile(
|
||||
config_path: Path, cwd: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=cwd,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compiled_binary(config_path: Path) -> Path:
|
||||
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
idedata = get_idedata(config)
|
||||
binary_path = Path(idedata.firmware_elf_path)
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
return binary_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def compile_esphome(
|
||||
integration_test_dir: Path,
|
||||
shared_platformio_cache: Path,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> AsyncGenerator[CompileFunction]:
|
||||
"""Compile an ESPHome configuration and return the binary path."""
|
||||
|
||||
@@ -258,66 +489,96 @@ async def compile_esphome(
|
||||
# Use the shared PlatformIO cache for faster compilation
|
||||
# This avoids re-downloading dependencies for each test
|
||||
env = _get_platformio_env(shared_platformio_cache)
|
||||
|
||||
# Retry compilation up to 3 times if we get a segfault
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=integration_test_dir,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
# Success!
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
# Other error or final retry
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
# Load the config to get idedata (blocking call, must use executor)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read_config_and_get_binary():
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
name = _shared_yaml_name(request)
|
||||
if name is None:
|
||||
await _run_esphome_compile(config_path, integration_test_dir, env)
|
||||
return await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, config_path
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
|
||||
# Get the compiled binary path
|
||||
idedata = get_idedata(config)
|
||||
return Path(idedata.firmware_elf_path)
|
||||
|
||||
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
|
||||
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
|
||||
return binary_path
|
||||
# Shared fixture: build in a hash-keyed dir so tests sharing a config
|
||||
# pay one full compile and later only a main.cpp (port) rebuild + relink
|
||||
shared_dir = _shared_build_dir(name)
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Freshen the dir before locking so a concurrent age sweep, which
|
||||
# re-probes under the lock, never reaps a dir a worker just claimed;
|
||||
# if a peer reaped it already, the guarded lock open recreates it
|
||||
with suppress(FileNotFoundError):
|
||||
os.utime(shared_dir)
|
||||
if shared_dir not in _pruned_dirs:
|
||||
_pruned_dirs.add(shared_dir)
|
||||
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
|
||||
shared_config = shared_dir / f"{name}.yaml"
|
||||
private_binary = integration_test_dir / f"{name}.elf"
|
||||
content = await loop.run_in_executor(None, read_file, config_path)
|
||||
if content != getattr(request.node, "_shared_yaml_content", None):
|
||||
# The dir is keyed by the fixture source; a mutated config would be
|
||||
# cached under a hash that does not describe it
|
||||
raise RuntimeError(
|
||||
"shared_yaml tests must compile the yaml_config content unmodified"
|
||||
)
|
||||
# flock serializes concurrent xdist workers; closing the fd releases it.
|
||||
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
|
||||
# the wait cancellable, while a blocking acquire in an executor thread
|
||||
# would survive test cancellation holding the fd
|
||||
try:
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
# A peer run pruning divergent hashes reaped the dir between our
|
||||
# mkdir and this open; recreate it and pay a full rebuild
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
with lock_file:
|
||||
start = time.monotonic()
|
||||
last_report = start
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
now = time.monotonic()
|
||||
if now - start > _SHARED_LOCK_TIMEOUT_S:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for the {shared_dir} lock"
|
||||
) from None
|
||||
if now - last_report >= _SHARED_LOCK_REPORT_S:
|
||||
last_report = now
|
||||
print(
|
||||
f"Waited {now - start:.0f}s for another worker's "
|
||||
f"build of {shared_dir.name}"
|
||||
)
|
||||
await asyncio.sleep(_SHARED_LOCK_POLL_S)
|
||||
# .built carries the ELF path of the last completed compile, so
|
||||
# later workers skip the config re-read in _resolve_compiled_binary
|
||||
stamp = shared_dir / ".built"
|
||||
if (built := _shared_elf_paths.get(shared_dir)) is None:
|
||||
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
|
||||
# Delete the ELF before compiling: whatever exists afterwards is
|
||||
# this compile's output, so no staleness check is ever needed.
|
||||
# With no usable stamp, sweep any leftover at the known layout
|
||||
if built is not None:
|
||||
built.unlink(missing_ok=True)
|
||||
else:
|
||||
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
|
||||
for leftover in shared_dir.rglob("program"):
|
||||
if leftover.is_file():
|
||||
leftover.unlink()
|
||||
await loop.run_in_executor(
|
||||
None, write_file_if_changed, shared_config, content
|
||||
)
|
||||
await _run_esphome_compile(shared_config, shared_dir, env)
|
||||
if built is None or not built.exists():
|
||||
built = await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, shared_config
|
||||
)
|
||||
_shared_elf_paths[shared_dir] = built
|
||||
await loop.run_in_executor(None, write_file, stamp, str(built))
|
||||
# Copy out before unlocking: another worker may relink firmware.elf
|
||||
# while this test is still running its private copy
|
||||
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
|
||||
return private_binary
|
||||
|
||||
yield _compile
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
esphome:
|
||||
name: test-batch-window-filters
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Template sensor that we'll use to publish values
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Source Sensor"
|
||||
id: source_sensor
|
||||
accuracy_decimals: 2
|
||||
|
||||
# Batch window filters (window_size == send_every) - use streaming filters
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Min Sensor"
|
||||
id: min_sensor
|
||||
filters:
|
||||
- min:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Max Sensor"
|
||||
id: max_sensor
|
||||
filters:
|
||||
- max:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Moving Avg Sensor"
|
||||
id: moving_avg_sensor
|
||||
filters:
|
||||
- sliding_window_moving_average:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
# Button to trigger publishing test values
|
||||
button:
|
||||
- platform: template
|
||||
name: "Publish Values Button"
|
||||
id: publish_button
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// Publish 10 values: 1.0, 2.0, ..., 10.0
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
id(source_sensor).publish_state(float(i));
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-cli-rw
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
|
||||
# server's replies reach the client. auto_start so forwarding is active before the button fires.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_client
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_client
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_client
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: the read publishes what it returns, so the test can confirm the
|
||||
# write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(srv_read_1).publish_state(id(stored_1));
|
||||
return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
|
||||
sensor:
|
||||
# Server-side observations.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "srv_read_1"
|
||||
id: srv_read_1
|
||||
# Client-side read-back: the values the client's on_response received.
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0x01
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-custom-pdu
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
|
||||
sensor:
|
||||
# Plain read to confirm the controller <-> server link is up.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001, count 1. The PDU is
|
||||
# {function code, address hi, address lo, count hi, count lo}; the device
|
||||
# address and CRC are added by the hub. The lambda parses the response payload
|
||||
# (the register value, big-endian).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,106 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-dep-buffer
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: |-
|
||||
id(reg10) = x;
|
||||
return true;
|
||||
|
||||
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
|
||||
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
|
||||
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
|
||||
# entity no matter how many writes happen.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0010);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg10);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# The test drives the writes via number_command; the mock is autostart.
|
||||
@@ -1,95 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-invert
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
|
||||
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
|
||||
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
|
||||
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,97 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-write
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
|
||||
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
|
||||
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
|
||||
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
|
||||
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -0,0 +1,233 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-loopback
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared loopback fixture (see the shared_yaml markers in the test file);
|
||||
# register spaces are disjoint so each test only observes its own entities.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
- id: reg50
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
- address: 0x50
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg50);
|
||||
write_lambda: id(reg50) = x; return true;
|
||||
|
||||
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
|
||||
# register count, hitting 0x12). assumed_state keeps the switch write-only.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
|
||||
# would draw ILLEGAL_DATA_ADDRESS and never publish.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
|
||||
# returning an empty optional suppresses the default coil write.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
# Active-low: the write_lambda inverts the wire value but the entity must still
|
||||
# report the requested state (assumed_state keeps the register unpolled).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
|
||||
# must land and the deprecation warning must fire only once per entity.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x50
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0050);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001; device address and CRC are added
|
||||
# by the hub. The lambda parses the big-endian register value.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg50);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# Nothing to start (mock is autostart); tests drive entities directly
|
||||
+210
-23
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-write
|
||||
name: uart-mock-modbus-mesh
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,13 +17,14 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers
|
||||
# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6
|
||||
# on the second server hub. auto_start everywhere: the controller polls at
|
||||
# boot, so the forwarding must already be live or early requests generate warnings.
|
||||
# Every test presses Start Scenario, so all merged actions fire in every test.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
@@ -31,79 +32,120 @@ uart_mock:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
- id: stored_u_word
|
||||
type: uint16_t
|
||||
initial_value: "11"
|
||||
initial_value: "99"
|
||||
- id: stored_u_word_s
|
||||
type: uint16_t
|
||||
initial_value: "4660"
|
||||
- id: stored_s_word
|
||||
type: int16_t
|
||||
initial_value: "-11"
|
||||
initial_value: "-99"
|
||||
- id: stored_s_word_s
|
||||
type: int16_t
|
||||
initial_value: "-2"
|
||||
- id: stored_u_dword
|
||||
type: uint32_t
|
||||
initial_value: "1001"
|
||||
initial_value: "16909060"
|
||||
- id: stored_s_dword
|
||||
type: int32_t
|
||||
initial_value: "-1001"
|
||||
initial_value: "-16909060"
|
||||
- id: stored_u_dword_r
|
||||
type: uint32_t
|
||||
initial_value: "3003"
|
||||
initial_value: "67305985"
|
||||
- id: stored_s_dword_r
|
||||
type: int32_t
|
||||
initial_value: "-3003"
|
||||
initial_value: "-67305985"
|
||||
- id: stored_u_qword
|
||||
type: uint64_t
|
||||
initial_value: "5005"
|
||||
initial_value: "72623859790382856"
|
||||
- id: stored_s_qword
|
||||
type: int64_t
|
||||
initial_value: "-5005"
|
||||
initial_value: "-72623859790382856"
|
||||
- id: stored_u_qword_r
|
||||
type: uint64_t
|
||||
initial_value: "7007"
|
||||
initial_value: "578437695752307201"
|
||||
- id: stored_s_qword_r
|
||||
type: int64_t
|
||||
initial_value: "-7007"
|
||||
initial_value: "-578437695752307201"
|
||||
- id: stored_fp32
|
||||
type: float
|
||||
initial_value: "1.5"
|
||||
initial_value: "3.14"
|
||||
- id: stored_fp32_r
|
||||
type: float
|
||||
initial_value: "2.5"
|
||||
- id: stored_bit_2
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
- id: stored_bit_3
|
||||
type: bool
|
||||
initial_value: "true"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
update_interval: 2s
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_2
|
||||
update_interval: 1s
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_3
|
||||
update_interval: 1s
|
||||
- address: 6
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_6
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
@@ -161,6 +203,47 @@ modbus_server:
|
||||
value_type: FP32_R
|
||||
read_lambda: return id(stored_fp32_r);
|
||||
write_lambda: id(stored_fp32_r) = x; return true;
|
||||
- address: 5
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: srv_write_1 plus the client's read-back
|
||||
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
- address: 6
|
||||
modbus_id: virtual_modbus_server_2
|
||||
bits:
|
||||
- address: 0x00
|
||||
read_lambda: return true;
|
||||
- address: 0x01
|
||||
read_lambda: return false;
|
||||
- address: 0x02
|
||||
read_lambda: return id(stored_bit_2);
|
||||
write_lambda: id(stored_bit_2) = x; return true;
|
||||
- address: 0x03
|
||||
read_lambda: return id(stored_bit_3);
|
||||
write_lambda: id(stored_bit_3) = x; return true;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
@@ -175,6 +258,12 @@ sensor:
|
||||
address: 0x02
|
||||
register_type: holding
|
||||
value_type: U_WORD_S
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word_s_raw"
|
||||
address: 0x02
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_word"
|
||||
@@ -247,7 +336,31 @@ sensor:
|
||||
address: 0x28
|
||||
register_type: holding
|
||||
value_type: FP32_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "multi_reg_a"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_3
|
||||
name: "multi_reg_b"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# client_read_write observations, server- and client-side.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
# The number schema caps min/max at 16777215 (float32 integer precision), so
|
||||
# the large dword/qword baselines cannot be written back through these numbers.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
@@ -364,8 +477,82 @@ number:
|
||||
max_value: 16777215
|
||||
step: 0.01
|
||||
|
||||
# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02);
|
||||
# the server serves both from one shared table, so the two views must agree.
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_coil_0"
|
||||
address: 0x00
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_coil_1"
|
||||
address: 0x01
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_coil_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_coil_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_di_0"
|
||||
address: 0x00
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_di_1"
|
||||
address: 0x01
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_di_2"
|
||||
address: 0x02
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "bit_di_3"
|
||||
address: 0x03
|
||||
register_type: discrete_input
|
||||
|
||||
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
|
||||
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "write_bit_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_6
|
||||
name: "write_bit_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
use_write_multiple: true
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 5
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-reg-offset
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
write_lambda: id(reg13) = x; return true;
|
||||
|
||||
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
|
||||
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
|
||||
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
|
||||
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
|
||||
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
|
||||
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,124 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
rx_full_threshold: 120
|
||||
rx_timeout: 2
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2
|
||||
# Reply from device 2
|
||||
# Read holding register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x02,
|
||||
0x03,
|
||||
0x02,
|
||||
0x00,
|
||||
0xF0,
|
||||
0xFC,
|
||||
0x00,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x94,
|
||||
0x0B,
|
||||
]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, with no response
|
||||
# Read holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x01,
|
||||
0xA4,
|
||||
0x08,
|
||||
]
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
role: server
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
registers:
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
- lambda: "id(virtual_uart_dev).start_scenario();"
|
||||
@@ -1,203 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-contro
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 99;
|
||||
- address: 0x02
|
||||
value_type: U_WORD_S
|
||||
read_lambda: return 4660;
|
||||
- address: 0x03
|
||||
value_type: S_WORD
|
||||
read_lambda: return -99;
|
||||
- address: 0x04
|
||||
value_type: S_WORD_S
|
||||
read_lambda: return -2;
|
||||
- address: 0x05
|
||||
value_type: U_DWORD
|
||||
read_lambda: return 16909060;
|
||||
- address: 0x08
|
||||
value_type: S_DWORD
|
||||
read_lambda: return -16909060;
|
||||
- address: 0x0B
|
||||
value_type: U_DWORD_R
|
||||
read_lambda: return 67305985;
|
||||
- address: 0x0E
|
||||
value_type: S_DWORD_R
|
||||
read_lambda: return -67305985;
|
||||
- address: 0x11
|
||||
value_type: U_QWORD
|
||||
read_lambda: return 72623859790382856;
|
||||
- address: 0x16
|
||||
value_type: S_QWORD
|
||||
read_lambda: return -72623859790382856;
|
||||
- address: 0x1B
|
||||
value_type: U_QWORD_R
|
||||
read_lambda: return 578437695752307201;
|
||||
- address: 0x20
|
||||
value_type: S_QWORD_R
|
||||
read_lambda: return -578437695752307201;
|
||||
- address: 0x25
|
||||
value_type: FP32
|
||||
read_lambda: return 3.14;
|
||||
- address: 0x28
|
||||
value_type: FP32_R
|
||||
read_lambda: return 3.14;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word_s"
|
||||
address: 0x02
|
||||
register_type: holding
|
||||
value_type: U_WORD_S
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word_s_raw"
|
||||
address: 0x02
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_word"
|
||||
address: 0x03
|
||||
register_type: holding
|
||||
value_type: S_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_word_s"
|
||||
address: 0x04
|
||||
register_type: holding
|
||||
value_type: S_WORD_S
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_dword"
|
||||
address: 0x05
|
||||
register_type: holding
|
||||
value_type: U_DWORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_dword"
|
||||
address: 0x08
|
||||
register_type: holding
|
||||
value_type: S_DWORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_dword_r"
|
||||
address: 0x0B
|
||||
register_type: holding
|
||||
value_type: U_DWORD_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_dword_r"
|
||||
address: 0x0E
|
||||
register_type: holding
|
||||
value_type: S_DWORD_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_qword"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_QWORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_qword"
|
||||
address: 0x16
|
||||
register_type: holding
|
||||
value_type: S_QWORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_qword_r"
|
||||
address: 0x1B
|
||||
register_type: holding
|
||||
value_type: U_QWORD_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_s_qword_r"
|
||||
address: 0x20
|
||||
register_type: holding
|
||||
value_type: S_QWORD_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_fp32"
|
||||
address: 0x25
|
||||
register_type: holding
|
||||
value_type: FP32
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_fp32_r"
|
||||
address: 0x28
|
||||
register_type: holding
|
||||
value_type: FP32_R
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,147 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-bits
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_bit_2
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
- id: stored_bit_3
|
||||
type: bool
|
||||
initial_value: "true"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
bits:
|
||||
- address: 0x00
|
||||
read_lambda: return true;
|
||||
- address: 0x01
|
||||
read_lambda: return false;
|
||||
- address: 0x02
|
||||
read_lambda: return id(stored_bit_2);
|
||||
write_lambda: id(stored_bit_2) = x; return true;
|
||||
- address: 0x03
|
||||
read_lambda: return id(stored_bit_3);
|
||||
write_lambda: id(stored_bit_3) = x; return true;
|
||||
|
||||
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
|
||||
# (FC 0x02): the server serves both from one shared bit table, so the two
|
||||
# views must always agree.
|
||||
binary_sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_0"
|
||||
address: 0x00
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_1"
|
||||
address: 0x01
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_coil_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_0"
|
||||
address: 0x00
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_1"
|
||||
address: 0x01
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_2"
|
||||
address: 0x02
|
||||
register_type: discrete_input
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "bit_di_3"
|
||||
address: 0x03
|
||||
register_type: discrete_input
|
||||
|
||||
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
|
||||
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_2"
|
||||
address: 0x02
|
||||
register_type: coil
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "write_bit_3"
|
||||
address: 0x03
|
||||
register_type: coil
|
||||
use_write_multiple: true
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,116 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-mult
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_2
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "reg_u_word_2"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
+47
-8
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-rw
|
||||
name: uart-mock-modbus-srv-injected
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,6 +17,8 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared server-role fixture (see the shared_yaml markers in the test file);
|
||||
# the injections concatenate and each test waits only on its own sensors.
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
@@ -25,18 +27,31 @@ uart_mock:
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
# FC 0x17 Read/Write Multiple Registers on device 1:
|
||||
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
|
||||
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
|
||||
# read back the just-written 0x1234 in the same request.
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, its reply, then read holding
|
||||
# register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
|
||||
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2 with no response, then read
|
||||
# holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
|
||||
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
|
||||
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
|
||||
# FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
|
||||
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
|
||||
# a write and read targeting a different register block.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
|
||||
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
@@ -70,8 +85,18 @@ modbus_server:
|
||||
read_lambda: |-
|
||||
id(rw_read_2).publish_state(0x00AA);
|
||||
return 0x00AA;
|
||||
# Second writable + readable register, targeted by the second request.
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
# Second writable + readable register, targeted by the second FC 0x17 request.
|
||||
- address: 0x06
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(rw_read_3).publish_state(id(stored_3));
|
||||
@@ -80,8 +105,22 @@ modbus_server:
|
||||
id(stored_3) = x;
|
||||
id(rw_write_3).publish_state(x);
|
||||
return true;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
- platform: template
|
||||
name: "rw_write_1"
|
||||
id: rw_write_1
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helpers for manipulating the host platform's preferences file.
|
||||
|
||||
ESPHome's host platform stores preferences in
|
||||
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
|
||||
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
|
||||
mirrors ``HostPreferences::sync()``:
|
||||
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
|
||||
|
||||
@@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
def host_prefs_path(device_name: str) -> Path:
|
||||
"""Return the on-disk prefs file path for a host-platform device."""
|
||||
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
|
||||
"""Return the on-disk prefs file path for a host-platform device.
|
||||
|
||||
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
|
||||
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
|
||||
data if the fixture is ever bypassed."""
|
||||
prefdir = os.environ.get("ESPHOME_PREFDIR")
|
||||
if not prefdir:
|
||||
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
|
||||
return Path(prefdir) / f"{device_name}.prefs"
|
||||
|
||||
|
||||
def clear_host_prefs(device_name: str) -> None:
|
||||
|
||||
@@ -24,12 +24,6 @@ NEW_KEY = base64.b64encode(b"n" * 32)
|
||||
KEY_ACTIVATION_DELAY = 0.5
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so every run starts unprovisioned."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_zero_psk_provisioning(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
|
||||
"""Keep host preferences per-test so this test never touches the real
|
||||
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_preferences_suspend_resume(
|
||||
yaml_config: str,
|
||||
@@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume(
|
||||
isolated_preferences: Path,
|
||||
) -> None:
|
||||
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
|
||||
pref_file = isolated_preferences
|
||||
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
saved_in_memory = loop.create_future()
|
||||
|
||||
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
|
||||
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
|
||||
keyed only by device name)."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_initial_state(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -19,23 +19,40 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import SensorTracker, find_entity, wait_for_state
|
||||
from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegisterTestCase:
|
||||
"""Test parameters for a single modbus register write/read round-trip."""
|
||||
def _swap16(value: int) -> int:
|
||||
"""Byte-swapped view of a 16-bit register as the raw U_WORD wire value."""
|
||||
return ((value & 0xFF) << 8) | (value >> 8)
|
||||
|
||||
initial_value: object
|
||||
write_number_name: str
|
||||
write_value: float
|
||||
post_write_value: object
|
||||
|
||||
# Raw U_WORD view of reg_u_word_s's initial 0x1234
|
||||
MESH_RAW_U_WORD_S = _swap16(4660)
|
||||
|
||||
# Initial values of the mesh fixture's address 1 registers; the
|
||||
# server_controller test reads them and the write test uses them as baseline.
|
||||
MESH_INITIAL_VALUES: dict[str, object] = {
|
||||
"reg_u_word": 99,
|
||||
"reg_u_word_s": 4660,
|
||||
"reg_s_word": -99,
|
||||
"reg_s_word_s": -2,
|
||||
"reg_u_dword": 16909060,
|
||||
"reg_s_dword": -16909060,
|
||||
"reg_u_dword_r": pytest.approx(67305985),
|
||||
"reg_s_dword_r": pytest.approx(-67305985),
|
||||
"reg_u_qword": pytest.approx(72623859790382856),
|
||||
"reg_s_qword": pytest.approx(-72623859790382856),
|
||||
"reg_u_qword_r": pytest.approx(578437695752307201),
|
||||
"reg_s_qword_r": pytest.approx(-578437695752307201),
|
||||
"reg_fp32": pytest.approx(3.14),
|
||||
"reg_fp32_r": pytest.approx(2.5),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -173,6 +190,7 @@ async def test_uart_mock_modbus_no_threshold(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server(
|
||||
yaml_config: str,
|
||||
@@ -203,6 +221,7 @@ async def test_uart_mock_modbus_server(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_read_write(
|
||||
yaml_config: str,
|
||||
@@ -231,8 +250,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
"rw_write_1": 4660, # 0x1234 written to reg 0x0001
|
||||
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
|
||||
"rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
|
||||
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
|
||||
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value
|
||||
}
|
||||
)
|
||||
|
||||
@@ -241,7 +260,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_all(futures)
|
||||
# The FC 0x17 injections fire last, behind four earlier 100ms delays
|
||||
await tracker.await_all(futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@@ -296,6 +316,7 @@ async def test_uart_mock_modbus_server_read_write_invalid(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller(
|
||||
yaml_config: str,
|
||||
@@ -306,23 +327,7 @@ async def test_uart_mock_modbus_server_controller(
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
expected_values = {
|
||||
"reg_u_word": 99,
|
||||
"reg_u_word_s": 4660,
|
||||
"reg_u_word_s_raw": 13330,
|
||||
"reg_s_word": -99,
|
||||
"reg_s_word_s": -2,
|
||||
"reg_u_dword": 16909060,
|
||||
"reg_s_dword": -16909060,
|
||||
"reg_u_dword_r": pytest.approx(67305985),
|
||||
"reg_s_dword_r": pytest.approx(-67305985),
|
||||
"reg_u_qword": pytest.approx(72623859790382856),
|
||||
"reg_s_qword": pytest.approx(-72623859790382856),
|
||||
"reg_u_qword_r": pytest.approx(578437695752307201),
|
||||
"reg_s_qword_r": pytest.approx(-578437695752307201),
|
||||
"reg_fp32": pytest.approx(3.14),
|
||||
"reg_fp32_r": pytest.approx(3.14),
|
||||
}
|
||||
expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S}
|
||||
tracker = SensorTracker(list(expected_values.keys()))
|
||||
futures = tracker.expect_all(expected_values)
|
||||
|
||||
@@ -330,14 +335,12 @@ async def test_uart_mock_modbus_server_controller(
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# The controller polls from boot, so the first values can already be in
|
||||
# the states the device sends on connect; matching them there saves
|
||||
# waiting for the next poll
|
||||
await tracker.setup_and_start_scenario(client, match_initial_states=True)
|
||||
await tracker.await_all(futures)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_write(
|
||||
yaml_config: str,
|
||||
@@ -353,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write(
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
register_test_cases: dict[str, RegisterTestCase] = {
|
||||
"reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42),
|
||||
"reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185),
|
||||
"reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42),
|
||||
"reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257),
|
||||
"reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002),
|
||||
"reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002),
|
||||
"reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004),
|
||||
"reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004),
|
||||
"reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006),
|
||||
"reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006),
|
||||
"reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008),
|
||||
"reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008),
|
||||
"reg_fp32": RegisterTestCase(
|
||||
pytest.approx(1.5, abs=0.01),
|
||||
"write_fp32",
|
||||
3.14,
|
||||
pytest.approx(3.14, abs=0.01),
|
||||
),
|
||||
"reg_fp32_r": RegisterTestCase(
|
||||
pytest.approx(2.5, abs=0.01),
|
||||
"write_fp32_r",
|
||||
6.28,
|
||||
pytest.approx(6.28, abs=0.01),
|
||||
),
|
||||
# Per read-back sensor: the number entity to write through and the value;
|
||||
# floats read back within tolerance, everything else exactly
|
||||
register_writes: dict[str, tuple[str, int | float]] = {
|
||||
"reg_u_word": ("write_u_word", 42),
|
||||
"reg_u_word_s": ("write_u_word_s", 17185),
|
||||
"reg_s_word": ("write_s_word", -42),
|
||||
"reg_s_word_s": ("write_s_word_s", -257),
|
||||
"reg_u_dword": ("write_u_dword", 2002),
|
||||
"reg_s_dword": ("write_s_dword", -2002),
|
||||
"reg_u_dword_r": ("write_u_dword_r", 4004),
|
||||
"reg_s_dword_r": ("write_s_dword_r", -4004),
|
||||
"reg_u_qword": ("write_u_qword", 6006),
|
||||
"reg_s_qword": ("write_s_qword", -6006),
|
||||
"reg_u_qword_r": ("write_u_qword_r", 8008),
|
||||
"reg_s_qword_r": ("write_s_qword_r", -8008),
|
||||
"reg_fp32": ("write_fp32", 6.28),
|
||||
"reg_fp32_r": ("write_fp32_r", 9.42),
|
||||
}
|
||||
|
||||
tracker = SensorTracker(list(register_test_cases.keys()))
|
||||
tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"])
|
||||
|
||||
# The raw U_WORD view of 0x02 pins the byte swap on the write path: the
|
||||
# round trip through write_u_word_s applies the swap an even number of
|
||||
# times, so only the raw sensor can catch a symmetrically dropped swap.
|
||||
# Phase 1: expect initial baseline values
|
||||
initial_futures = tracker.expect_all(
|
||||
{name: case.initial_value for name, case in register_test_cases.items()}
|
||||
MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S}
|
||||
)
|
||||
# Phase 2: expect post-write values (registered now so on_state can match them)
|
||||
written_futures = tracker.expect_all(
|
||||
{name: case.post_write_value for name, case in register_test_cases.items()}
|
||||
{
|
||||
name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value
|
||||
for name, (_, value) in register_writes.items()
|
||||
}
|
||||
| {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])}
|
||||
)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# The controller polls from boot, so the baseline can already be in the
|
||||
# states the device sends on connect; matching it there saves waiting for
|
||||
# the next poll
|
||||
entities = await tracker.setup_and_start_scenario(
|
||||
client, match_initial_states=True
|
||||
)
|
||||
@@ -406,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write(
|
||||
# connection is working before issuing writes
|
||||
await tracker.await_all(initial_futures, timeout=4.0)
|
||||
|
||||
# Issue write commands for all register types
|
||||
for case in register_test_cases.values():
|
||||
entity = find_entity(entities, case.write_number_name, NumberInfo)
|
||||
assert entity is not None, (
|
||||
f"{case.write_number_name} number entity not found"
|
||||
)
|
||||
client.number_command(entity.key, case.write_value)
|
||||
# Issue write commands for all register types; exact object_id match,
|
||||
# since several write_* names are prefixes of a sibling
|
||||
numbers = {
|
||||
e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo)
|
||||
}
|
||||
for number_name, value in register_writes.values():
|
||||
entity = numbers.get(number_name)
|
||||
assert entity is not None, f"{number_name} number entity not found"
|
||||
client.number_command(entity.key, value)
|
||||
|
||||
# Wait for sensors to reflect the written values (round-trip write+read)
|
||||
await tracker.await_all(written_futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_bits(
|
||||
yaml_config: str,
|
||||
@@ -464,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits(
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# The controller polls from boot and binary sensors drop repeats, so the
|
||||
# baseline can arrive only in the states the device sends on connect
|
||||
entities = await tracker.setup_and_start_scenario(
|
||||
client, match_initial_states=True
|
||||
)
|
||||
@@ -476,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits(
|
||||
|
||||
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false
|
||||
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
|
||||
entity = find_entity(entities, switch_name, SwitchInfo)
|
||||
assert entity is not None, f"{switch_name} switch entity not found"
|
||||
entity = require_entity(entities, switch_name, SwitchInfo)
|
||||
client.switch_command(entity.key, value)
|
||||
|
||||
# Wait for both read views to reflect the written values
|
||||
@@ -485,6 +484,7 @@ async def test_uart_mock_modbus_server_controller_bits(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_multiple(
|
||||
yaml_config: str,
|
||||
@@ -495,7 +495,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
|
||||
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929}
|
||||
tracker = SensorTracker(list(expected_values.keys()))
|
||||
futures = tracker.expect_all(expected_values)
|
||||
|
||||
@@ -503,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple(
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# The controller polls from boot, so the first values can already be in
|
||||
# the states the device sends on connect; matching them there saves
|
||||
# waiting for the next poll
|
||||
await tracker.setup_and_start_scenario(client, match_initial_states=True)
|
||||
await tracker.await_all(futures)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
@@ -706,6 +703,7 @@ async def test_uart_mock_modbus_shared_address(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_custom_pdu(
|
||||
yaml_config: str,
|
||||
@@ -932,6 +930,7 @@ async def test_uart_mock_modbus_broadcast_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_client_read_write(
|
||||
yaml_config: str,
|
||||
@@ -947,9 +946,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
"""
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
tracker = SensorTracker(
|
||||
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
|
||||
)
|
||||
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
|
||||
futures = tracker.expect_all(
|
||||
{
|
||||
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
|
||||
@@ -967,6 +964,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_register_offset(
|
||||
yaml_config: str,
|
||||
@@ -1022,6 +1020,7 @@ async def test_uart_mock_modbus_register_offset(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_write(
|
||||
yaml_config: str,
|
||||
@@ -1058,6 +1057,7 @@ async def test_uart_mock_modbus_lambda_write(
|
||||
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_invert(
|
||||
yaml_config: str,
|
||||
@@ -1113,6 +1113,7 @@ async def test_uart_mock_modbus_lambda_invert(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_deprecated_write_buffer(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -2122,6 +2122,34 @@ def test_get_cpp_changed_components_independent_of_cwd(
|
||||
) == ["time"]
|
||||
|
||||
|
||||
def test_fixture_map_includes_shared_yaml_markers() -> None:
|
||||
"""Fixtures named only by shared_yaml markers must map to their test file."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
for fixture in (
|
||||
"uart_mock_modbus_loopback",
|
||||
"uart_mock_modbus_mesh",
|
||||
"uart_mock_modbus_server_injected",
|
||||
):
|
||||
assert mapping[fixture] == frozenset(
|
||||
{"tests/integration/test_uart_mock_modbus.py"}
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_integration_fixtures() -> None:
|
||||
"""Every fixture must reach CI test selection; an orphan selects nothing."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
|
||||
fixtures = list(fixtures_dir.glob("*.yaml"))
|
||||
assert fixtures, f"no fixtures found under {fixtures_dir}"
|
||||
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
|
||||
orphans = [
|
||||
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
|
||||
]
|
||||
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
|
||||
|
||||
|
||||
def test_lpt_partition_balances_skewed_weights() -> None:
|
||||
"""Heavy items spread across groups instead of clustering."""
|
||||
items = [f"i{n}" for n in range(6)]
|
||||
|
||||
Reference in New Issue
Block a user