diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bf3092a4e..d45f2d9df2 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -592,7 +612,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) diff --git a/tests/component_tests/esp32_ble_server/__init__.py b/tests/component_tests/esp32_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py new file mode 100644 index 0000000000..88307d0dcf --- /dev/null +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -0,0 +1,47 @@ +"""Tests for esp32_ble_server configuration helpers.""" + +import pytest + +from esphome.components.esp32_ble_server import ( + CCCD_DESCRIPTOR_UUID, + CUD_DESCRIPTOR_UUID, + DEVICE_INFORMATION_SERVICE_UUID, + uuid_is, +) + + +@pytest.mark.parametrize( + "uuid", + [ + DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t) + "180A", # 16 bit short form (bt_uuid) + "180a", # lowercase is normalized by bt_uuid but guard anyway + "0000180A", # 32 bit form + "0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form + ], +) +def test_uuid_is_matches_all_representations(uuid) -> None: + """All representations of the same 16 bit UUID must compare equal.""" + assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize( + "uuid", + [ + 0x1818, # Cycling Power Service (different int) + "1818", # different 16 bit short form + "0000180B", # adjacent UUID + "0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix + ], +) +def test_uuid_is_rejects_other_uuids(uuid) -> None: + """A different UUID must not be mistaken for the device information service.""" + assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID) + + +@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID]) +def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: + """Reserved descriptor UUIDs match whether given as int or short string.""" + assert uuid_is(uuid16, uuid16) + assert uuid_is(f"{uuid16:04X}", uuid16) + assert uuid_is(f"{uuid16:08X}", uuid16)