From 2dfcde477fb0c6ddc201093d8f8b2e8de23abb07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 19:09:45 -0500 Subject: [PATCH] [ble_client] Reject descriptor_uuid combined with notify at validation time (#18109) --- esphome/components/ble_client/__init__.py | 36 ++++++++ .../components/ble_client/sensor/__init__.py | 13 ++- .../ble_client/text_sensor/__init__.py | 15 ++-- tests/component_tests/ble_client/__init__.py | 0 .../ble_client/test_validation.py | 86 +++++++++++++++++++ 5 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/ble_client/__init__.py create mode 100644 tests/component_tests/ble_client/test_validation.py diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 56ac2ea1472..1ef7967fa83 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_MAC_ADDRESS, CONF_NAME, + CONF_NOTIFY, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_SERVICE_UUID, @@ -16,11 +17,46 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import ID +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +CONF_DESCRIPTOR_UUID = "descriptor_uuid" +CONF_ON_NOTIFY = "on_notify" + + +def validate_descriptor_not_notify(config: ConfigType) -> ConfigType: + """Reject descriptor_uuid combined with notify or on_notify. + + BLE descriptors cannot send notifications; only characteristics can, and + ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify. + """ + if CONF_DESCRIPTOR_UUID in config and ( + config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config + ): + raise cv.Invalid( + f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or " + f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove " + f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or " + f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor" + ) + return config + + +def notify_from_on_notify(config: ConfigType) -> ConfigType: + """Enable notifications when an on_notify automation is configured. + + The triggers have no registration path of their own; without notify the + automation would validate but never fire. + """ + if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]: + config = config.copy() + config[CONF_NOTIFY] = True + return config + + ble_client_ns = cg.esphome_ns.namespace("ble_client") BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) BLEClientNode = ble_client_ns.class_("BLEClientNode") diff --git a/esphome/components/ble_client/sensor/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 0975640ece6..7764955d891 100644 --- a/esphome/components/ble_client/sensor/__init__.py +++ b/esphome/components/ble_client/sensor/__init__.py @@ -14,13 +14,16 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" TYPE_CHARACTERISTIC = "characteristic" TYPE_RSSI = "rssi" @@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All( }, lower=True, ), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/esphome/components/ble_client/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 0f53cccdad0..820f60845d8 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -9,13 +9,16 @@ from esphome.const import ( CONF_TRIGGER_ID, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" adv_data_t = cg.std_vector.template(cg.uint8) adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") @@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(ble_client.BLE_CLIENT_SCHEMA), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/tests/component_tests/ble_client/__init__.py b/tests/component_tests/ble_client/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ble_client/test_validation.py b/tests/component_tests/ble_client/test_validation.py new file mode 100644 index 00000000000..1865812b74e --- /dev/null +++ b/tests/component_tests/ble_client/test_validation.py @@ -0,0 +1,86 @@ +"""Tests for ble_client config validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_client import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + notify_from_on_notify, + validate_descriptor_not_notify, +) +from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA +from esphome.components.ble_client.text_sensor import ( + CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA, +) +from esphome.const import ( + CONF_CHARACTERISTIC_UUID, + CONF_NAME, + CONF_NOTIFY, + CONF_SERVICE_UUID, + CONF_TYPE, +) +from esphome.types import ConfigType + +DESCRIPTOR_CONFIG: ConfigType = { + CONF_NAME: "test", + CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_DESCRIPTOR_UUID: "2902", +} + + +def test_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_on_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = { + CONF_NOTIFY: False, + CONF_ON_NOTIFY: [{}], + CONF_DESCRIPTOR_UUID: "2902", + } + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_descriptor_uuid_without_notify_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"} + assert validate_descriptor_not_notify(config) is config + + +def test_notify_without_descriptor_uuid_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: True} + assert validate_descriptor_not_notify(config) is config + + +def test_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + SENSOR_SCHEMA(config) + + +def test_text_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + TEXT_SENSOR_SCHEMA(config) + + +def test_sensor_schema_allows_descriptor_polling() -> None: + assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"}) + + +def test_text_sensor_schema_allows_descriptor_polling() -> None: + assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG)) + + +def test_on_notify_implies_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]} + assert notify_from_on_notify(config)[CONF_NOTIFY] is True + + +def test_notify_unchanged_without_on_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False} + assert notify_from_on_notify(config)[CONF_NOTIFY] is False