mirror of
https://github.com/esphome/esphome.git
synced 2026-08-18 03:39:57 +08:00
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: storage split <ai@local> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1075 lines
40 KiB
Python
1075 lines
40 KiB
Python
"""
|
|
Test ESP32 configuration
|
|
"""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from esphome.components.esp32 import (
|
|
KEY_FATFS_REQUIRED,
|
|
KEY_VFS_DIR_REQUIRED,
|
|
KEY_VFS_SELECT_REQUIRED,
|
|
KEY_VFS_TERMIOS_REQUIRED,
|
|
VARIANT_ESP32,
|
|
VARIANTS,
|
|
NetworkSdkconfigData,
|
|
_ota_downgrade_protection_errors,
|
|
_reconcile_network_sdkconfig,
|
|
_reconcile_vfs_fatfs_sdkconfig,
|
|
)
|
|
from esphome.components.esp32.const import (
|
|
KEY_ESP32,
|
|
KEY_NETWORK_SDKCONFIG,
|
|
KEY_SDKCONFIG_OPTIONS,
|
|
KEY_VARIANT,
|
|
)
|
|
from esphome.components.esp32.gpio import validate_gpio_pin
|
|
import esphome.config_validation as cv
|
|
from esphome.const import (
|
|
CONF_ESPHOME,
|
|
CONF_IGNORE_PIN_VALIDATION_ERROR,
|
|
CONF_NUMBER,
|
|
PlatformFramework,
|
|
Toolchain,
|
|
)
|
|
from esphome.core import CORE
|
|
from tests.component_tests.types import SetCoreConfigCallable
|
|
|
|
|
|
def test_esp32_config(
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
set_core_config(PlatformFramework.ESP32_IDF)
|
|
|
|
from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY
|
|
|
|
# Example ESP32 configuration
|
|
config = {
|
|
"board": "esp32dev",
|
|
"variant": VARIANT_ESP32,
|
|
"cpu_frequency": "240MHz",
|
|
"flash_size": "4MB",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
},
|
|
}
|
|
|
|
# Check if the variant is valid
|
|
config = CONFIG_SCHEMA(config)
|
|
assert config["variant"] == VARIANT_ESP32
|
|
|
|
# Check that defining a variant sets the board name correctly.
|
|
# Run under the ESP-IDF toolchain so variants without an entry in
|
|
# STANDARD_BOARDS (S31, H4, H21) still derive a board name from
|
|
# VARIANT_FRIENDLY rather than failing with cv.Invalid. CORE.toolchain
|
|
# gets pinned by the first CONFIG_SCHEMA() call above (via
|
|
# _resolve_toolchain) and that pinned value wins over the dict's
|
|
# CONF_TOOLCHAIN, so clear it between iterations to mirror a fresh
|
|
# config run.
|
|
for variant in VARIANTS:
|
|
CORE.toolchain = None
|
|
config = CONFIG_SCHEMA(
|
|
{
|
|
"variant": variant,
|
|
"toolchain": Toolchain.ESP_IDF.value,
|
|
}
|
|
)
|
|
assert VARIANT_FRIENDLY[variant].lower() in config["board"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config_toolchain", "expected"),
|
|
[
|
|
# No `toolchain:` set -> the new default for esp32.
|
|
(None, Toolchain.ESP_IDF),
|
|
# An explicit `toolchain:` still wins over the default.
|
|
(Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO),
|
|
(Toolchain.ESP_IDF.value, Toolchain.ESP_IDF),
|
|
],
|
|
)
|
|
def test_esp32_default_toolchain_is_esp_idf(
|
|
set_core_config: SetCoreConfigCallable,
|
|
config_toolchain: str | None,
|
|
expected: Toolchain,
|
|
) -> None:
|
|
"""With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves
|
|
to the ESP-IDF toolchain; an explicit `toolchain:` still wins."""
|
|
set_core_config(PlatformFramework.ESP32_IDF)
|
|
|
|
from esphome.components.esp32 import CONFIG_SCHEMA
|
|
|
|
# Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain.
|
|
CORE.toolchain = None
|
|
config: dict[str, Any] = {"variant": VARIANT_ESP32}
|
|
if config_toolchain is not None:
|
|
config["toolchain"] = config_toolchain
|
|
|
|
CONFIG_SCHEMA(config)
|
|
|
|
assert CORE.toolchain == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"config_toolchain",
|
|
[Toolchain.SDK_NRF.value, "nonsense"],
|
|
)
|
|
def test_esp32_rejects_unsupported_toolchains(
|
|
set_core_config: SetCoreConfigCallable,
|
|
config_toolchain: str,
|
|
) -> None:
|
|
"""Toolchains esp32 does not support are rejected at validation time."""
|
|
set_core_config(PlatformFramework.ESP32_IDF)
|
|
|
|
from esphome.components.esp32 import CONFIG_SCHEMA
|
|
|
|
CORE.toolchain = None
|
|
with pytest.raises(cv.Invalid, match="Unknown value"):
|
|
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config", "error_match"),
|
|
[
|
|
pytest.param(
|
|
{"flash_size": "4MB"},
|
|
r"This board is unknown, if you are sure you want to compile with this board selection, override with option 'variant' @ data\['board'\]",
|
|
id="unknown_board_config",
|
|
),
|
|
pytest.param(
|
|
{"variant": "esp32xx"},
|
|
r"Unknown value 'ESP32XX', did you mean 'ESP32', 'ESP32S3', 'ESP32S2'\? for dictionary value @ data\['variant'\]",
|
|
id="unknown_variant_config",
|
|
),
|
|
pytest.param(
|
|
{"variant": "esp32s3", "board": "esp32dev"},
|
|
r"Option 'variant' does not match selected board. @ data\['variant'\]",
|
|
id="mismatched_board_variant_config",
|
|
),
|
|
pytest.param(
|
|
{"variant": "esp32s31", "toolchain": Toolchain.PLATFORMIO.value},
|
|
r"No default board is known for ESP32S31\. Please specify the `board:` option explicitly\. @ data\['variant'\]",
|
|
id="variant_without_default_board_requires_explicit_board_under_platformio",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32s2",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"execute_from_psram": True},
|
|
},
|
|
},
|
|
r"'execute_from_psram' is not available on this esp32 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
|
|
id="execute_from_psram_invalid_for_variant_config",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32s3",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"execute_from_psram": True},
|
|
},
|
|
},
|
|
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
|
|
id="execute_from_psram_requires_psram_s3_config",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32p4",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"execute_from_psram": True},
|
|
},
|
|
},
|
|
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
|
|
id="execute_from_psram_requires_psram_p4_config",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32s3",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"ignore_efuse_mac_crc": True},
|
|
},
|
|
},
|
|
r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]",
|
|
id="ignore_efuse_mac_crc_only_on_esp32",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32",
|
|
"board": "esp32dev",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"nvs_encryption": {"key_id": 0}},
|
|
},
|
|
},
|
|
r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]",
|
|
id="nvs_encryption_unsupported_on_esp32",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"variant": "esp32s3",
|
|
"framework": {
|
|
"type": "esp-idf",
|
|
"advanced": {"nvs_encryption": {"key_id": 6}},
|
|
},
|
|
},
|
|
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
|
|
id="nvs_encryption_key_id_out_of_range",
|
|
),
|
|
],
|
|
)
|
|
def test_esp32_configuration_errors(
|
|
config: Any,
|
|
error_match: str,
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
set_core_config(PlatformFramework.ESP32_IDF, full_config={CONF_ESPHOME: {}})
|
|
"""Test detection of invalid configuration."""
|
|
from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
|
|
|
with pytest.raises(cv.Invalid, match=error_match):
|
|
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
|
|
|
|
|
|
def test_execute_from_psram_s3_sdkconfig(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test that execute_from_psram on ESP32-S3 sets the correct sdkconfig options."""
|
|
generate_main(component_config_path("execute_from_psram_s3.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_SPIRAM_FETCH_INSTRUCTIONS") is True
|
|
assert sdkconfig.get("CONFIG_SPIRAM_RODATA") is True
|
|
assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig
|
|
|
|
|
|
def test_execute_from_psram_p4_sdkconfig(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test that execute_from_psram on ESP32-P4 sets the correct sdkconfig options."""
|
|
generate_main(component_config_path("execute_from_psram_p4.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True
|
|
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
|
|
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
|
|
|
|
|
|
def test_nvs_encryption_sdkconfig(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test that nvs_encryption sets the HMAC scheme sdkconfig options."""
|
|
generate_main(component_config_path("nvs_encryption_s3.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True
|
|
assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True
|
|
assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0
|
|
# The permanent/irreversible eFuse burn is warned about at config time.
|
|
assert "PERMANENT and IRREVERSIBLE" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fixture", "multi_key", "idf_on_update"),
|
|
[
|
|
# Externally-signed RSA with a declared trusted-key list hands
|
|
# verification to ESPHome's multi-key verifier, so IDF's single-block
|
|
# on-update check must be OFF. It defaults ON under
|
|
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False
|
|
# explicitly -- not merely omitted.
|
|
("signed_ota_verification_keys_s3.yaml", True, False),
|
|
# Externally-signed RSA without a trusted-key list has no trust anchor,
|
|
# so it falls back to IDF's built-in check.
|
|
("signed_ota_external_rsa_s3.yaml", False, True),
|
|
# Build-time signing and the other schemes keep IDF's check.
|
|
("signed_ota_signing_key_s3.yaml", False, True),
|
|
("signed_ota_ecdsa256_c6.yaml", False, True),
|
|
("signed_ota_ecdsa_v1.yaml", False, True),
|
|
],
|
|
)
|
|
def test_signed_ota_verification_sdkconfig(
|
|
fixture: str,
|
|
multi_key: bool,
|
|
idf_on_update: bool,
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Only external RSA disables IDF's on-update check and uses ESPHome's verifier."""
|
|
generate_main(component_config_path(fixture))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
# The padded, externally-signable image is always produced.
|
|
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True
|
|
# Explicit value (never left to the Kconfig default) decides who verifies.
|
|
assert (
|
|
sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update
|
|
)
|
|
defines = {define.name for define in CORE.defines}
|
|
assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key
|
|
if multi_key:
|
|
# The padding / reserved signature sector the verifier depends on keys
|
|
# off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS
|
|
# (which the explicit `n` above drives to n). Pin the real dependency.
|
|
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True
|
|
# The compiled-in trust anchor: the fixture lists one key.
|
|
define_values = {define.name: str(define.value) for define in CORE.defines}
|
|
assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1"
|
|
assert "OTA_TRUSTED_KEY_DIGESTS" in define_values
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fixture", "expect_warning"),
|
|
[
|
|
("psram_quad_gpio34.yaml", False),
|
|
("psram_octal_gpio34.yaml", True),
|
|
("psram_octal_disabled_gpio34.yaml", False),
|
|
],
|
|
)
|
|
def test_s3_psram_pin_warning_only_for_octal(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
caplog: pytest.LogCaptureFixture,
|
|
fixture: str,
|
|
expect_warning: bool,
|
|
) -> None:
|
|
"""GPIO33-37 are only used by the PSRAM interface in octal mode.
|
|
|
|
Using such a pin must only warn when octal PSRAM is configured; on quad
|
|
PSRAM the pins are free and warning would be a false positive (#16857).
|
|
"""
|
|
with caplog.at_level("WARNING"):
|
|
generate_main(component_config_path(fixture))
|
|
warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text
|
|
assert warned == expect_warning
|
|
|
|
|
|
def test_ignore_pin_validation_error_on_clean_pin_warns(
|
|
set_core_config: SetCoreConfigCallable,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A pin that passes validation but sets `ignore_pin_validation_error: true`
|
|
should log a warning nudging the user to remove the flag, and not raise."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
|
)
|
|
|
|
pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
|
with caplog.at_level("WARNING"):
|
|
result = validate_gpio_pin(pin)
|
|
|
|
assert result[CONF_NUMBER] == 4
|
|
assert "GPIO4 has no validation errors to ignore" in caplog.text
|
|
|
|
|
|
def test_ignore_pin_validation_error_on_dirty_pin_suppresses(
|
|
set_core_config: SetCoreConfigCallable,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A pin that fails validation with `ignore_pin_validation_error: true` should
|
|
log the suppression warning and not raise (existing behavior)."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
|
)
|
|
|
|
# GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid
|
|
pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
|
with caplog.at_level("WARNING"):
|
|
result = validate_gpio_pin(pin)
|
|
|
|
assert result[CONF_NUMBER] == 6
|
|
assert "Ignoring validation error on pin 6" in caplog.text
|
|
|
|
|
|
def test_dirty_pin_without_ignore_flag_raises(
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""A pin that fails validation without the ignore flag should still raise."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
|
)
|
|
|
|
pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
|
with pytest.raises(cv.Invalid, match="flash interface"):
|
|
validate_gpio_pin(pin)
|
|
|
|
|
|
def test_clean_pin_without_ignore_flag_does_not_warn(
|
|
set_core_config: SetCoreConfigCallable,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A clean pin without the ignore flag should pass silently."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32}
|
|
)
|
|
|
|
pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
|
with caplog.at_level("WARNING"):
|
|
result = validate_gpio_pin(pin)
|
|
|
|
assert result[CONF_NUMBER] == 4
|
|
assert "has no validation errors to ignore" not in caplog.text
|
|
|
|
|
|
def test_execute_from_psram_disabled_sdkconfig(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test that without execute_from_psram, no XIP sdkconfig options are set."""
|
|
generate_main(component_config_path("execute_from_psram_disabled.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
|
|
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
|
|
assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig
|
|
|
|
|
|
def test_platformio_idf_enables_reproducible_build(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test PlatformIO ESP-IDF builds enable reproducible app metadata."""
|
|
generate_main(component_config_path("reproducible_build.yaml"))
|
|
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
|
|
|
|
|
|
def test_platformio_arduino_enables_reproducible_build(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test PlatformIO Arduino builds enable reproducible app metadata."""
|
|
generate_main(component_config_path("reproducible_build_arduino.yaml"))
|
|
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
|
|
|
|
|
|
def test_native_idf_enables_reproducible_build(
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test native ESP-IDF builds enable reproducible app metadata."""
|
|
from esphome.__main__ import generate_cpp_contents
|
|
from esphome.config import read_config
|
|
|
|
CORE.config_path = component_config_path("reproducible_build.yaml")
|
|
CORE.config = read_config({})
|
|
CORE.toolchain = Toolchain.ESP_IDF
|
|
generate_cpp_contents(CORE.config)
|
|
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
|
|
|
|
|
|
def test_flash_mode_sets_sdkconfig_and_pio_option(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""flash_mode/flash_frequency select the esptool flash parameters on both backends."""
|
|
generate_main(component_config_path("flash_mode_idf.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
|
|
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
|
|
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
|
|
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
|
|
|
|
|
|
def test_flash_mode_unset_leaves_defaults(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Without flash_mode the board/sdkconfig defaults stay untouched."""
|
|
generate_main(component_config_path("flash_mode_default.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig)
|
|
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
|
|
assert "board_build.flash_mode" not in CORE.platformio_options
|
|
assert "board_build.f_flash" not in CORE.platformio_options
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("framework", "net", "preset", "expected"),
|
|
[
|
|
# --- IDF: single-interface cases (must match pre-refactor behavior) ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(wifi=True),
|
|
{},
|
|
{
|
|
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
|
|
"CONFIG_LWIP_DHCPS": False,
|
|
},
|
|
id="idf_wifi_no_ap",
|
|
),
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(wifi=True, wifi_ap=True),
|
|
{},
|
|
{},
|
|
id="idf_wifi_ap_leaves_softap_dhcps",
|
|
),
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(ethernet=True),
|
|
{},
|
|
{
|
|
"CONFIG_ESP_WIFI_ENABLED": False,
|
|
"CONFIG_SW_COEXIST_ENABLE": False,
|
|
},
|
|
id="idf_ethernet_only",
|
|
),
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
|
|
{},
|
|
{
|
|
"CONFIG_BT_ENABLED": True,
|
|
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
|
|
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
|
|
"CONFIG_SW_COEXIST_ENABLE": True,
|
|
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
|
|
"CONFIG_LWIP_DHCPS": False,
|
|
},
|
|
id="idf_wifi_ble_tracker_coexistence",
|
|
),
|
|
# --- IDF: user sdkconfig_options always win ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(wifi=True),
|
|
{"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True},
|
|
{
|
|
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True,
|
|
"CONFIG_LWIP_DHCPS": False,
|
|
},
|
|
id="idf_user_override_wins",
|
|
),
|
|
# --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(
|
|
wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False
|
|
),
|
|
{},
|
|
{"CONFIG_LWIP_DHCPS": False},
|
|
id="idf_user_disables_dhcps_with_ap",
|
|
),
|
|
# --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_IDF,
|
|
NetworkSdkconfigData(wifi=True, ethernet=True),
|
|
{},
|
|
{
|
|
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
|
|
"CONFIG_LWIP_DHCPS": False,
|
|
},
|
|
id="idf_wifi_and_ethernet_keeps_wifi_enabled",
|
|
),
|
|
# --- Arduino: SoftAP/DHCPS disable is IDF-only ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_ARDUINO,
|
|
NetworkSdkconfigData(wifi=True),
|
|
{},
|
|
{},
|
|
id="arduino_wifi_no_ap_untouched",
|
|
),
|
|
pytest.param(
|
|
PlatformFramework.ESP32_ARDUINO,
|
|
NetworkSdkconfigData(ethernet=True),
|
|
{},
|
|
{
|
|
"CONFIG_ESP_WIFI_ENABLED": False,
|
|
"CONFIG_SW_COEXIST_ENABLE": False,
|
|
},
|
|
id="arduino_ethernet_only_disables_wifi",
|
|
),
|
|
# --- Arduino + Ethernet: DHCPS stays available even if user disabled it ---
|
|
pytest.param(
|
|
PlatformFramework.ESP32_ARDUINO,
|
|
NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False),
|
|
{},
|
|
{
|
|
"CONFIG_ESP_WIFI_ENABLED": False,
|
|
"CONFIG_SW_COEXIST_ENABLE": False,
|
|
},
|
|
id="arduino_ethernet_dhcps_exclusion",
|
|
),
|
|
],
|
|
)
|
|
def test_reconcile_network_sdkconfig(
|
|
set_core_config: SetCoreConfigCallable,
|
|
framework: PlatformFramework,
|
|
net: NetworkSdkconfigData,
|
|
preset: dict[str, Any],
|
|
expected: dict[str, Any],
|
|
) -> None:
|
|
"""The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence
|
|
sdkconfig flags from the requests recorded in NetworkSdkconfigData."""
|
|
set_core_config(framework)
|
|
CORE.data[KEY_ESP32] = {
|
|
KEY_SDKCONFIG_OPTIONS: dict(preset),
|
|
KEY_NETWORK_SDKCONFIG: net,
|
|
}
|
|
|
|
asyncio.run(_reconcile_network_sdkconfig())
|
|
|
|
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("requires", "fatfs_required", "disables", "preset", "expected"),
|
|
[
|
|
# Nothing required and every disable_* flag off (NOT the shipped defaults, which
|
|
# disable everything): VFS enabled, FATFS left untouched entirely.
|
|
pytest.param(
|
|
{},
|
|
False,
|
|
(False, False, False, False),
|
|
{},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
},
|
|
id="nothing_disabled_nothing_required",
|
|
),
|
|
# The shipped out-of-the-box path: every disable_* flag defaults to True and nothing
|
|
# is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume).
|
|
pytest.param(
|
|
{},
|
|
False,
|
|
(True, True, True, True),
|
|
{},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": False,
|
|
"CONFIG_VFS_SUPPORT_SELECT": False,
|
|
"CONFIG_VFS_SUPPORT_DIR": False,
|
|
"CONFIG_FATFS_LFN_NONE": True,
|
|
"CONFIG_FATFS_VOLUME_COUNT": 1,
|
|
},
|
|
id="all_disabled_fatfs_fallback",
|
|
),
|
|
# A component's require_* beats the user's disable_* flag for every VFS feature.
|
|
pytest.param(
|
|
{
|
|
KEY_VFS_TERMIOS_REQUIRED: True,
|
|
KEY_VFS_SELECT_REQUIRED: True,
|
|
KEY_VFS_DIR_REQUIRED: True,
|
|
},
|
|
False,
|
|
(True, True, True, False),
|
|
{},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
},
|
|
id="require_beats_disable",
|
|
),
|
|
# A user sdkconfig_options preset wins over a require (the set_opt guard).
|
|
pytest.param(
|
|
{KEY_VFS_SELECT_REQUIRED: True},
|
|
False,
|
|
(False, False, False, False),
|
|
{"CONFIG_VFS_SUPPORT_SELECT": False},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": False,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
},
|
|
id="user_preset_wins_over_require",
|
|
),
|
|
# require_fatfs() with no user preset: long filenames on the heap, 255 chars,
|
|
# four volumes.
|
|
pytest.param(
|
|
{},
|
|
True,
|
|
(False, False, False, False),
|
|
{},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
"CONFIG_FATFS_LFN_NONE": False,
|
|
"CONFIG_FATFS_LFN_HEAP": True,
|
|
"CONFIG_FATFS_MAX_LFN": 255,
|
|
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
|
},
|
|
id="fatfs_required_defaults",
|
|
),
|
|
# CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member
|
|
# (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice.
|
|
pytest.param(
|
|
{},
|
|
True,
|
|
(False, False, False, False),
|
|
{"CONFIG_FATFS_LFN_STACK": "y"},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
"CONFIG_FATFS_LFN_STACK": "y",
|
|
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
|
},
|
|
id="fatfs_user_lfn_stack_untouched",
|
|
),
|
|
# disable_fatfs (the shipped default) with a user LFN pick: the choice group is the
|
|
# user's -- no LFN_NONE=y written next to their member, only the volume fallback.
|
|
pytest.param(
|
|
{},
|
|
False,
|
|
(False, False, False, True),
|
|
{"CONFIG_FATFS_LFN_HEAP": "y"},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
"CONFIG_FATFS_LFN_HEAP": "y",
|
|
"CONFIG_FATFS_VOLUME_COUNT": 1,
|
|
},
|
|
id="disable_fatfs_user_lfn_untouched",
|
|
),
|
|
# Same for an explicit LFN_NONE preset: the group is the user's, only the volume
|
|
# count default is added.
|
|
pytest.param(
|
|
{},
|
|
True,
|
|
(False, False, False, False),
|
|
{"CONFIG_FATFS_LFN_NONE": "y"},
|
|
{
|
|
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
|
"CONFIG_VFS_SUPPORT_SELECT": True,
|
|
"CONFIG_VFS_SUPPORT_DIR": True,
|
|
"CONFIG_FATFS_LFN_NONE": "y",
|
|
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
|
},
|
|
id="fatfs_user_lfn_none_untouched",
|
|
),
|
|
],
|
|
)
|
|
def test_reconcile_vfs_fatfs_sdkconfig(
|
|
set_core_config: SetCoreConfigCallable,
|
|
requires: dict[str, bool],
|
|
fatfs_required: bool,
|
|
disables: tuple[bool, bool, bool, bool],
|
|
preset: dict[str, Any],
|
|
expected: dict[str, Any],
|
|
) -> None:
|
|
"""The FINAL-priority reconciler resolves the VFS feature flags and the FATFS
|
|
defaults from the recorded require_* calls, with user sdkconfig_options winning
|
|
and the LFN Kconfig choice treated as one group."""
|
|
set_core_config(PlatformFramework.ESP32_IDF)
|
|
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)}
|
|
if fatfs_required:
|
|
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
|
|
for key, value in requires.items():
|
|
CORE.data[key] = value
|
|
|
|
asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables))
|
|
|
|
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
|
|
|
|
|
|
def test_network_wifi_only_reconciles_end_to_end(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler
|
|
after wifi's request_wifi(), disabling SoftAP support and the DHCP server."""
|
|
generate_main(component_config_path("network_wifi_only.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
|
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
|
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
|
|
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
|
assert "CONFIG_BT_ENABLED" not in sdkconfig
|
|
|
|
|
|
def test_network_ethernet_only_reconciles_end_to_end(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""End-to-end: ethernet's request_ethernet() makes the reconciler disable the
|
|
WiFi stack and coexistence when WiFi is absent."""
|
|
generate_main(component_config_path("network_ethernet_only.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
|
|
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
|
|
|
|
|
|
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to
|
|
BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP)."""
|
|
generate_main(component_config_path("network_wifi_ble_coexistence.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
|
|
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
|
|
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
|
|
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
|
|
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
|
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
|
# WiFi present alongside BT -> WiFi stack must stay enabled.
|
|
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
|
|
|
|
|
def test_network_wifi_ethernet_priority_keeps_wifi_enabled(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_config_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""End-to-end: with both WiFi and Ethernet declared under network: priority:,
|
|
the reconciler must NOT disable the WiFi stack or coexistence (the
|
|
multi-interface case unlocked by composing network priority with the
|
|
sdkconfig reconciler)."""
|
|
generate_main(component_config_path("network_wifi_ethernet_priority.yaml"))
|
|
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
|
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
|
assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig
|
|
# WiFi has no AP here, so SoftAP/DHCP server are still dropped.
|
|
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
|
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
|
|
|
|
|
def test_esp32_build_internals_are_yaml_only() -> None:
|
|
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
|
|
|
|
The framework block's PlatformIO package pins, raw ESP-IDF
|
|
sdkconfig options, the low-level ``advanced`` block, extra IDF
|
|
component sources, plus the partition table and toolchain override
|
|
on the main schema are build internals — never UI form fields.
|
|
User-facing choices (framework type/version, board, variant, …)
|
|
stay on the main form.
|
|
"""
|
|
from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA
|
|
|
|
fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema}
|
|
for field in (
|
|
"release",
|
|
"source",
|
|
"platform_version",
|
|
"sdkconfig_options",
|
|
"advanced",
|
|
"components",
|
|
):
|
|
assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field
|
|
# Framework type/version remain user-facing.
|
|
assert fw_markers["type"].visibility is None
|
|
assert fw_markers["version"].visibility is None
|
|
|
|
main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema}
|
|
assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY
|
|
# toolchain is a real but rarely-touched override -> advanced disclosure.
|
|
assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED
|
|
assert main_markers["board"].visibility is None
|
|
assert main_markers["flash_size"].visibility is None
|
|
|
|
|
|
def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None:
|
|
assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == []
|
|
|
|
|
|
def test_downgrade_protection_accepts_calendar_version() -> None:
|
|
assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == []
|
|
|
|
|
|
def test_downgrade_protection_requires_project_version() -> None:
|
|
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True)
|
|
assert len(errs) == 1
|
|
assert "version" in str(errs[0])
|
|
|
|
|
|
def test_downgrade_protection_rejects_non_numeric_version() -> None:
|
|
errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True)
|
|
assert len(errs) == 1
|
|
assert "dotted-numeric" in str(errs[0])
|
|
|
|
|
|
def test_downgrade_protection_requires_signed_ota() -> None:
|
|
errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False)
|
|
assert len(errs) == 1
|
|
assert "signed_ota_verification" in str(errs[0])
|
|
|
|
|
|
def test_downgrade_protection_reports_all_unmet_requirements() -> None:
|
|
# No project version and no signing -> two distinct errors.
|
|
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False)
|
|
assert len(errs) == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"config",
|
|
[
|
|
# V2 schemes: signing key (sign during build) or no key at all
|
|
# (external signing; the public key travels in the signature block).
|
|
{"signing_scheme": "rsa3072", "signing_key": "key.pem"},
|
|
{"signing_scheme": "rsa3072"},
|
|
{"signing_scheme": "ecdsa256", "signing_key": "key.pem"},
|
|
{"signing_scheme": "ecdsa256"},
|
|
# V1 ECDSA: exactly one of signing key / verification key.
|
|
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
|
|
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
|
|
# External RSA with a compiled-in trusted-key list (digests).
|
|
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]},
|
|
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]},
|
|
],
|
|
)
|
|
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
|
|
from esphome.components.esp32 import _validate_signed_ota_keys
|
|
|
|
assert _validate_signed_ota_keys(config) is config
|
|
|
|
|
|
@pytest.mark.parametrize("value", [None, {}])
|
|
def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None:
|
|
"""A bare `signed_ota_verification:` block is valid: the default V2
|
|
scheme embeds the public key in the signature block, so verifying
|
|
externally-signed binaries needs no keys in the config."""
|
|
from esphome.components.esp32 import _validate_signed_ota_verification
|
|
|
|
config = _validate_signed_ota_verification(value)
|
|
assert config == {"signing_scheme": "rsa3072"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config", "match"),
|
|
[
|
|
# A verification key is meaningless with the V2 schemes -- the public
|
|
# key is embedded in each image's signature block.
|
|
(
|
|
{"signing_scheme": "rsa3072", "verification_key": "key.bin"},
|
|
"only used with signing scheme 'ecdsa_v1'",
|
|
),
|
|
(
|
|
{"signing_scheme": "ecdsa256", "verification_key": "key.bin"},
|
|
"only used with signing scheme 'ecdsa_v1'",
|
|
),
|
|
# V1 ECDSA needs a key either way.
|
|
(
|
|
{"signing_scheme": "ecdsa_v1"},
|
|
"Signing scheme 'ecdsa_v1' requires either",
|
|
),
|
|
# Never both keys at once.
|
|
(
|
|
{
|
|
"signing_scheme": "rsa3072",
|
|
"signing_key": "key.pem",
|
|
"verification_key": "key.bin",
|
|
},
|
|
"not both",
|
|
),
|
|
(
|
|
{
|
|
"signing_scheme": "ecdsa_v1",
|
|
"signing_key": "key.pem",
|
|
"verification_key": "key.bin",
|
|
},
|
|
"not both",
|
|
),
|
|
# A trusted-key list only applies to external RSA.
|
|
(
|
|
{"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]},
|
|
"only used with signing scheme 'rsa3072'",
|
|
),
|
|
# Can't both auto-sign and verify against a fixed trusted set.
|
|
(
|
|
{
|
|
"signing_scheme": "rsa3072",
|
|
"signing_key": "key.pem",
|
|
"verification_keys": ["ab" * 32],
|
|
},
|
|
"cannot be combined with",
|
|
),
|
|
# The singular V1 key and the RSA trusted-key list are mutually exclusive.
|
|
(
|
|
{
|
|
"signing_scheme": "rsa3072",
|
|
"verification_key": "key.bin",
|
|
"verification_keys": ["ab" * 32],
|
|
},
|
|
"at most one",
|
|
),
|
|
# Duplicate trusted keys are rejected.
|
|
(
|
|
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]},
|
|
"must be unique",
|
|
),
|
|
],
|
|
)
|
|
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
|
|
from esphome.components.esp32 import _validate_signed_ota_keys
|
|
|
|
with pytest.raises(cv.Invalid, match=match):
|
|
_validate_signed_ota_keys(config)
|
|
|
|
|
|
def test_sbv2_rsa_key_digest_known_answer() -> None:
|
|
"""The compiled-in trust anchor is the block-format digest the device
|
|
computes per signature block; pin it to espsecure's known output for the
|
|
shipped dummy key so a future change to the derivation can't drift silently.
|
|
"""
|
|
from esphome.components.esp32 import _sbv2_rsa_key_digest
|
|
|
|
key = (
|
|
Path(__file__).parent.parent.parent
|
|
/ "components"
|
|
/ "esp32"
|
|
/ "dummy_signing_key.pem"
|
|
)
|
|
assert (
|
|
_sbv2_rsa_key_digest(key).hex()
|
|
== "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65"
|
|
)
|
|
|
|
|
|
def test_validate_trusted_key_hex_forms() -> None:
|
|
"""The digest-input branch: the same key as an uppercase 64-hex digest
|
|
normalizes to the PEM-derived value (the two forms are interchangeable), and
|
|
a mangled digest fails clearly instead of as a missing file.
|
|
"""
|
|
from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key
|
|
|
|
key = (
|
|
Path(__file__).parent.parent.parent
|
|
/ "components"
|
|
/ "esp32"
|
|
/ "dummy_signing_key.pem"
|
|
)
|
|
pem_digest = _sbv2_rsa_key_digest(key).hex()
|
|
assert _validate_trusted_key(pem_digest.upper()) == pem_digest
|
|
for bad in (pem_digest[:-1], "0x" + pem_digest):
|
|
with pytest.raises(cv.Invalid, match="64 hex"):
|
|
_validate_trusted_key(bad)
|
|
# An unquoted 0x.../all-digit digest reaches the validator as a YAML int.
|
|
with pytest.raises(cv.Invalid, match="Quote the digest"):
|
|
_validate_trusted_key(0x957671F5EC1B55B3)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
# Full x.y.z versions are rewritten into pioarduino release URLs
|
|
(
|
|
"55.3.30",
|
|
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip",
|
|
),
|
|
(
|
|
"55.3.31-2",
|
|
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip",
|
|
),
|
|
# Non-version values pass through untouched
|
|
(
|
|
"https://github.com/pioarduino/platform-espressif32.git#develop",
|
|
"https://github.com/pioarduino/platform-espressif32.git#develop",
|
|
),
|
|
],
|
|
)
|
|
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
|