[rp2] Rename rp2040 platform to rp2 (#17145)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
Jesse Hills
2026-07-07 08:20:24 +12:00
committed by GitHub
co-authored by Jonathan Swoboda
parent 9caf431740
commit cdd334284e
153 changed files with 1297 additions and 1062 deletions
+95
View File
@@ -0,0 +1,95 @@
"""Tests for the ``rp2`` target-platform component.
``rp2`` is the canonical name for the Raspberry Pi RP-series target
platform. ``rp2040`` is a deprecated alias declared via
``ALIASES = ["rp2040"]`` on the rp2 component — the framework
(see ``esphome/loader.py`` and ``esphome/config.py``) handles both
Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key
aliasing (via a pre-pass in ``validate_config``), so there is no
hand-rolled shim in ``esphome/components/rp2040/``.
These tests pin down the canonical board helpers; the alias contract
itself (Python imports, YAML key rename, deprecation warning) is covered
by the framework tests under ``tests/unit_tests/``.
"""
def test_board_id_has_wifi_for_known_wifi_board() -> None:
"""``rpipicow`` is the canonical Pico W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipicow") is True
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
"""Plain ``rpipico`` has no CYW43 → False."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico") is False
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico2w") is True
def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
"""Unknown ids fail open so a custom board is not rejected.
The validator falls back to ESPHome's compile-time check; the
helper returning True here means the wizard emits a ``wifi:``
block and any genuinely-unsupported config trips the existing
"no CYW43" guard at compile time.
"""
from esphome.components import rp2
assert rp2.board_id_has_wifi("not-a-real-board-id") is True
def test_rp2_declares_rp2040_as_alias() -> None:
"""The framework-level deprecation hook is on the ``rp2`` component.
The legacy ``rp2040:`` YAML key works because the rp2 component
opts in via ``ALIASES``; without this declaration the rename
framework wouldn't route legacy configs.
"""
from esphome.components import rp2
assert "rp2040" in rp2.ALIASES
assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0"
def test_rp2040_python_import_resolves_to_rp2() -> None:
"""``from esphome.components import rp2040`` must work for external
custom components and external tooling (device-builder, the dashboard
wizard, etc.) that still import from the legacy module path.
The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to
the canonical module — both should be the same object.
"""
from esphome.components import (
rp2,
rp2040, # routed via _AliasFinder
)
assert rp2040 is rp2
def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None:
"""Submodule imports (e.g. ``esphome.components.rp2040.boards``) must
also route to the canonical equivalents — the board-generator script
and the dashboard wizard both rely on this path.
"""
from esphome.components.rp2 import (
boards as rp2_boards,
generate_boards as rp2_generate,
)
from esphome.components.rp2040 import (
boards as rp2040_boards,
generate_boards as rp2040_generate,
)
assert rp2040_boards is rp2_boards
assert rp2040_generate is rp2_generate
@@ -1,92 +0,0 @@
"""Tests for RP2040 component public helpers and variant detection."""
import pytest
from esphome.components.rp2040 import _detect_variant, board_id_has_wifi
from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_VARIANT
def test_board_id_has_wifi_for_known_wifi_board() -> None:
"""``rpipicow`` is the canonical Pico W → True."""
assert board_id_has_wifi("rpipicow") is True
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
"""Plain ``rpipico`` has no CYW43 → False."""
assert board_id_has_wifi("rpipico") is False
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
assert board_id_has_wifi("rpipico2w") is True
def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
"""Unknown ids fail open so a custom board is not rejected.
The validator falls back to ESPHome's compile-time check; the
helper returning True here means the wizard emits a ``wifi:``
block and any genuinely-unsupported config trips the existing
"no CYW43" guard at compile time.
"""
assert board_id_has_wifi("not-a-real-board-id") is True
def test_detect_variant_derives_variant_from_board() -> None:
"""Board alone resolves to the matching variant."""
result = _detect_variant({CONF_BOARD: "rpipicow"})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_derives_variant_from_rp2350_board() -> None:
"""An RP2350 board resolves to ``RP2350``."""
result = _detect_variant({CONF_BOARD: "rpipico2"})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_only_picks_default_board_rp2040() -> None:
"""Variant alone picks Pico W as the canonical RP2040 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2040})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_only_picks_default_board_rp2350() -> None:
"""Variant alone picks Pico 2 W as the canonical RP2350 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2w"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_matching_explicit_variant_passes() -> None:
"""Specifying both a board and the matching variant is allowed."""
result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_mismatched_variant_raises() -> None:
"""Board/variant mismatch must be rejected and name the offending board."""
with pytest.raises(
cv.Invalid, match=r"does not match the selected board 'rpipicow'"
):
_detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350})
def test_detect_variant_unknown_board_without_variant_raises() -> None:
"""Unknown board with no variant tells the user how to recover."""
with pytest.raises(cv.Invalid, match="please specify the chip variant"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
def test_detect_variant_unknown_board_with_variant_passes() -> None:
"""Unknown board + explicit variant is accepted (with a warning)."""
result = _detect_variant(
{CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040}
)
assert result[CONF_BOARD] == "not-a-real-board"
assert result[CONF_VARIANT] == VARIANT_RP2040
@@ -1,4 +1,4 @@
"""Tests for rp2040 generate_boards.py."""
"""Tests for rp2 generate_boards.py."""
from __future__ import annotations
@@ -8,7 +8,7 @@ import textwrap
import pytest
from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins
from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins
PICO_PINS_HEADER = textwrap.dedent("""\
#pragma once
+3 -3
View File
@@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None:
def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None:
"""RP2040 platform routes through ``rp2040.board_id_has_wifi``."""
assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True
assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False
assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True
assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False
def test_has_native_wifi_returns_false_for_nrf52() -> None:
@@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
"""RP2040 without a board id falls open to True (custom-board default)."""
assert has_native_wifi(platform=Platform.RP2040) is True
assert has_native_wifi(platform=Platform.RP2) is True
def _wifi_config(
+196 -8
View File
@@ -39,7 +39,7 @@ from esphome.const import (
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_RP2040,
PLATFORM_RP2,
PLATFORM_RTL87XX,
SCHEDULER_DONT_RUN,
TYPE_GIT,
@@ -438,7 +438,7 @@ def hex_int__valid(value):
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"),
("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"),
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"),
("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"),
("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"),
("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"),
("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"),
("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"),
@@ -469,7 +469,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple)
"esp32_c3": "11",
"esp32_c6": "14",
"esp32_h2": "17",
"rp2040": "20",
"rp2": "20",
"bk72xx": "21",
"rtl87xx": "22",
"ln882x": "23",
@@ -517,7 +517,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple)
("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"),
("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"),
("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"),
("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"),
("arduino", PLATFORM_RP2, "RP2 using arduino framework"),
("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"),
("host", PLATFORM_HOST, "HOST using host framework"),
],
@@ -540,7 +540,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(0, 5, 0),
esp32_arduino=cv.Version(0, 5, 0),
esp8266_arduino=cv.Version(0, 5, 0),
rp2040_arduino=cv.Version(0, 5, 0),
rp2_arduino=cv.Version(0, 5, 0),
bk72xx_arduino=cv.Version(0, 5, 0),
host=cv.Version(0, 5, 0),
extra_message="test 1",
@@ -556,7 +556,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(2, 0, 0),
esp32_arduino=cv.Version(2, 0, 0),
esp8266_arduino=cv.Version(2, 0, 0),
rp2040_arduino=cv.Version(2, 0, 0),
rp2_arduino=cv.Version(2, 0, 0),
bk72xx_arduino=cv.Version(2, 0, 0),
host=cv.Version(2, 0, 0),
extra_message="test 2",
@@ -567,7 +567,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(1, 5, 0),
esp32_arduino=cv.Version(1, 5, 0),
esp8266_arduino=cv.Version(1, 5, 0),
rp2040_arduino=cv.Version(1, 5, 0),
rp2_arduino=cv.Version(1, 5, 0),
bk72xx_arduino=cv.Version(1, 5, 0),
host=cv.Version(1, 5, 0),
max_version=True,
@@ -584,7 +584,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(0, 5, 0),
esp32_arduino=cv.Version(0, 5, 0),
esp8266_arduino=cv.Version(0, 5, 0),
rp2040_arduino=cv.Version(0, 5, 0),
rp2_arduino=cv.Version(0, 5, 0),
bk72xx_arduino=cv.Version(0, 5, 0),
host=cv.Version(0, 5, 0),
max_version=True,
@@ -599,6 +599,194 @@ def test_require_framework_version(framework, platform, message):
)("test")
def _setup_core_for_framework(platform: str, framework: str) -> None:
"""Wire CORE.data with the minimum keys for require_framework_version /
SplitDefault to evaluate without raising KeyError."""
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: platform,
KEY_TARGET_FRAMEWORK: framework,
KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0),
}
def test_only_on_rp2_passes_on_rp2_platform() -> None:
"""``cv.only_on_rp2`` is the canonical family gate. It accepts any value
untouched when the configured platform is rp2."""
_setup_core_for_framework(PLATFORM_RP2, "arduino")
assert cv.only_on_rp2("anything") == "anything"
def test_only_on_rp2_rejects_other_platforms() -> None:
"""The same gate raises ``Invalid`` outside the rp2 platform."""
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
with pytest.raises(Invalid, match="rp2"):
cv.only_on_rp2("anything")
def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None:
"""``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot
warning, dedupes via CORE.data, and delegates to ``only_on_rp2``.
Repeated calls in the same run must not log again."""
import logging
_setup_core_for_framework(PLATFORM_RP2, "arduino")
# Reset the dedupe flag so this test is independent of order.
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
assert cv.only_on_rp2040("ok") == "ok"
first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message]
assert len(first_warnings) == 1
assert "2027.7.0" in first_warnings[0].message
# Second call dedupes — no additional warning is emitted.
assert cv.only_on_rp2040("ok") == "ok"
warnings_after_second = [
r for r in caplog.records if "only_on_rp2040" in r.message
]
assert len(warnings_after_second) == 1
def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None:
"""The deprecation shim must still raise on non-rp2 platforms — it
delegates to ``only_on_rp2``, so the gating behavior is preserved."""
import logging
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
with (
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
pytest.raises(Invalid, match="rp2"),
):
cv.only_on_rp2040("anything")
def test_require_framework_version_esp32_variant_specific_key() -> None:
"""ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over
the base ``esp32_arduino`` key when the configured variant matches."""
from esphome.components.esp32 import KEY_ESP32
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_ESP32,
KEY_TARGET_FRAMEWORK: "arduino",
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
}
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3}
# Variant-specific entry permits this version; base key would reject it.
assert (
cv.require_framework_version(
esp32_arduino=cv.Version(5, 0, 0), # would reject
esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok
)("test")
== "test"
)
def test_require_framework_version_rp2_variant_specific_key() -> None:
"""RP2 variant kwargs (``rp2_2040_arduino``) must win over the base
``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired."""
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_RP2,
KEY_TARGET_FRAMEWORK: "arduino",
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
}
CORE.data["rp2"] = {"variant": "RP2040"}
# Variant key wins — base ``rp2_arduino`` (which would reject) is ignored.
assert (
cv.require_framework_version(
rp2_arduino=cv.Version(5, 0, 0), # would reject
rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok
)("test")
== "test"
)
# Without a variant kwarg the base ``rp2_arduino`` is used (fallback).
CORE.data["rp2"] = {"variant": "RP2350"}
assert (
cv.require_framework_version(
rp2_arduino=cv.Version(1, 0, 0),
)("test")
== "test"
)
def test_split_default_rp2_variant_keys() -> None:
"""``SplitDefault`` resolves ``rp2_<chip>_<framework>`` first, falling
back to ``rp2_<chip>`` and ``rp2_<framework>`` before the base key."""
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_RP2,
KEY_TARGET_FRAMEWORK: "arduino",
}
CORE.data["rp2"] = {"variant": "RP2040"}
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="variant-only",
rp2_2040_arduino="variant-framework",
): str,
}
)
# Most specific (variant + framework) wins.
assert schema({}).get("full") == "variant-framework"
# Drop the most-specific kwarg → variant-only wins.
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="variant-only",
): str,
}
)
assert schema({}).get("full") == "variant-only"
# RP2350 variant — no rp2_2350_* kwargs → fall through to base framework.
CORE.data["rp2"] = {"variant": "RP2350"}
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="not-this",
): str,
}
)
assert schema({}).get("full") == "base-framework"
def test_only_with_single_component_loaded() -> None:
"""Test OnlyWith with single component when component is loaded."""
CORE.loaded_integrations = {"mqtt"}
+30
View File
@@ -591,6 +591,36 @@ class TestEsphomeCore:
assert target.is_esp32 is False
assert target.is_esp8266 is True
def test_is_rp2(self, target):
"""The canonical RP2 family gate flips on for the rp2 platform."""
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
assert target.is_rp2 is True
assert target.is_esp32 is False
assert target.is_esp8266 is False
def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog):
"""``is_rp2040`` is kept as a deprecation shim that returns whatever
``is_rp2`` returns; both must agree across platform values. A
one-shot deprecation warning is emitted on first access and
deduped via ``CORE.data`` for the rest of the run."""
import logging
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
with caplog.at_level(logging.WARNING, logger="esphome.core"):
assert target.is_rp2040 is True
assert target.is_rp2040 == target.is_rp2
warnings = [r for r in caplog.records if "is_rp2040" in r.message]
assert len(warnings) == 1
assert "2027.7.0" in warnings[0].message
# Reset the dedupe so the False-platform branch also runs the shim.
target.data.pop("_core_is_rp2040_deprecated_warned", None)
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.is_rp2040 is False
assert target.is_rp2040 == target.is_rp2
def test_firmware_bin__default(self, target):
"""Default platforms produce <pioenvs>/<name>/firmware.bin."""
target.name = "test-device"
+110 -434
View File
@@ -1,19 +1,13 @@
"""Unit tests for esphome.loader module."""
import ast
import logging
from pathlib import Path
import sys
import textwrap
from types import ModuleType
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
import voluptuous as vol
from esphome import config as esphome_config, config_validation as cv
from esphome.core import CORE
import esphome.loader as loader_mod
from esphome.loader import (
AliasMeta,
ComponentManifest,
@@ -21,6 +15,7 @@ from esphome.loader import (
_build_alias_map,
_read_aliases,
_replace_component_manifest,
get_alias_metadata,
get_component,
)
from tests.testing_helpers import ComponentManifestOverride
@@ -348,17 +343,12 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub
# Component aliases (renamed-platform back-compat)
# ---------------------------------------------------------------------------
#
# These tests pin down the substrate behind `ALIASES = [...]` on component
# `__init__.py` files: the AST scanner, the resulting global alias map, the
# Python-import `sys.meta_path` finder, the `get_component` integration, and
# the YAML pre-pass that rewrites legacy top-level keys.
#
# The framework is component-agnostic, so the integration tests inject a
# synthetic alias map (pointing a fake legacy name at the real `esp32`
# component) rather than depending on any specific renamed component.
# A legacy name that is NOT a real component, used as a synthetic alias.
_FAKE_ALIAS = "esp32_legacy_alias"
# The framework here is the substrate behind `ALIASES = [...]` on component
# `__init__.py` files. These tests pin down the AST scanner, the resulting
# global alias map, the Python-import `sys.meta_path` finder, and the
# integration with `get_component`. The rp2 → rp2040 actual mapping in this
# repo is used as a real-world fixture; other cases use temp dirs / mocks so
# the framework's behavior is testable in isolation.
def _write_component(root: Path, name: str, body: str) -> None:
@@ -383,12 +373,12 @@ def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None:
init.write_text(
textwrap.dedent("""\
ALIASES = ['old']
ALIAS_REMOVAL_VERSION = "2027.6.0"
ALIAS_REMOVAL_VERSION = "2027.7.0"
""")
)
aliases, removal = _read_aliases(init, ast)
assert aliases == ["old"]
assert removal == "2027.6.0"
assert removal == "2027.7.0"
def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None:
@@ -409,28 +399,19 @@ def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> N
assert removal is None
def test_read_aliases_handles_syntax_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
def test_read_aliases_handles_syntax_error(tmp_path: Path) -> None:
"""A broken __init__.py shouldn't crash the alias scanner — it'll
surface as an ImportError elsewhere, but the scanner logs a warning and
yields nothing so other components keep working. The substring pre-filter
only skips files with no ``ALIASES`` token, so this file (which has one)
still reaches the parse."""
surface as an ImportError elsewhere, but the scanner just yields
nothing so other components keep working.
The source must contain the substring ``ALIASES`` so the scanner
actually attempts to parse the file; otherwise the early-return
optimization would short-circuit before reaching the parser and
this test would not exercise the syntax-error branch.
"""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['x']\ndef broken( :\n")
init.write_text("ALIASES = ['oops'\ndef broken( :\n")
assert _read_aliases(init, ast) == ([], None)
assert "Could not parse" in caplog.text
def test_read_aliases_handles_read_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable __init__.py logs a warning and yields nothing rather
than aborting the whole component scan."""
missing = tmp_path / "nope" / "__init__.py"
assert _read_aliases(missing, ast) == ([], None)
assert "Could not read" in caplog.text
def test_build_alias_map_aggregates_components(tmp_path: Path) -> None:
@@ -480,96 +461,64 @@ def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None:
but possible in some test contexts), we want an empty map rather than
a crash — the rest of the loader can still function."""
fake = tmp_path / "does-not-exist"
assert not fake.exists()
with patch("esphome.loader.CORE_COMPONENTS_PATH", fake):
alias_map, meta_map = _build_alias_map()
assert alias_map == {}
assert meta_map == {}
def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None:
"""An alias that names an existing component package is refused: it would
hijack a live domain, and a self-alias (alias == canonical) would send
``_lookup_module`` into infinite recursion."""
# `newcomp` declares itself as an alias — its own package already exists.
_write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shadows an existing component"),
):
_build_alias_map()
# ---- Live integration against the real rp2/rp2040 mapping in this repo ----
# ---- Integration against a synthetic alias map (fake legacy -> esp32) ----
def test_real_alias_map_includes_rp2040() -> None:
"""The rp2 component declares ``ALIASES = ['rp2040']`` in this repo;
the live alias map should surface it. This guards against future
refactors silently dropping the declaration."""
meta = get_alias_metadata()
assert "rp2040" in meta
assert meta["rp2040"].canonical == "rp2"
assert meta["rp2040"].removal_version == "2027.7.0"
def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None:
"""Force the loader's alias map (used by the finder and get_component).
Patches the lazily-built caches so both ``_get_alias_map`` and the
installed meta-path finder resolve against ``mapping`` regardless of
what the real on-disk scan would produce.
"""
monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping)
def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None:
"""``get_component(<alias>)`` should return the canonical manifest — every
def test_get_component_resolves_alias() -> None:
"""``get_component('rp2040')`` should return the rp2 manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
the canonical component without knowing about the alias."""
import esphome.loader as loader_mod
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None)
canonical = get_component("esp32")
aliased = get_component(_FAKE_ALIAS)
assert canonical is not None
assert aliased is canonical
rp2 = get_component("rp2")
rp2040 = get_component("rp2040")
assert rp2 is not None
assert rp2040 is rp2
def test_alias_finder_resolves_top_level_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``import esphome.components.<alias>`` resolves to the canonical
module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
def test_alias_finder_resolves_top_level_import() -> None:
"""``import esphome.components.rp2040`` resolves to the canonical
module via the meta-path finder."""
# Remove any cached entry so we exercise the finder, not sys.modules cache.
sys.modules.pop("esphome.components.rp2040", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None)
spec = finder.find_spec("esphome.components.rp2040", None)
assert spec is not None
import esphome.components.esp32
import esphome.components.esp32_legacy_alias
import esphome.components.rp2
import esphome.components.rp2040
assert esphome.components.esp32_legacy_alias is esphome.components.esp32
assert esphome.components.rp2040 is esphome.components.rp2
def test_alias_finder_resolves_submodule_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``from esphome.components.<alias> import boards`` routes through to
``esphome.components.esp32.boards`` — same submodule object on both paths.
The canonical submodule is imported first so its parent module carries
the ``boards`` attribute; ``from <alias> import boards`` then resolves
the aliased parent (via the finder) and reads that same attribute,
rather than triggering a fresh file load under the alias name.
``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
def test_alias_finder_resolves_submodule_import() -> None:
"""``from esphome.components.rp2040 import boards`` routes through to
``esphome.components.rp2.boards`` — same submodule object on both
paths."""
sys.modules.pop("esphome.components.rp2040.boards", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None)
spec = finder.find_spec("esphome.components.rp2040.boards", None)
assert spec is not None
from esphome.components.esp32 import boards as canonical_boards
from esphome.components.esp32_legacy_alias import boards as aliased_boards
from esphome.components.rp2 import boards as rp2_boards
from esphome.components.rp2040 import boards as rp2040_boards
assert aliased_boards is canonical_boards
assert rp2040_boards is rp2_boards
def test_alias_finder_ignores_non_components_path() -> None:
@@ -581,9 +530,6 @@ def test_alias_finder_ignores_non_components_path() -> None:
assert finder.find_spec("os.path", None) is None
# `esphome.components` itself (no domain segment) is not a candidate.
assert finder.find_spec("esphome.components", None) is None
# A real, non-aliased component domain defers to normal import machinery
# (no component declares an alias in this repo, so the live map is empty).
assert finder.find_spec("esphome.components.logger", None) is None
# ---------------------------------------------------------------------------
@@ -593,391 +539,121 @@ def test_alias_finder_ignores_non_components_path() -> None:
# The companion to the loader-side alias map: ``esphome.config`` runs a
# pre-pass over the user's parsed YAML that rewrites legacy top-level keys
# to their canonical names, surfacing a one-shot deprecation warning. These
# tests inject a synthetic alias-metadata map so the rewrite behavior, the
# warning text, and the both-keys-present conflict can be tested in isolation.
def _patch_alias_metadata(
monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta]
) -> None:
monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping)
# tests pin down the rewrite behavior, the warning text, and the
# both-keys-present conflict.
def test_resolve_component_aliases_renames_legacy_key(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
caplog: pytest.LogCaptureFixture,
) -> None:
"""A legacy alias key should be renamed to the canonical key and a
deprecation warning citing the removal version logged."""
"""A legacy alias key ``rp2040:`` should be renamed to the canonical
``rp2:`` and a deprecation warning citing the removal version logged."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires
config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}}
config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}}
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert "oldcomp" not in config
assert config["newcomp"] == {"board": "x"}
assert "rp2040" not in config
assert config["rp2"] == {"board": "rpipicow"}
assert any(
"'oldcomp:' top-level key is deprecated" in record.message
and "rename it to 'newcomp:'" in record.message
and "2027.6.0" in record.message
"'rp2040:' top-level key is deprecated" in record.message
and "rename it to 'rp2:'" in record.message
and "2027.7.0" in record.message
for record in caplog.records
)
def test_resolve_component_aliases_dedupes_warning_within_a_run(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
caplog: pytest.LogCaptureFixture,
) -> None:
"""Schema validators can run twice (auto-load discovery + final pass)
so the rename pass must emit the warning only once per alias per run.
Deduped via ``CORE.data``; cleared between runs."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases({"oldcomp": {"board": "a"}})
_resolve_component_aliases({"oldcomp": {"board": "b"}})
_resolve_component_aliases({"rp2040": {"board": "rpipicow"}})
_resolve_component_aliases({"rp2040": {"board": "rpipico2w"}})
matches = [
r
for r in caplog.records
if "'oldcomp:' top-level key is deprecated" in r.message
if "'rp2040:' top-level key is deprecated" in r.message
]
assert len(matches) == 1
def test_resolve_component_aliases_rejects_both_keys_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_resolve_component_aliases_rejects_both_keys_present() -> None:
"""If the user has BOTH legacy and canonical keys, silently dropping
one would hide a real misconfiguration. Raise instead."""
import voluptuous as vol
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
config = {
"rp2": {"board": "rpipicow"},
"rp2040": {"board": "rpipicow"},
}
with pytest.raises(vol.Invalid, match="Both 'rp2040:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_canonical_key_after_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The both-keys conflict must be detected even when the canonical key
appears *after* the legacy key in the config (the up-front conflict
scan, not a position-dependent check)."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two different deprecated aliases of the same canonical component is
ambiguous — silently keeping one would hide a misconfiguration."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{
"oldcomp": AliasMeta(canonical="newcomp", removal_version=None),
"legacycomp": AliasMeta(canonical="newcomp", removal_version=None),
},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}}
with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_preserves_key_position(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The renamed canonical key keeps the legacy key's original position
rather than being moved to the end of the config."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}}
_resolve_component_aliases(config)
assert list(config) == ["esphome", "newcomp", "logger"]
def test_resolve_component_aliases_no_op_when_no_legacy_keys(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None:
"""The pre-pass must be a no-op (no warning, no mutation) for configs
that already use canonical keys."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}}
config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}}
original = dict(config)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
with caplog_at_warning() as records:
_resolve_component_aliases(config)
assert config == original
assert not any("deprecated" in r.message for r in caplog.records)
assert not any("deprecated" in r.message for r in records)
_ = logging # silence unused-import in branches that don't read records
# ---------------------------------------------------------------------------
# ComponentManifest alias properties
# ---------------------------------------------------------------------------
# Helper context manager — small enough to inline rather than pull in
# caplog for the simple "did anything warn?" case above.
import contextlib # noqa: E402
def test_component_manifest_alias_properties_default_empty() -> None:
"""``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None``
when the component module declares neither.
@contextlib.contextmanager
def caplog_at_warning():
"""Minimal in-test caplog substitute: collect WARNING records on a
dedicated handler attached to ``esphome.config``."""
import logging
Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the
``getattr(..., default)`` fallback is actually exercised — a bare mock
auto-creates any attribute on access and would never hit the default."""
mod = ModuleType("fake_component")
manifest = ComponentManifest(mod)
assert manifest.aliases == []
assert manifest.alias_removal_version is None
logger = logging.getLogger("esphome.config")
records: list[logging.LogRecord] = []
class _Handler(logging.Handler):
def emit(self, record): # noqa: D401
records.append(record)
def test_component_manifest_alias_properties_read_module_values() -> None:
"""The properties surface the module's declared values verbatim."""
mod = MagicMock()
mod.ALIASES = ["legacy"]
mod.ALIAS_REMOVAL_VERSION = "2027.6.0"
manifest = ComponentManifest(mod)
assert manifest.aliases == ["legacy"]
assert manifest.alias_removal_version == "2027.6.0"
# ---------------------------------------------------------------------------
# Real (unpatched) lazy build + cache and remaining scanner branches
# ---------------------------------------------------------------------------
def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise the real lazy build over the actual components dir (no patch):
the first call scans and caches, the second returns the cached object."""
monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None)
first = loader_mod._get_alias_map()
second = loader_mod._get_alias_map()
assert isinstance(first, dict)
assert first is second # cached, not rebuilt on the second call
def test_get_alias_metadata_real_build_and_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None)
first = loader_mod.get_alias_metadata()
second = loader_mod.get_alias_metadata()
assert isinstance(first, dict)
assert first is second
def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None:
"""Loose files and directories without an ``__init__.py`` are ignored;
only real component packages contribute to the map."""
(tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n")
(tmp_path / "initless").mkdir() # a dir, but no __init__.py
_write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, _ = _build_alias_map()
assert alias_map == {"legacy": "realcomp"}
def test_read_aliases_ignores_non_assignment_and_complex_targets(
tmp_path: Path,
) -> None:
"""Non-assignment statements and assignments to non-Name targets are
skipped; only simple ``NAME = ...`` assignments are read."""
init = tmp_path / "__init__.py"
init.write_text(
"import os\n" # non-Assign (Import) node -> skipped
"obj.attr = 'v'\n" # Assign with an Attribute target -> skipped
"ALIASES = ['legacy']\n"
)
aliases, _ = _read_aliases(init, ast)
assert aliases == ["legacy"]
# ---------------------------------------------------------------------------
# Finder / loader edge branches
# ---------------------------------------------------------------------------
def test_alias_finder_returns_none_when_canonical_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias points at a canonical *target* that doesn't exist, the
finder declines (returns None) and lets normal import machinery report
the missing module."""
_patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"})
finder = _AliasFinder()
assert finder.find_spec("esphome.components.broken_alias", None) is None
def test_alias_finder_reraises_when_canonical_dependency_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the canonical module exists but fails to import one of its own
dependencies, the finder surfaces that real error instead of masking it
as an unresolved alias (which would silently fall through to a confusing
'no module named <alias>')."""
_patch_alias_map(monkeypatch, {"some_alias": "real_canonical"})
def boom(name: str) -> None:
raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep")
monkeypatch.setattr("esphome.loader.importlib.import_module", boom)
finder = _AliasFinder()
with pytest.raises(ModuleNotFoundError, match="missing_dep"):
finder.find_spec("esphome.components.some_alias", None)
def test_install_alias_finder_is_idempotent() -> None:
"""The finder is installed once at import; calling the installer again is
a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``)."""
before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(before) == 1 # installed at module import time
loader_mod._install_alias_finder()
after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(after) == 1
def test_get_component_alias_to_missing_canonical_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias resolves to a canonical component that can't be loaded,
``get_component`` returns None and caches no bogus manifest."""
_patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"})
loader_mod._COMPONENT_CACHE.pop("ghost_alias", None)
assert get_component("ghost_alias") is None
assert "ghost_alias" not in loader_mod._COMPONENT_CACHE
# ---------------------------------------------------------------------------
# YAML pre-pass: empty-map fast path + validate_config integration
# ---------------------------------------------------------------------------
def test_resolve_component_aliases_noop_when_no_aliases_declared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no component declares an alias, the pre-pass returns immediately
without inspecting or mutating the config."""
from esphome.config import _resolve_component_aliases
monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map
config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}}
original = dict(config)
_resolve_component_aliases(config)
assert config == original
def _default_component_mock() -> Mock:
"""A permissive component mock that validates any config (ALLOW_EXTRA)."""
return Mock(
auto_load=[],
is_platform_component=False,
is_platform=False,
multi_conf=False,
multi_conf_no_default=False,
dependencies=[],
conflicts_with=[],
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
)
@pytest.mark.usefixtures("setup_core")
def test_validate_config_renames_alias_key(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end: a legacy top-level key is renamed to its canonical name
before the rest of ``validate_config`` runs, and validation succeeds.
A real ``esp32`` target platform is included so ``preload_core_config``
is satisfied and validation runs to completion (the renamed canonical
key is loaded via the mocked, permissive component)."""
mock_get_component.side_effect = lambda name: _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {
"legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0")
},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"esp32": {"board": "esp32dev"},
"legacyfoo": {"opt": 1},
}
result = esphome_config.validate_config(raw_config, {})
assert not result.errors, f"unexpected errors: {result.errors}"
assert "newcomp" in result
assert "legacyfoo" not in result
@pytest.mark.usefixtures("setup_core")
def test_validate_config_reports_alias_conflict_as_error(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If both the legacy and canonical keys are present, ``validate_config``
surfaces the conflict as a config error (the ``vol.Invalid`` path)."""
mock_get_component.return_value = _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"newcomp": {"opt": 1},
"legacyfoo": {"opt": 2},
}
result = esphome_config.validate_config(raw_config, {})
assert result.errors
assert "Both 'legacyfoo:'" in str(result.errors)
handler = _Handler(level=logging.WARNING)
logger.addHandler(handler)
prev_level = logger.level
logger.setLevel(logging.WARNING)
try:
yield records
finally:
logger.removeHandler(handler)
logger.setLevel(prev_level)
+64 -17
View File
@@ -94,7 +94,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_RP2,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@@ -1226,7 +1226,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test interactive mode shows RP2040 BOOTSEL option via picotool."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1249,7 +1249,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None:
"""Test BOOTSEL instructions shown when no RP2040 device found."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1271,7 +1271,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota(
) -> None:
"""Test BOOTSEL tip shown when only OTA options exist for RP2040."""
setup_core(
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
)
@@ -1300,7 +1300,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports(
mock_choose_prompt: Mock,
) -> None:
"""Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
with (
@@ -1325,7 +1325,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test permission warning shown when BOOTSEL device found but not accessible."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1355,7 +1355,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota(
) -> None:
"""Test permission warning shown with OTA fallback available."""
setup_core(
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
)
@@ -1412,7 +1412,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test both serial ports and BOOTSEL option shown for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
with (
@@ -1665,7 +1665,7 @@ def test_upload_using_esptool_with_file_path(
@pytest.mark.parametrize(
"platform,device",
[
(PLATFORM_RP2040, "/dev/ttyACM0"),
(PLATFORM_RP2, "/dev/ttyACM0"),
(PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform
],
)
@@ -1720,7 +1720,7 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040(
tmp_path: Path,
) -> None:
"""Test that upload_using_platformio creates firmware.bin.signed for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1756,6 +1756,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
assert result == 0
def test_upload_using_platformio_skips_signed_bin_when_already_present(
tmp_path: Path,
) -> None:
"""The signed-bin copy is idempotent: if ``firmware.bin.signed`` already
exists on the RP2 build path, the upload step must not overwrite it
(and must not fail when the unsigned ``firmware.bin`` is absent)."""
setup_core(platform=PLATFORM_RP2)
build_dir = tmp_path / "build"
build_dir.mkdir()
# Pre-existing signed bin with distinct content — must be preserved.
signed_bin = build_dir / "firmware.bin.signed"
signed_bin.write_bytes(b"already signed")
# No unsigned firmware.bin on disk — the `is_file()` guard must hold.
firmware_elf = build_dir / "firmware.elf"
firmware_elf.write_bytes(b"elf")
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(firmware_elf)
with (
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0),
):
result = upload_using_platformio({}, "/dev/ttyACM0")
assert result == 0
# Pre-existing signed bin is untouched.
assert signed_bin.read_bytes() == b"already signed"
def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None:
"""The upload step must work without a serial port (PlatformIO picks the
target itself); the ``--upload-port`` flag is only appended when a port
is provided."""
setup_core(platform=PLATFORM_ESP32)
with patch(
"esphome.platformio.toolchain.run_platformio_cli_run", return_value=0
) as mock_run:
result = upload_using_platformio({}, None)
assert result == 0
args = mock_run.call_args.args
assert "--upload-port" not in args
def test_upload_program_serial_upload_failed(
mock_upload_using_esptool: Mock,
mock_get_port_type: Mock,
@@ -1783,7 +1830,7 @@ def test_upload_program_bootsel(
mock_get_port_type: Mock,
) -> None:
"""Test upload_program with BOOTSEL for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 0
@@ -1804,7 +1851,7 @@ def test_upload_program_bootsel_failed(
mock_get_port_type: Mock,
) -> None:
"""Test upload_program when BOOTSEL upload fails."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 1
@@ -1821,7 +1868,7 @@ def test_upload_program_bootsel_failed(
def test_upload_using_picotool_success(tmp_path: Path) -> None:
"""Test upload_using_picotool succeeds."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1858,7 +1905,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None:
def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
"""Test upload_using_picotool when ELF file is missing."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1876,7 +1923,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
"""Test upload_using_picotool when picotool binary not found."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1896,7 +1943,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
def test_upload_using_picotool_permission_error(tmp_path: Path) -> None:
"""Test upload_using_picotool shows helpful message on permission error."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -6411,7 +6458,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
picks up the newly enumerated serial port before showing logs."""
setup_core(
config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}},
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
)
args = MockArgs()
+55 -1
View File
@@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS
from esphome.components.esp32.boards import ESP32_BOARD_PINS
from esphome.components.esp8266.boards import ESP8266_BOARD_PINS
from esphome.components.ln882x.boards import LN882X_BOARD_PINS
from esphome.components.rp2.boards import RP2_BOARD_PINS
from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS
from esphome.core import CORE
import esphome.wizard as wz
@@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx(
assert "rtl87xx:" in generated_config
def test_wizard_write_defaults_platform_from_board_rp2(
default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch
):
"""
If the platform is not explicitly set, use "RP2" when the board is in
the RP2 boards list. The generated config must use the canonical
``rp2:`` top-level key (not the deprecated ``rp2040:`` alias).
"""
# Given
del default_config["platform"]
default_config["board"] = [*RP2_BOARD_PINS][0]
monkeypatch.setattr(wz, "write_file", MagicMock())
monkeypatch.setattr(CORE, "config_path", tmp_path.parent)
# When
wz.wizard_write(tmp_path, **default_config)
# Then
generated_config = wz.write_file.call_args.args[1]
assert "rp2:" in generated_config
# Guard against regressing to the legacy alias key.
assert "rp2040:" not in generated_config
def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch):
"""
The safe_print_step function prints the step number and the passed description
@@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32(
assert retval == 0
def test_wizard_accepts_default_answers_bk72xx(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
"""
The wizard should accept the given default answers for bk72xx. The
libretiny branch also exercises the False side of the
``elif platform == "RP2":`` checks in the platform / board-link
elif chain (without this, those branches show as partial coverage
because only the rpipico interactive test reaches them with platform
== "RP2").
"""
# Given
wizard_answers[1] = "BK72XX"
wizard_answers[2] = next(iter(BK72XX_BOARD_PINS))
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
monkeypatch.setattr(wz, "wizard_write", MagicMock())
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
def test_wizard_offers_better_node_name(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
@@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
# Given
wizard_answers_rp2040 = [
"test-node", # Name of the node
"RP2040", # platform
"RP2", # platform (canonical name; ``RP2040`` was the legacy alias)
"rpipico", # board (no WiFi support)
]
config_file = tmp_path / "test.yaml"
+2 -2
View File
@@ -18,7 +18,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import EsphomeError
@@ -338,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes(
@pytest.mark.parametrize(
"core_platform",
[PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX],
[PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX],
)
def test_storage_should_not_update_cmake_cache_for_non_esp32(
create_storage: Callable[..., StorageJSON],