diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 317cec8236b..1f12112eea7 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -11,11 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, + get_esp32_variant, idf_version, only_on_variant, ) import esphome.config_validation as cv -from esphome.const import CONF_DEVICES, CONF_ID +from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component @@ -117,12 +118,101 @@ def _final_validate(config: ConfigType) -> ConfigType: if redundant else "narrow its filter so it no longer matches that device", ) + _reconcile_fifo() return config FINAL_VALIDATE_SCHEMA = _final_validate +# S2/S3 full-speed controller: 200 usable 4-byte FIFO lines (EPINFO takes the rest of 256) shared by RX, +# non-periodic TX and periodic TX +FIFO_VARIANTS = (VARIANT_ESP32S2, VARIANT_ESP32S3) +FIFO_LINES = 200 +FIFO_LINE_BYTES = 4 +FIFO_RX_STATUS_LINES = 2 +# (RX, NPTX, PTX) lines ESP-IDF picks for each CONFIG_USB_HOST_HW_BUFFER_BIAS_* option +FIFO_BIAS_SPLITS = { + "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": (152, 16, 32), + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": (34, 16, 150), +} +FIFO_BALANCED_SPLIT = (104, 64, 32) +# Custom split: 64-byte control/bulk, at least 128 bytes each way +FIFO_NPTX_LINES = 16 +FIFO_PTX_MIN_LINES = 32 +FIFO_RX_MIN_LINES = 32 + FIFO_RX_STATUS_LINES +# usb_host_config_t::fifo_settings_custom; USB host is not reliable on older releases +FIFO_CUSTOM_MIN_IDF = cv.Version(5, 5, 0) +KEY_PERIODIC_FIFO = "periodic_fifo" + + +def request_periodic_fifo(in_bytes: int = 0, out_bytes: int = 0) -> None: + """Request FIFO space for the largest periodic IN/OUT packet. Call from CONFIG_SCHEMA validation.""" + requested = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_PERIODIC_FIFO, [0, 0]) + requested[0] = max(requested[0], in_bytes) + requested[1] = max(requested[1], out_bytes) + + +def _fifo_lines(size: int) -> int: + return -(-size // FIFO_LINE_BYTES) + + +def _bias_option() -> str | None: + framework = fv.full_config.get().get("esp32", {}).get(CONF_FRAMEWORK, {}) + options = framework.get("sdkconfig_options", {}) + return next((o for o in FIFO_BIAS_SPLITS if options.get(o) in ("y", True)), None) + + +def fifo_split() -> tuple[int, int, int] | None: + """(RX, NPTX, PTX) lines for the requests, or None when the default split fits them.""" + requested = CORE.data.get(DOMAIN, {}).get(KEY_PERIODIC_FIFO) + if requested is None or get_esp32_variant() not in FIFO_VARIANTS: + return None + in_bytes, out_bytes = requested + rx = _fifo_lines(in_bytes) + FIFO_RX_STATUS_LINES + ptx = _fifo_lines(out_bytes) + bias = _bias_option() + default_rx, _, default_ptx = FIFO_BIAS_SPLITS[bias] if bias else FIFO_BALANCED_SPLIT + if rx <= default_rx and ptx <= default_ptx: + return None + if bias: + raise cv.Invalid( + f"The requested USB packets don't fit the FIFO split {bias} selects; remove it from " + "sdkconfig_options so usb_host can size the FIFO" + ) + rx = max(FIFO_RX_MIN_LINES, rx) + ptx = max(FIFO_PTX_MIN_LINES, ptx) + spare = FIFO_LINES - FIFO_NPTX_LINES - rx - ptx + if spare < 0: + raise cv.Invalid( + f"The requested USB packets need {(rx + ptx + FIFO_NPTX_LINES) * FIFO_LINE_BYTES} bytes of USB FIFO, " + f"but the {get_esp32_variant()} has {FIFO_LINES * FIFO_LINE_BYTES} usable" + ) + # Devices may declare a larger max packet size, so the requested directions share the rest + if in_bytes and out_bytes: + rx += spare // 2 + ptx += spare - spare // 2 + elif out_bytes: + ptx += spare + else: + rx += spare + return rx, FIFO_NPTX_LINES, ptx + + +def _reconcile_fifo() -> None: + if (split := fifo_split()) is None: + return + if idf_version() < FIFO_CUSTOM_MIN_IDF: + raise cv.Invalid( + f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later" + ) + rx, nptx, ptx = split + cg.add_define("USE_USB_HOST_FIFO_CONFIG") + cg.add_define("USB_HOST_FIFO_RX_LINES", rx) + cg.add_define("USB_HOST_FIFO_NPTX_LINES", nptx) + cg.add_define("USB_HOST_FIFO_PTX_LINES", ptx) + + def _set_max_packet_size(config: dict) -> dict: CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[ CONF_MAX_PACKET_SIZE diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 102311348aa..c2a87eeab70 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -9,6 +9,12 @@ namespace esphome::usb_host { void USBHost::setup() { usb_host_config_t config{}; +#ifdef USE_USB_HOST_FIFO_CONFIG + // Sized in Python from component requests + config.fifo_settings_custom.rx_fifo_lines = USB_HOST_FIFO_RX_LINES; + config.fifo_settings_custom.nptx_fifo_lines = USB_HOST_FIFO_NPTX_LINES; + config.fifo_settings_custom.ptx_fifo_lines = USB_HOST_FIFO_PTX_LINES; +#endif if (usb_host_install(&config) != ESP_OK) { this->status_set_error(LOG_STR("usb_host_install failed")); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ed8f7b984a0..931ccf3c789 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -542,6 +542,10 @@ #define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 +#define USE_USB_HOST_FIFO_CONFIG +#define USB_HOST_FIFO_RX_LINES 93 +#define USB_HOST_FIFO_NPTX_LINES 16 +#define USB_HOST_FIFO_PTX_LINES 91 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #define USE_USB_UART_CH34X #define USE_USB_UART_CP210X diff --git a/tests/component_tests/usb_host/__init__.py b/tests/component_tests/usb_host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py new file mode 100644 index 00000000000..8bc12a3a183 --- /dev/null +++ b/tests/component_tests/usb_host/test_fifo.py @@ -0,0 +1,172 @@ +"""Tests for the usb_host FIFO split sized from periodic packet requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_host +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +from esphome.config import Config +from esphome.const import ( + CONF_FRAMEWORK, + KEY_IDF_VERSION, + KEY_VARIANT, + PlatformFramework, +) +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +IDF_5_5 = cv.Version(5, 5, 6) + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, idf: cv.Version = IDF_5_5 +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant, KEY_IDF_VERSION: idf}, + ) + + +def test_no_request_keeps_the_default_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + assert usb_host.fifo_split() is None + + +def test_in_and_out_requests_share_the_spare_lines( + set_core_config: SetCoreConfigCallable, +) -> None: + # 196-byte packets each way, such as 48 kHz 16-bit stereo audio: 49 + 51 lines, plus 84 spare. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=196, out_bytes=196) + rx, nptx, ptx = usb_host.fifo_split() + assert (rx, nptx, ptx) == (93, 16, 91) + assert rx + nptx + ptx == 200 + assert ptx * 4 >= 196 + assert (rx - 2) * 4 >= 196 + + +def test_out_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S2) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() == (34, 16, 150) + + +def test_in_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + # 512 bytes is more than the default 408 bytes of RX. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() == (152, 16, 32) + + +@pytest.mark.parametrize( + ("in_bytes", "out_bytes"), [(0, 0), (196, 0), (408, 128), (0, 128)] +) +def test_requests_that_fit_the_default_keep_it( + set_core_config: SetCoreConfigCallable, in_bytes: int, out_bytes: int +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + assert usb_host.fifo_split() is None + + +def test_requests_that_fit_the_default_need_no_newer_idf( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=cv.Version(5, 4, 2)) + usb_host.request_periodic_fifo(in_bytes=34, out_bytes=128) + usb_host.FINAL_VALIDATE_SCHEMA({}) + assert "USE_USB_HOST_FIFO_CONFIG" not in _defines() + + +def test_requests_keep_the_largest_packet( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=400) + usb_host.request_periodic_fifo(in_bytes=96, out_bytes=196) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [96, 400] + + +def test_only_full_speed_variants_are_sized( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() is None + + +def test_requests_that_do_not_fit_are_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=776, out_bytes=776) + with pytest.raises(cv.Invalid, match="bytes of USB FIFO"): + usb_host.fifo_split() + + +def _defines() -> dict[str, str]: + return {define.name: str(define.value) for define in CORE.defines} + + +def test_final_validate_emits_the_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=196) + usb_host.FINAL_VALIDATE_SCHEMA({}) + defines = _defines() + assert "USE_USB_HOST_FIFO_CONFIG" in defines + assert ( + defines["USB_HOST_FIFO_RX_LINES"], + defines["USB_HOST_FIFO_NPTX_LINES"], + defines["USB_HOST_FIFO_PTX_LINES"], + ) == ("34", "16", "150") + + +def _set_bias(set_core_config: SetCoreConfigCallable, option: str) -> None: + full = Config() + full["esp32"] = {CONF_FRAMEWORK: {"sdkconfig_options": {option: "y"}}} + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3, KEY_IDF_VERSION: IDF_5_5}, + full_config=full, + ) + + +def test_request_that_fits_the_selected_bias_keeps_it( + set_core_config: SetCoreConfigCallable, +) -> None: + # The IN bias leaves RX 152 lines, enough for 512-byte IN packets. + _set_bias(set_core_config, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN") + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() is None + + +@pytest.mark.parametrize( + ("option", "in_bytes", "out_bytes"), + [ + # The IN bias reserves 152 RX lines, leaving no room for more than 128-byte OUT packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", 0, 196), + # The periodic OUT bias reserves 150 PTX lines, leaving no room for more than 128-byte IN packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", 196, 0), + ], +) +def test_request_conflicting_with_the_selected_bias_is_rejected( + set_core_config: SetCoreConfigCallable, option: str, in_bytes: int, out_bytes: int +) -> None: + _set_bias(set_core_config, option) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + with pytest.raises(cv.Invalid, match=f"FIFO split {option} selects"): + usb_host.fifo_split() + + +@pytest.mark.parametrize("idf", [cv.Version(5, 3, 4), cv.Version(5, 4, 3)]) +def test_final_validate_needs_idf_5_5( + set_core_config: SetCoreConfigCallable, idf: cv.Version +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=idf) + usb_host.request_periodic_fifo(out_bytes=196) + with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"): + usb_host.FINAL_VALIDATE_SCHEMA({})