diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index c0837da402..55a2f9bd18 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -7,6 +7,7 @@ from esphome.components.uart import ( uart_ns, ) from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_DEBUG, @@ -61,6 +62,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT_NUS", True) + request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU expose_log = config[CONF_TYPE] == CONF_LOGS cg.add(var.set_expose_log(expose_log)) if expose_log: diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index a012e832c7..2493bd9f95 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from esphome import automation import esphome.codegen as cg from esphome.components.nrf52.framework import ( @@ -7,9 +9,48 @@ from esphome.components.nrf52.framework import ( from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +# BLE LE Data Length Extension maximum LL PDU payload (BLE spec) +_DLE_MAX_PDU = 251 + +DOMAIN = "zephyr_ble_server" + + +@dataclass +class _BLEServerData: + requested_l2cap_mtu: int = 0 + mtu_emitted: bool = False + + +def _get_data() -> _BLEServerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _BLEServerData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_ble_mtu() -> None: + data = _get_data() + data.mtu_emitted = True + mtu = data.requested_l2cap_mtu + if mtu > 0: + zephyr_add_prj_conf("BT_L2CAP_TX_MTU", mtu) + zephyr_add_prj_conf("BT_BUF_ACL_TX_SIZE", min(mtu + 4, _DLE_MAX_PDU)) + zephyr_add_prj_conf("BT_BUF_ACL_RX_SIZE", mtu + 4) + + +def request_ble_l2cap_mtu(l2cap_mtu: int) -> None: + """Request a minimum BLE L2CAP MTU. The maximum of all callers wins.""" + data = _get_data() + if data.mtu_emitted: + raise RuntimeError("BLE L2CAP MTU requested after it was written to prj.conf") + if data.requested_l2cap_mtu == 0: + CORE.add_job(_emit_ble_mtu) + data.requested_l2cap_mtu = max(data.requested_l2cap_mtu, l2cap_mtu) + + zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -51,6 +92,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("BT_SETTINGS", True) zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 109d1f7dd6..9fc02d8708 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -14,6 +14,7 @@ from esphome.components.zephyr.const import ( KEY_BOOTLOADER, KEY_SYSBUILD, ) +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_HARDWARE_UART, @@ -144,6 +145,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) + request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: uart = UARTS[transport[CONF_HARDWARE_UART]] uart_name = uart[0] diff --git a/tests/component_tests/zephyr_ble_server/__init__.py b/tests/component_tests/zephyr_ble_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/zephyr_ble_server/config/no_request.yaml b/tests/component_tests/zephyr_ble_server/config/no_request.yaml new file mode 100644 index 0000000000..dcf79033ec --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/no_request.yaml @@ -0,0 +1,8 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +zephyr_ble_server: diff --git a/tests/component_tests/zephyr_ble_server/config/nus.yaml b/tests/component_tests/zephyr_ble_server/config/nus.yaml new file mode 100644 index 0000000000..63129069d2 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus.yaml @@ -0,0 +1,11 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +ble_nus: + type: logs diff --git a/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml new file mode 100644 index 0000000000..d62247dc54 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml @@ -0,0 +1,18 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ble_nus: + type: logs + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py new file mode 100644 index 0000000000..af0c4112b0 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py @@ -0,0 +1,52 @@ +"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF +from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu + +MTU_KEYS = ( + "CONFIG_BT_L2CAP_TX_MTU", + "CONFIG_BT_BUF_ACL_TX_SIZE", + "CONFIG_BT_BUF_ACL_RX_SIZE", +) + + +def _mtu_values() -> tuple[int | None, ...]: + prj_conf = zephyr_data()[KEY_PRJ_CONF][""] + return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS) + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("no_request.yaml", (None, None, None)), + ("nus.yaml", (247, 251, 251)), + # The largest request wins; only the TX buffer is capped at 251 + ("nus_mcumgr.yaml", (498, 251, 502)), + ], +) +def test_l2cap_mtu_prj_conf( + fixture: str, + expected: tuple[int | None, ...], + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + assert _mtu_values() == expected + + +@pytest.mark.asyncio +async def test_request_after_emit_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("no_request.yaml")) + request_ble_l2cap_mtu(247) + await _emit_ble_mtu() + with pytest.raises(RuntimeError, match="after it was written"): + request_ble_l2cap_mtu(498)