diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 1ef7967fa8..fe088a9104 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -1,8 +1,18 @@ +import functools + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker -from esphome.components.esp32_ble import BTLoggers +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 import esphome.config_validation as cv from esphome.const import ( CONF_CHARACTERISTIC_UUID, @@ -15,13 +25,40 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, CONF_VALUE, + PlatformFramework, ) -from esphome.core import ID +from esphome.core import CORE, ID +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_client"] +# 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 AUTO_LOAD() -> list[str]: + """The engine's closure per platform: the legacy esp32 engine builds on + esp32_ble_client, the neutral engine on the bluetooth_connection backend. + The platform-less arm is the union for manifest-resolving tooling.""" + if CORE.is_esp32: + return ["esp32_ble_client"] + if CORE.target_platform is None: + return ["bluetooth_connection", "esp32_ble_client"] + return ["bluetooth_connection"] + + CODEOWNERS = ["@buxtronix", "@clydebarrow"] -DEPENDENCIES = ["esp32_ble_tracker"] + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "ble_client.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "ble_client_gatt.cpp": {PlatformFramework.RP2_ARDUINO}, + } +) CONF_DESCRIPTOR_UUID = "descriptor_uuid" CONF_ON_NOTIFY = "on_notify" @@ -58,7 +95,9 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType: ble_client_ns = cg.esphome_ns.namespace("ble_client") -BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) +# 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) BLEClientNode = ble_client_ns.class_("BLEClientNode") BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const") # Triggers @@ -105,62 +144,116 @@ CONF_AUTO_CONNECT = "auto_connect" MULTI_CONF = True -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 - ), - } - ), - } +# 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_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 + ), + } + ), + } +).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, esp32_ble_tracker + + return cv.All( + _COMMON_SCHEMA.extend( + { + 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), + esp32_ble.consume_connection_slots(1, "ble_client"), ) - .extend(cv.COMPONENT_SCHEMA) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA), - esp32_ble.consume_connection_slots(1, "ble_client"), -) + + +@functools.cache +def _gatt_config_schema() -> cv.All: + """The neutral engine's schema: the shared keys plus the hub reference + (parsed-advertisement sightings) and the GATT backend declaration.""" + return cv.All( + _COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend( + bluetooth_connection.gatt_client_schema() + ), + bluetooth_connection.consume_gatt_slot("ble_client"), + ) + + +@schema_extractor("schema") +def _validate_platform(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + # The language-schema dumper runs without a platform; expose the esp32 + # shape (the superset). + return _esp32_config_schema() + if CORE.is_esp32: + return _esp32_config_schema()(config) + if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS: + return _gatt_config_schema()(config) + raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}") + + +CONFIG_SCHEMA = _validate_platform CONF_BLE_CLIENT_ID = "ble_client_id" + +def _legacy_engine_only(value: ID) -> ID: + # The raw-gattc node family runs on the legacy esp32 engine; this is the + # one choke point for every component that has not migrated to the + # neutral node interface yet. + if not CORE.is_esp32: + raise cv.Invalid( + "This component requires the ESP32 ble_client engine and has not " + "been migrated to the platform-neutral client yet" + ) + return value + + BLE_CLIENT_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient), + cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All( + cv.use_id(BLEClient), _legacy_engine_only + ), } ) @@ -170,11 +263,20 @@ async def register_ble_node(var, config): cg.add(parent.register_ble_node(var)) +def _esp32_only_action(name: str): + def validator(config: ConfigType) -> ConfigType: + if not CORE.is_esp32: + raise cv.Invalid(f"{name} is only supported on esp32") + return config + + return validator + + BLE_WRITE_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEClient), - cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, - cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid, + cv.Required(CONF_SERVICE_UUID): bt_uuid, + cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid, cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)), } ) @@ -185,25 +287,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id( } ) -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_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEClient), + cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean), + } + ), + _esp32_only_action("ble_client.numeric_comparison_reply"), ) -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_PASSKEY_REPLY_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEClient), + cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)), + } + ), + _esp32_only_action("ble_client.passkey_reply"), ) -BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_ID): cv.use_id(BLEClient), - } +BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEClient), + } + ), + _esp32_only_action("ble_client.remove_bond"), ) @@ -251,38 +362,20 @@ 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(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]) + 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]) cg.add(var.set_service_uuid128(uuid128)) - 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] - ) + 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]) cg.add(var.set_char_uuid128(uuid128)) return var @@ -339,7 +432,10 @@ 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(config): +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 + # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) cg.add_define("USE_ESP32_BLE_UUID") @@ -347,6 +443,25 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await esp32_ble_tracker.register_client(var, config) + return var + + +async def _to_code_gatt(config: ConfigType) -> cg.MockObj: + 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 CORE.is_esp32: + 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, []): diff --git a/esphome/components/ble_client/output/__init__.py b/esphome/components/ble_client/output/__init__.py index 22a6b29442..c2405c04fa 100644 --- a/esphome/components/ble_client/output/__init__.py +++ b/esphome/components/ble_client/output/__init__.py @@ -13,7 +13,7 @@ BLEBinaryOutput = ble_client_ns.class_( "BLEBinaryOutput", output.BinaryOutput, ble_client.BLEClientNode, cg.Component ) -CONFIG_SCHEMA = cv.All( +_CONFIG_SCHEMA = cv.All( output.BINARY_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(BLEBinaryOutput), @@ -66,3 +66,7 @@ async def to_code(config): await output.register_output(var, config) await ble_client.register_ble_node(var, config) await cg.register_component(var, config) + + +# Raw-gattc node platform: not yet migrated to the neutral ble_client engine. +CONFIG_SCHEMA = cv.All(cv.only_on_esp32, _CONFIG_SCHEMA) diff --git a/esphome/components/ble_client/sensor/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 7764955d89..0d6e7b5397 100644 --- a/esphome/components/ble_client/sensor/__init__.py +++ b/esphome/components/ble_client/sensor/__init__.py @@ -50,7 +50,7 @@ def checkType(value): return value -CONFIG_SCHEMA = cv.All( +_CONFIG_SCHEMA = cv.All( checkType, cv.typed_schema( { @@ -164,3 +164,7 @@ async def to_code(config): await rssi_sensor_to_code(config) elif config[CONF_TYPE] == TYPE_CHARACTERISTIC: await characteristic_sensor_to_code(config) + + +# Raw-gattc node platform: not yet migrated to the neutral ble_client engine. +CONFIG_SCHEMA = cv.All(cv.only_on_esp32, _CONFIG_SCHEMA) diff --git a/esphome/components/ble_client/switch/__init__.py b/esphome/components/ble_client/switch/__init__.py index 70314e8f30..d180518090 100644 --- a/esphome/components/ble_client/switch/__init__.py +++ b/esphome/components/ble_client/switch/__init__.py @@ -9,7 +9,7 @@ BLEClientSwitch = ble_client_ns.class_( "BLEClientSwitch", switch.Switch, cg.Component, ble_client.BLEClientNode ) -CONFIG_SCHEMA = ( +_CONFIG_SCHEMA = ( switch.switch_schema(BLEClientSwitch, icon=ICON_BLUETOOTH, block_inverted=True) .extend(ble_client.BLE_CLIENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA) @@ -20,3 +20,7 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) + + +# Raw-gattc node platform: not yet migrated to the neutral ble_client engine. +CONFIG_SCHEMA = cv.All(cv.only_on_esp32, _CONFIG_SCHEMA) diff --git a/esphome/components/ble_client/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 820f60845d..ed2e05107c 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -33,7 +33,7 @@ BLETextSensorNotifyTrigger = ble_client_ns.class_( "BLETextSensorNotifyTrigger", automation.Trigger.template(cg.std_string) ) -CONFIG_SCHEMA = cv.All( +_CONFIG_SCHEMA = cv.All( text_sensor.text_sensor_schema(BLETextSensor) .extend( { @@ -109,3 +109,7 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await ble_client.register_ble_node(trigger, config) await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + + +# Raw-gattc node platform: not yet migrated to the neutral ble_client engine. +CONFIG_SCHEMA = cv.All(cv.only_on_esp32, _CONFIG_SCHEMA) diff --git a/tests/component_tests/ble_client/test_validation.py b/tests/component_tests/ble_client/test_validation.py index 1865812b74..f229b1c5a0 100644 --- a/tests/component_tests/ble_client/test_validation.py +++ b/tests/component_tests/ble_client/test_validation.py @@ -19,9 +19,21 @@ from esphome.const import ( CONF_NOTIFY, CONF_SERVICE_UUID, CONF_TYPE, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, ) +from esphome.core import CORE from esphome.types import ConfigType + +@pytest.fixture(autouse=True) +def esp32_platform() -> None: + # The node platforms gate on only_on_esp32 now (the neutral engine has no + # raw-gattc nodes); these schema tests exercise the esp32 arm. + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP32 + + DESCRIPTOR_CONFIG: ConfigType = { CONF_NAME: "test", CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", diff --git a/tests/components/ble_client/common-gatt.yaml b/tests/components/ble_client/common-gatt.yaml new file mode 100644 index 0000000000..88e5b7669d --- /dev/null +++ b/tests/components/ble_client/common-gatt.yaml @@ -0,0 +1,24 @@ +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}; diff --git a/tests/components/ble_client/test.rp2040-ard.yaml b/tests/components/ble_client/test.rp2040-ard.yaml new file mode 100644 index 0000000000..22b5206286 --- /dev/null +++ b/tests/components/ble_client/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +# 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: