Compare commits

..
2 changed files with 276 additions and 3 deletions

No files matched your search

+121 -3
View File
@@ -1,13 +1,37 @@
"""Pin checks shared by the ESP32 variant validators."""
from collections.abc import Iterable, Mapping, Set as AbstractSet
import logging
from logging import Logger
from types import MappingProxyType
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
from esphome.const import CONF_NUMBER
import esphome.config_validation as cv
from esphome.const import (
CONF_MODE,
CONF_NUMBER,
CONF_OUTPUT,
CONF_PULLDOWN,
CONF_PULLUP,
)
from esphome.pins import check_strapping_pin
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
_NO_PINS: Mapping[int, str] = MappingProxyType({})
_INPUT_ONLY_REJECTED_MODES = (
(CONF_OUTPUT, "does not support output pin mode."),
(CONF_PULLUP, "does not support pullups."),
(CONF_PULLDOWN, "does not support pulldowns."),
)
def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None:
def check_usb_jtag_pin(
num: int, usb_jtag_pins: AbstractSet[int], logger: Logger
) -> None:
if num in usb_jtag_pins:
logger.warning(
"GPIO%d is used by the USB-Serial-JTAG interface."
@@ -17,10 +41,104 @@ def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> Non
def check_usb_jtag_hold(
conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger
conf: dict[str, Any], usb_jtag_pins: AbstractSet[int], logger: Logger
) -> None:
num = conf[CONF_NUMBER]
if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins:
logger.warning(
"GPIO%d cannot hold at low level during wakeup from deep sleep.", num
)
def spi_psram_errors(variant: str, functions: Mapping[int, str]) -> dict[int, str]:
"""Error messages for pins taken by the SPI flash/PSRAM interface."""
return {
num: f"This pin cannot be used on {variant}s and is already used by the"
f" SPI/PSRAM interface (function: {function})"
for num, function in functions.items()
}
def unusable_errors(variant: str, pins: Iterable[int]) -> dict[int, str]:
"""Error messages for pins that are missing from the GPIO matrix."""
return {num: f"The pin GPIO{num} is not usable on {variant}s." for num in pins}
class ESP32PinSpec:
"""Pin facts of one ESP32 variant, with the two validators built from them.
`error_pins` fail pin validation, which `ignore_pin_validation_error` bypasses;
`fatal_pins` and pins above `max_pin` fail both validators, so the option cannot bypass them.
`input_only_label` is inserted after "GPIOn" in the input-only errors.
A plain class, not a dataclass: building a dataclass costs ~0.5 ms of import.
"""
__slots__ = (
"error_pins",
"fatal_pins",
"input_only_label",
"input_only_pins",
"max_pin",
"no_hold_pins",
"strapping_pins",
"usb_jtag_pins",
"warning_pins",
)
def __init__(
self,
*,
max_pin: int,
strapping_pins: frozenset[int],
usb_jtag_pins: frozenset[int] = frozenset(),
error_pins: Mapping[int, str] = _NO_PINS,
fatal_pins: Mapping[int, str] = _NO_PINS,
warning_pins: Mapping[int, str] = _NO_PINS,
input_only_pins: frozenset[int] = frozenset(),
input_only_label: str = "",
no_hold_pins: frozenset[int] = frozenset(),
) -> None:
self.max_pin = max_pin
self.strapping_pins = strapping_pins
self.usb_jtag_pins = usb_jtag_pins
self.error_pins = error_pins
self.fatal_pins = fatal_pins
self.warning_pins = warning_pins
self.input_only_pins = input_only_pins
self.input_only_label = input_only_label
self.no_hold_pins = no_hold_pins
def _check_pin_exists(self, num: int) -> None:
if num < 0 or num > self.max_pin:
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-{self.max_pin})")
if (message := self.fatal_pins.get(num)) is not None:
raise cv.Invalid(message)
def validate_gpio_pin(self, value: int) -> int:
self._check_pin_exists(value)
if (message := self.error_pins.get(value)) is not None:
raise cv.Invalid(message)
if (message := self.warning_pins.get(value)) is not None:
_LOGGER.warning("%s", message)
check_usb_jtag_pin(value, self.usb_jtag_pins, _LOGGER)
return value
def validate_supports(self, value: ConfigType) -> ConfigType:
num = value[CONF_NUMBER]
self._check_pin_exists(num)
hold = value.get(CONF_HOLD_STATE)
if num in self.input_only_pins:
prefix = f"GPIO{num}{self.input_only_label}"
mode = value[CONF_MODE]
for key, problem in _INPUT_ONLY_REJECTED_MODES:
if mode[key]:
raise cv.Invalid(f"{prefix} {problem}", [CONF_MODE, key])
if hold:
raise cv.Invalid(
f"{prefix} is input-only and cannot be held.", [CONF_HOLD_STATE]
)
if hold and num in self.no_hold_pins:
raise cv.Invalid(f"GPIO{num} has no hold function.", [CONF_HOLD_STATE])
check_usb_jtag_hold(value, self.usb_jtag_pins, _LOGGER)
check_strapping_pin(value, self.strapping_pins, _LOGGER)
return value
@@ -0,0 +1,155 @@
"""Tests for the data driven ESP32 pin validators."""
import pytest
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.esp32.gpio_common import (
ESP32PinSpec,
spi_psram_errors,
unusable_errors,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_IGNORE_STRAPPING_WARNING,
CONF_INPUT,
CONF_MODE,
CONF_NUMBER,
CONF_OPEN_DRAIN,
CONF_OUTPUT,
CONF_PULLDOWN,
CONF_PULLUP,
)
from esphome.types import ConfigType
SPEC = ESP32PinSpec(
max_pin=40,
strapping_pins=frozenset({0, 3}),
usb_jtag_pins=frozenset({3, 19}),
error_pins={
**spi_psram_errors("ESP32-X1", {26: "SPICS1"}),
**unusable_errors("ESP32-X1", (22,)),
},
fatal_pins={29: "GPIO29 does not exist on ESP32-X1."},
warning_pins={9: "GPIO9 might be used by flash."},
input_only_pins=frozenset({34, 35}),
input_only_label=" (34-35)",
no_hold_pins=frozenset({20}),
)
def _conf(num: int, hold: bool = False, **mode: bool) -> ConfigType:
return {
CONF_NUMBER: num,
CONF_MODE: {
CONF_INPUT: False,
CONF_OUTPUT: False,
CONF_PULLUP: False,
CONF_PULLDOWN: False,
CONF_OPEN_DRAIN: False,
**mode,
},
CONF_HOLD_STATE: hold,
}
@pytest.mark.parametrize("num", [-1, 41])
def test_out_of_range_rejected_by_both(num: int) -> None:
message = rf"Invalid pin number: {num} \(must be 0-40\)"
with pytest.raises(cv.Invalid, match=message):
SPEC.validate_gpio_pin(num)
with pytest.raises(cv.Invalid, match=message):
SPEC.validate_supports(_conf(num, input=True))
@pytest.mark.parametrize(
("num", "message"),
[
(
26,
(
"This pin cannot be used on ESP32-X1s and is already used by the"
" SPI/PSRAM interface (function: SPICS1)"
),
),
(22, "The pin GPIO22 is not usable on ESP32-X1s."),
],
)
def test_error_pins_only_fail_pin_validation(num: int, message: str) -> None:
with pytest.raises(cv.Invalid) as exc:
SPEC.validate_gpio_pin(num)
assert exc.value.msg == message
SPEC.validate_supports(_conf(num, output=True))
def test_fatal_pins_fail_both_validators() -> None:
for validate, value in (
(SPEC.validate_gpio_pin, 29),
(SPEC.validate_supports, _conf(29, input=True)),
):
with pytest.raises(cv.Invalid, match="GPIO29 does not exist on ESP32-X1."):
validate(value)
def test_warning_and_usb_jtag_pins(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level("WARNING"):
assert SPEC.validate_gpio_pin(9) == 9
assert SPEC.validate_gpio_pin(19) == 19
assert SPEC.validate_gpio_pin(5) == 5
assert caplog.messages == [
"GPIO9 might be used by flash.",
(
"GPIO19 is used by the USB-Serial-JTAG interface."
" Using this pin as GPIO will conflict with USB-Serial-JTAG."
),
]
@pytest.mark.parametrize(
("mode", "problem"),
[
(CONF_OUTPUT, "does not support output pin mode."),
(CONF_PULLUP, "does not support pullups."),
(CONF_PULLDOWN, "does not support pulldowns."),
],
)
def test_input_only_pins_reject_modes(mode: str, problem: str) -> None:
with pytest.raises(cv.Invalid) as exc:
SPEC.validate_supports(_conf(35, input=True, **{mode: True}))
assert exc.value.msg == f"GPIO35 (34-35) {problem}"
assert exc.value.path == [CONF_MODE, mode]
# The same modes are fine on a normal pin
SPEC.validate_supports(_conf(5, input=True, **{mode: True}))
@pytest.mark.parametrize(
("num", "message"),
[
(34, "GPIO34 (34-35) is input-only and cannot be held."),
(20, "GPIO20 has no hold function."),
],
)
def test_pins_that_cannot_hold(num: int, message: str) -> None:
SPEC.validate_supports(_conf(num, input=True))
with pytest.raises(cv.Invalid) as exc:
SPEC.validate_supports(_conf(num, hold=True, input=True))
assert exc.value.msg == message
assert exc.value.path == [CONF_HOLD_STATE]
def test_usb_jtag_hold_and_strapping_warnings(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level("WARNING"):
SPEC.validate_supports(_conf(3, hold=True, output=True))
assert len(caplog.messages) == 2
assert caplog.messages[0] == (
"GPIO3 cannot hold at low level during wakeup from deep sleep."
)
assert caplog.messages[1].startswith("GPIO3 is a strapping pin")
def test_ignore_strapping_warning_on_normal_pin_rejected() -> None:
conf = _conf(5, input=True)
conf[CONF_IGNORE_STRAPPING_WARNING] = True
with pytest.raises(cv.Invalid, match="GPIO5 is not a strapping pin"):
SPEC.validate_supports(conf)