From 575a540c9f1e7902a3b1f6d8a676bcb611cdac3d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:19:20 -0400 Subject: [PATCH] [esp32] Fix ESP32-S31 GPIO validation (#18904) --- esphome/components/esp32/gpio_esp32_s31.py | 24 ++++++++------ tests/component_tests/esp32/test_esp32.py | 37 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index d49240723b..c53c32c99c 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -5,11 +5,15 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin -# Per the ESP32-S31 datasheet (page 96): -# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf -_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} -# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source. -_ESP32S31_STRAPPING_PINS: set[int] = {37, 60, 61} +# Per the ESP32-S31 datasheet, the SPI flash and PSRAM interfaces use +# dedicated package pins (SPICS/SPIQ/SPIWP/SPIHD/SPICLK/SPID) outside the +# GPIO matrix, so no GPIOs are reserved for them. GPIO29 and GPIO41 do not +# exist on this chip (SOC_GPIO_VALID_GPIO_MASK excludes them). +# https://documentation.espressif.com/esp32-s31_datasheet_en.html +_ESP32S31_INVALID_PINS: set[int] = {29, 41} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source; +# GPIO36 sets the VDD_SPI voltage. +_ESP32S31_STRAPPING_PINS: set[int] = {36, 37, 60, 61} # LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. _ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} @@ -19,10 +23,8 @@ _LOGGER = logging.getLogger(__name__) def esp32_s31_validate_gpio_pin(value: int) -> int: if value < 0 or value > 61: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)") - if value in _ESP32S31_SPI_FLASH_PINS: - raise cv.Invalid( - f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." - ) + if value in _ESP32S31_INVALID_PINS: + raise cv.Invalid(f"GPIO{value} does not exist on ESP32-S31.") return value @@ -33,6 +35,10 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if num < 0 or num > 61: raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + # Checked here as well so ignore_pin_validation_error cannot bypass it; + # these pins are not bonded and can never work + if num in _ESP32S31_INVALID_PINS: + raise cv.Invalid(f"GPIO{num} does not exist on ESP32-S31.") if is_input: # All ESP32 pins support input mode pass diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index db7ed6b3fc..190f2d2896 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1268,3 +1268,40 @@ def test_parse_pio_platform_version(value: str, expected: str) -> None: from esphome.components.esp32 import _parse_pio_platform_version assert _parse_pio_platform_version(value) == expected + + +def test_esp32_s31_gpio_validation( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """S31: flash uses dedicated pins so GPIO27-33 are normal pins, GPIO29 and + GPIO41 do not exist, and GPIO36 is a strapping pin.""" + from esphome.components.esp32.const import VARIANT_ESP32S31 + from esphome.components.esp32.gpio import validate_supports + from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT + + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S31} + ) + + input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False} + + # Previously reserved for the flash interface, which uses dedicated pins + for num in (27, 28, 31, 32, 33): + pin = {CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + assert validate_gpio_pin(pin)[CONF_NUMBER] == num + + for num in (29, 41): + with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"): + validate_gpio_pin( + {CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + ) + # Also rejected in validate_supports so ignore_pin_validation_error + # cannot bypass it + with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"): + validate_supports({CONF_NUMBER: num, CONF_MODE: input_mode}) + + pin = {CONF_NUMBER: 36, CONF_MODE: input_mode} + with caplog.at_level("WARNING"): + validate_supports(pin) + assert "GPIO36 is a strapping PIN" in caplog.text