Merge branch 'dev' into sendspin-artwork

This commit is contained in:
Kevin Ahrendt
2026-05-14 07:46:14 -04:00
committed by GitHub
1869 changed files with 16645 additions and 8535 deletions
View File
+22
View File
@@ -0,0 +1,22 @@
"""Shared fixtures for the Python benchmark suite."""
from __future__ import annotations
from collections.abc import Generator
import pytest
from esphome.core import CORE
@pytest.fixture(autouse=True)
def reset_core_state() -> Generator[None]:
"""Reset CORE before and after every benchmark.
Per-iteration setups inside benchmarks reset CORE for the loop body;
this fixture handles the test-level boundary so stale state from
fixture priming doesn't leak across benchmarks.
"""
CORE.reset()
yield
CORE.reset()
@@ -0,0 +1,62 @@
substitutions:
devicename: bluetooth_proxy_device
friendly_name: bluetooth_proxy_device
esphome:
name: $devicename
friendly_name: $friendly_name
esp32:
board: esp32-poe-iso
framework:
type: esp-idf
advanced:
sram1_as_iram: true
minimum_chip_revision: "3.0"
esp32_ble_tracker:
scan_parameters:
active: false
bluetooth_proxy:
active: true
ethernet:
type: LAN8720
mdc_pin: GPIO23
mdio_pin: GPIO18
clk_mode: GPIO17_OUT
phy_addr: 0
power_pin: GPIO12
debug:
logger:
api:
ota:
platform: esphome
button:
- platform: restart
name: Restart
time:
- platform: homeassistant
id: homeassistant_time
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Ethernet Uptime
- platform: template
name: Free Memory
lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
unit_of_measurement: B
state_class: measurement
- platform: debug
free:
name: Heap Free
fragmentation:
name: Heap Fragmentation
min_free:
name: Heap Min Free
@@ -0,0 +1,116 @@
"""CodSpeed benchmarks for the validated-config cache fast path.
PR #16381 added a cache that lets ``esphome upload`` / ``esphome logs``
skip re-running the full config-validation pipeline. These benchmarks
compare the cached path (``load_compiled_config``) against the slow
path (``read_config``) on the same input.
The fixture YAML is a modest bluetooth-proxy device. The two paths
end up close on a config this small -- the win grows with config
complexity (external components, large package trees, deeply nested
schemas), where the slow path can be orders of magnitude slower than
the cache load.
Skipped when ``pytest-codspeed`` isn't installed so the regular
unit-test suite keeps working unchanged.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import shutil
from typing import Any
import pytest
from esphome.compiled_config import compiled_config_path, load_compiled_config
from esphome.config import read_config
from esphome.core import CORE
from esphome.storage_json import ext_storage_path
from esphome.writer import update_storage_json
pytest.importorskip("pytest_codspeed")
HERE = Path(__file__).parent
FIXTURE_YAML = HERE / "fixtures" / "bluetooth_proxy_device.yaml"
def _stage_yaml(tmp_path: Path) -> Path:
"""Copy fixture YAML into a fresh tmp dir.
Each benchmark gets its own copy so the cache files (under
``.esphome/storage/`` next to the YAML) don't bleed between cases.
"""
target = tmp_path / FIXTURE_YAML.name
shutil.copy2(FIXTURE_YAML, target)
return target
def _prime_cache(yaml_path: Path) -> None:
"""Run full validation once and persist the cache + sidecar.
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
then ``update_storage_json`` writes both the StorageJSON sidecar and
the ``.validated.yaml`` compiled-config cache.
"""
CORE.config_path = yaml_path
config = read_config({}, skip_external_update=True)
assert config is not None, f"fixture YAML failed to validate: {yaml_path}"
CORE.config = config
update_storage_json()
@pytest.fixture
def staged_yaml(tmp_path: Path) -> Path:
"""YAML copied into tmp_path; no cache files written yet."""
return _stage_yaml(tmp_path)
@pytest.fixture
def primed_yaml(staged_yaml: Path) -> Path:
"""YAML plus a fresh cache + sidecar on disk."""
_prime_cache(staged_yaml)
assert compiled_config_path(staged_yaml.name).is_file()
assert ext_storage_path(staged_yaml.name).is_file()
return staged_yaml
def _resetting_setup(
yaml_path: Path,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> Callable[[], tuple[tuple[Any, ...], dict[str, Any]]]:
"""Build a per-iteration setup that resets CORE and re-pins config_path."""
def setup() -> tuple[tuple[Any, ...], dict[str, Any]]:
CORE.reset()
CORE.config_path = yaml_path
return args, kwargs
return setup
def test_load_compiled_config_cached(primed_yaml: Path, benchmark) -> None:
"""Fast path: deserialize the cached, already-validated config."""
benchmark.pedantic(
load_compiled_config,
setup=_resetting_setup(primed_yaml, (primed_yaml,), {}),
rounds=5,
iterations=1,
)
def test_read_config_uncached(primed_yaml: Path, benchmark) -> None:
"""Slow path: full validation pipeline (yaml load + schema + components).
Uses the same primed fixture as the cached path -- ``read_config``
ignores the cache file on disk, so the two benchmarks measure the
same input from two different code paths.
"""
benchmark.pedantic(
read_config,
setup=_resetting_setup(primed_yaml, ({},), {"skip_external_update": True}),
rounds=3,
iterations=1,
)
+2 -2
View File
@@ -16,8 +16,8 @@ from esphome.const import (
CONF_ESPHOME,
CONF_IGNORE_PIN_VALIDATION_ERROR,
CONF_NUMBER,
KEY_NATIVE_IDF,
PlatformFramework,
Toolchain,
)
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
@@ -266,7 +266,7 @@ def test_native_idf_enables_reproducible_build(
CORE.config_path = component_config_path("reproducible_build.yaml")
CORE.config = read_config({})
CORE.data[KEY_NATIVE_IDF] = True
CORE.toolchain = Toolchain.ESP_IDF
generate_cpp_contents(CORE.config)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
@@ -0,0 +1,83 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
id: lvgl_id
displays: tft_display
pages:
- id: main_page
widgets:
# Widget with multiple static states; one true, one false.
- button:
id: btn_static
state:
checked: true
disabled: false
# Widget with a templated (lambda) state.
- button:
id: btn_lambda
state:
pressed: !lambda return true;
# Button referenced by enable/disable actions; the on_click handler
# exercises both branches of the obj_disable/obj_enable code path.
- button:
id: btn_actions
on_click:
- lvgl.widget.disable: btn_actions
- lvgl.widget.enable: btn_actions
# Button matrix with two buttons; matrix_btn_a is targeted by
# lvgl.widget.disable/enable actions to exercise the
# MatrixButton.set_state code path.
- buttonmatrix:
id: matrix
rows:
- buttons:
- id: matrix_btn_a
text: A
control:
checkable: true
- id: matrix_btn_b
text: B
control:
checkable: true
on_click:
- lvgl.widget.disable: matrix_btn_a
- lvgl.widget.enable: matrix_btn_a
# Switch derived from an LVGL switch widget – exercises
# set_state(LV_STATE.CHECKED, v) inside the control lambda.
- switch:
id: switch_widget
switch:
- platform: lvgl
id: lvgl_switch
name: LVGL Switch
widget: switch_widget
@@ -0,0 +1,167 @@
"""Tests for LVGL widget state code generation.
These tests cover the change from the old ``add_state``/``clear_state`` helpers
on :class:`Widget` (and on :class:`MatrixButton`) to a single ``set_state``
method that delegates to the new C++ helpers
``LvglComponent::lv_obj_set_state_value`` and
``LvglComponent::lv_buttonmatrix_set_button_ctrl_value``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared widget-state YAML config once
per module.
Module-scoped so the (relatively expensive) codegen runs a single time;
the function-scoped fixtures from ``conftest.py`` (e.g. ``generate_main``)
can't be requested from a higher-scoped fixture, so the small amount of
setup is inlined here. The captured string is independent of
``CORE.reset()`` calls that the per-test autouse fixtures perform after
this fixture has produced its value.
"""
config_path = Path(request.fspath).parent / "config" / "widget_state_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_static_state_emits_set_state_value(main_cpp: str) -> None:
"""A widget with ``state: { checked: true, disabled: false }`` should
generate one ``lv_obj_set_state_value`` call per entry, with the
appropriate boolean argument.
"""
assert (
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_CHECKED, true)"
in main_cpp
)
assert (
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_DISABLED, false)"
in main_cpp
)
def test_lambda_state_emits_set_state_value_with_lambda(main_cpp: str) -> None:
"""A widget with ``state: { pressed: !lambda return true; }`` should
generate ``lv_obj_set_state_value(..., LV_STATE_PRESSED, <expr>)`` where
``<expr>`` is the lambda's return value (cast or inlined), not a static
bool.
"""
# The set_state call is emitted for the templated state.
assert (
"LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED,"
in main_cpp
)
# And it must NOT have collapsed the lambda to a literal true/false.
assert (
"LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED, true)"
not in main_cpp
)
# The legacy if/else over add_state/remove_state is gone.
assert "lv_obj_add_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp
assert "lv_obj_remove_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp
def test_widget_disable_action_uses_set_state_value(main_cpp: str) -> None:
"""``lvgl.widget.disable: btn_actions`` should emit a
``set_state_value(..., LV_STATE_DISABLED, true)`` call rather than the
legacy ``lv_obj_add_state``.
"""
assert (
"LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, true)"
in main_cpp
)
# No leftover legacy add_state for the DISABLED state of this widget.
assert "lv_obj_add_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp
def test_widget_enable_action_uses_set_state_value(main_cpp: str) -> None:
"""``lvgl.widget.enable: btn_actions`` should emit a
``set_state_value(..., LV_STATE_DISABLED, false)`` call rather than the
legacy ``lv_obj_remove_state``.
"""
assert (
"LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, false)"
in main_cpp
)
assert "lv_obj_remove_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp
def test_buttonmatrix_disable_action_uses_helper(main_cpp: str) -> None:
"""``lvgl.widget.disable: matrix_btn_a`` should route through the new
``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0
with the ``DISABLED`` control bit set to ``true``, instead of the
legacy ``lv_buttonmatrix_set_button_ctrl``.
The button matrix obj is the compound's ``obj`` member and the index
is the position of the button in the row layout.
"""
assert (
"LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, "
"LV_BUTTONMATRIX_CTRL_DISABLED, true)"
) in main_cpp
def test_buttonmatrix_enable_action_uses_helper(main_cpp: str) -> None:
"""``lvgl.widget.enable: matrix_btn_a`` should route through the new
``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0
with the ``DISABLED`` control bit set to ``false``, instead of the
legacy ``lv_buttonmatrix_clear_button_ctrl``.
"""
assert (
"LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, "
"LV_BUTTONMATRIX_CTRL_DISABLED, false)"
) in main_cpp
# The legacy clear_button_ctrl path is gone for the matrix button enable
# action.
assert (
"lv_buttonmatrix_clear_button_ctrl(matrix->obj, 0, LV_BUTTONMATRIX_CTRL_DISABLED)"
not in main_cpp
)
def test_lvgl_switch_control_calls_set_state_value(main_cpp: str) -> None:
"""The LVGL switch platform installs a control lambda that mirrors the
switch's bool value into ``LV_STATE_CHECKED`` via
``lv_obj_set_state_value`` (replacing the previous if/else over
``add_state``/``clear_state`` plus an explicit ``send_event`` of
``lv_api_event``).
"""
# The control lambda calls the new helper with the bool ``v`` parameter.
assert (
"LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_CHECKED, v)"
in main_cpp
)
# The deprecated lv_api_event symbol must no longer appear anywhere.
assert "lv_api_event" not in main_cpp
def test_default_state_does_not_emit_set_state_value(main_cpp: str) -> None:
"""A widget without a ``state:`` block must not generate any
``lv_obj_set_state_value`` calls for it. (Sanity-check that the
new code path is opt-in driven by the YAML.)
"""
assert (
"LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_DISABLED"
not in main_cpp
)
assert (
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_PRESSED"
not in main_cpp
)
@@ -0,0 +1,50 @@
cc1101:
id: cc1101_radio
cs_pin: ${cs_pin}
frequency: 433.92MHz
modulation_type: ASK/OOK
output_power: 10
# Dual-pin wiring (recommended by the CC1101 docs):
# CC1101 GDO0 → ${gdo0_pin} (remote_transmitter)
# CC1101 GDO2 → ${gdo2_pin} (remote_receiver)
remote_transmitter:
id: rf_tx
pin: ${gdo0_pin}
carrier_duty_percent: 100%
# Switch the chip into TX state for the duration of each transmission and back to RX
# afterwards. Driver-agnostic: any RF front-end with begin_tx/begin_rx-style actions
# can be wired this way.
on_transmit:
then:
- cc1101.begin_tx: cc1101_radio
on_complete:
then:
- cc1101.begin_rx: cc1101_radio
remote_receiver:
id: rf_rx
pin: ${gdo2_pin}
radio_frequency:
- platform: ir_rf_proxy
id: rf_proxy_cc1101_tx
name: "CC1101 RF Transmitter"
frequency: 433.92MHz
remote_transmitter_id: rf_tx
# Optional: retune the CC1101 per-transmit when the API request specifies a
# different carrier frequency. Demonstrates the on_control trigger.
on_control:
then:
- if:
condition:
lambda: "return x.get_frequency().has_value() && *x.get_frequency() > 0;"
then:
- cc1101.set_frequency:
id: cc1101_radio
value: !lambda "return *x.get_frequency();"
- platform: ir_rf_proxy
id: rf_proxy_cc1101_rx
name: "CC1101 RF Receiver"
frequency: 433.92MHz
remote_receiver_id: rf_rx
@@ -0,0 +1,9 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
gdo2_pin: GPIO16
packages:
common: !include common.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
cc1101: !include common-cc1101.yaml
@@ -0,0 +1,9 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
gdo2_pin: GPIO16
packages:
common: !include common.yaml
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
cc1101: !include common-cc1101.yaml
+1 -1
View File
@@ -38,7 +38,7 @@ number:
- platform: lvgl
widget: slider_id
name: LVGL Slider Number
update_on_release: true
trigger: on_release
restore_value: true
- platform: lvgl
widget: lv_arc
+50
View File
@@ -309,6 +309,11 @@ lvgl:
- logger.log:
format: "Roller changed = %d: %s"
args: [x, text.c_str()]
on_update:
then:
- logger.log:
format: "Roller updated = %d: %s"
args: [x, text.c_str()]
- animimg:
height: 60
id: anim_img
@@ -630,6 +635,10 @@ lvgl:
logger.log:
format: "state now %d"
args: [x]
on_update:
logger.log:
format: "button updated %d"
args: [x]
on_short_click:
lvgl.widget.hide: hello_label
on_long_press:
@@ -758,6 +767,9 @@ lvgl:
lambda: return tile == id(tile_1);
then:
- logger.log: "tile 1 is now showing"
on_update:
then:
- logger.log: "tileview updated programmatically"
tiles:
- id: tile_1
scroll_snap_y: center
@@ -983,6 +995,11 @@ lvgl:
- logger.log:
format: "Arc value is %f"
args: [x]
on_update:
then:
- logger.log:
format: "Arc updated to %f"
args: [x]
scroll_on_focus: true
value: 75
min_value: 1
@@ -1085,6 +1102,11 @@ lvgl:
- logger.log:
format: "slider value %f"
args: [x]
on_update:
then:
- logger.log:
format: "slider updated to %f"
args: [x]
on_click:
then:
- lvgl.slider.update:
@@ -1197,6 +1219,10 @@ lvgl:
logger.log:
format: "Dropdown changed = %d: %s"
args: [x, text.c_str()]
on_update:
logger.log:
format: "Dropdown updated = %d: %s"
args: [x, text.c_str()]
on_cancel:
logger.log:
format: "Dropdown closed = %d"
@@ -1437,6 +1463,30 @@ color:
blue_int: 64
white_int: 255
sensor:
- platform: lvgl
widget: lv_arc_1
id: lvgl_arc1_sensor_on_change
name: LVGL Arc1 Sensor on_change
trigger: on_change
- platform: lvgl
widget: bar_id
id: lvgl_bar_sensor_on_release
name: LVGL Bar Sensor on_release
trigger: on_release
number:
- platform: lvgl
widget: lv_arc_1
id: lvgl_arc1_number_on_update
name: LVGL Arc1 Number on_update
trigger: on_update
- platform: lvgl
widget: spinbox_id
id: lvgl_spinbox_number_on_change
name: LVGL Spinbox Number on_change
trigger: on_change
select:
- platform: lvgl
id: lv_roller_select
@@ -16,13 +16,13 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
ctx.sut.set_current_time(123);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
ctx.sut.initialize();
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{123});
EXPECT_EQ(ctx.sut.operation_start_ms_, 123);
}
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
@@ -32,8 +32,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
ctx.uart.tx.clear(); // Remove first connect packet bytes
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
// Connect response
ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55});
@@ -47,21 +46,22 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{200});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
// Clear TX bytes.
ctx.uart.tx.clear();
// Settings response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99});
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
// Settings should still have initial values
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan());
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN);
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::UNKNOWN);
ctx.sut.set_current_time(300);
ASSERT_FALSE(ctx.sut.update());
@@ -72,13 +72,14 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO);
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
// Now fetch room temperature (0x03)
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{300});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
// Clear TX bytes.
ctx.uart.tx.clear();
@@ -101,8 +102,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{400});
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
@@ -115,21 +115,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
// Still no response after 1999ms, no retry yet
ctx.sut.set_current_time(1999);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
// Stop waiting after 2s and retry connect
ctx.sut.set_current_time(2000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{2000});
EXPECT_EQ(ctx.sut.operation_start_ms_, 2000);
}
TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
@@ -233,15 +233,12 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(80000);
// No scheduled status update
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Status update completed, schedule next status update
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{80000});
EXPECT_EQ(ctx.sut.operation_start_ms_, 80000);
// Wait for update_interval (ms) before doing another status update
ASSERT_FALSE(ctx.sut.update());
@@ -257,7 +254,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
ASSERT_FALSE(ctx.sut.update());
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
EXPECT_EQ(ctx.sut.operation_start_ms_, 82000);
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
@@ -310,6 +307,30 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f);
}
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_);
}
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_);
}
TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
auto ctx = TestContext{};
@@ -372,17 +393,56 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73));
}
TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) {
auto ctx = TestContext{};
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x10, 0x00, 0x00, 0x00, 0x00,
0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66));
}
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) {
auto ctx = TestContext{};
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x7A));
}
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) {
auto ctx = TestContext{};
ctx.sut.set_wide_vane_high_bit_ = true;
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0xFA));
}
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
auto ctx = TestContext{};
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(5000);
// Waiting for next scheduled status update
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
// Nothing to do in update (rx empty, no timeout)
ctx.sut.set_current_time(5500);
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
// Write new values
ctx.sut.use_temperature_encoding_b_ = true;
@@ -390,12 +450,52 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::AUTO);
// Waiting for next status update must be interrupted and new values send to AC
ctx.sut.set_current_time(6000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB));
// Write ACK response
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
ctx.sut.set_current_time(6500);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000);
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
}
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
auto ctx = TestContext{};
// Set remote temperature
ctx.sut.set_remote_temperature(28.5f);
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
// Write ACK response
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.uart.tx.clear();
// Clear remote temperature
ctx.sut.clear_remote_temperature();
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x00, 0x00, 0x80, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7));
// Write ACK response
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -404,4 +504,102 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
}
TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
auto ctx = TestContext{};
// Queue normal settings plus remote temperature together.
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
ctx.sut.set_remote_temperature(28.5f);
// First apply sends only the normal settings write.
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE));
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN));
// ACK the first write. Remote temperature should still be pending afterward.
ctx.uart.tx.clear();
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
// The next apply sends the remote-temperature packet and clears the last pending flag.
ctx.uart.tx.clear();
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
EXPECT_FALSE(ctx.sut.pending_updates_.any());
}
TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) {
auto ctx = TestContext{};
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(5000);
// Start in the scheduled status update wait state.
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ASSERT_EQ(ctx.sut.operation_start_ms_, 5000);
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
// Interrupt that wait with a write so credit is accumulated.
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
ctx.sut.set_current_time(6000);
ASSERT_FALSE(ctx.sut.update());
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
ASSERT_EQ(ctx.sut.operation_start_ms_, 6000);
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
// Do not ACK the write. Advance time far enough to force timeout/reconnect
// handling and verify that stale wait credit is cleared during recovery.
ctx.sut.set_current_time(36000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
ASSERT_EQ(ctx.sut.operation_start_ms_, 36000);
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
}
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
auto ctx = TestContext{};
ctx.sut.set_remote_temperature(7.0f);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
ctx.sut.set_remote_temperature(40.0f);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
ctx.sut.set_remote_temperature(NAN);
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
}
TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) {
auto ctx = TestContext{};
ctx.sut.set_remote_temperature(8.0f);
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
}
TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) {
auto ctx = TestContext{};
ctx.sut.set_remote_temperature(39.5f);
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
}
} // namespace esphome::mitsubishi_cn105::testing
+5 -2
View File
@@ -42,10 +42,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
public:
using MitsubishiCN105::MitsubishiCN105;
using MitsubishiCN105::State;
using MitsubishiCN105::UpdateFlag;
using MitsubishiCN105::state_;
using MitsubishiCN105::write_timeout_start_ms_;
using MitsubishiCN105::status_update_start_ms_;
using MitsubishiCN105::operation_start_ms_;
using MitsubishiCN105::use_temperature_encoding_b_;
using MitsubishiCN105::set_wide_vane_high_bit_;
using MitsubishiCN105::status_update_wait_credit_ms_;
using MitsubishiCN105::pending_updates_;
void set_state(State s) { this->set_state_(s); }
void apply_settings() { this->apply_settings_(); }
@@ -1,4 +1,14 @@
climate:
- platform: mitsubishi_cn105
id: ac
name: "AC Test"
uart_id: uart_bus
esphome:
on_boot:
then:
- climate.mitsubishi_cn105.set_remote_temperature:
id: ac
temperature: 22.0
- climate.mitsubishi_cn105.clear_remote_temperature:
id: ac
+12
View File
@@ -0,0 +1,12 @@
sensor:
- platform: ms8607
i2c_id: i2c_bus
temperature:
name: Temperature
humidity:
i2c_id: i2c_bus
name: Humidity
pressure:
name: Pressure
address: 0x76
update_interval: 15s
@@ -0,0 +1,8 @@
substitutions:
i2c_scl: GPIO16
i2c_sda: GPIO17
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,8 @@
substitutions:
i2c_scl: GPIO5
i2c_sda: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,8 @@
substitutions:
i2c_scl: GPIO5
i2c_sda: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
@@ -20,4 +20,4 @@ nrf52:
voltage: 2.1V
uicr_erase: true
framework:
version: "2.6.1-a"
version: "2.6.1-b"
+16
View File
@@ -7,6 +7,12 @@ esphome:
prefix: "Test"
param2: 0
param3: true
- script.execute:
id: my_script_with_array_params
ints: [42, 100]
floats: [1.5, 2.5]
bools: [true, false]
strings: ["a", "b"]
- script.wait: my_script
- script.stop: my_script
- if:
@@ -34,6 +40,16 @@ script:
mode: restart
then:
- lambda: 'ESP_LOGD("main", "Hello World!");'
- id: my_script_with_array_params
parameters:
ints: int[]
floats: float[]
bools: bool[]
strings: string[]
then:
- lambda: |-
ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s",
ints[0], floats[0], bools[0], strings[0].c_str());
- id: my_script_with_params
parameters:
prefix: string
@@ -0,0 +1,9 @@
ethernet:
type: OPENETH
psram:
mode: quad
sendspin:
id: sendspin_hub_id
task_stack_in_psram: true
@@ -0,0 +1,25 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
i2s_audio:
- id: i2s_output
speaker:
- platform: i2s_audio
id: speaker_id
dac_type: external
i2s_dout_pin: ${spdif_data_pin}
spdif_mode: true
use_apll: true
timeout: 2s
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
i2s_mode: primary
@@ -31,6 +31,11 @@ microphone:
i2s_din_pin: ${i2s_din_pin}
adc_type: external
pdm: false
- platform: i2s_audio
id: mic_id_external2
i2s_din_pin: ${i2s_din_pin2}
adc_type: external
pdm: false
speaker:
- platform: i2s_audio
@@ -40,9 +45,12 @@ speaker:
voice_assistant:
microphone:
microphone: mic_id_external
gain_factor: 4
channels: 0
- microphone: mic_id_external
gain_factor: 4
channels: 0
- microphone: mic_id_external2
gain_factor: 4
channels: 0
speaker: speaker_id
micro_wake_word: mww_id
conversation_timeout: 60s
@@ -3,6 +3,7 @@ substitutions:
i2s_bclk_pin: GPIO5
i2s_mclk_pin: GPIO15
i2s_din_pin: GPIO13
i2s_din_pin2: GPIO14
i2s_dout_pin: GPIO12
<<: !include common-idf.yaml
+7 -3
View File
@@ -1,10 +1,10 @@
packages:
- !include common.yaml
binary_sensor:
- platform: template
name: "Garage Door Open 10"
report: "enable"
- platform: template
name: "Garage Door Open 11"
report: "coordinator"
- platform: template
name: "Garage Door Open 12"
report: "force"
@@ -12,3 +12,7 @@ binary_sensor:
zigbee:
model: zigbee_test
router: true
power_source: MAINS_SINGLE_PHASE
on_join:
then:
- logger.log: "Joined network"
+2 -2
View File
@@ -128,8 +128,8 @@ def mock_storage_json() -> Generator[MagicMock]:
@pytest.fixture
def mock_idedata() -> Generator[MagicMock]:
"""Fixture to mock platformio_api.IDEData."""
with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock:
"""Fixture to mock platformio toolchain.IDEData."""
with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock:
yield mock
+1 -1
View File
@@ -23,7 +23,7 @@ import pytest_asyncio
import esphome.config
from esphome.core import CORE
from esphome.platformio_api import get_idedata
from esphome.platformio.toolchain import get_idedata
from .const import (
API_CONNECTION_TIMEOUT,
@@ -2,8 +2,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace crc8_test_component {
namespace esphome::crc8_test_component {
static const char *const TAG = "crc8_test";
@@ -166,5 +165,4 @@ void CRC8TestComponent::log_test_result(const char *test_name, bool passed) {
}
}
} // namespace crc8_test_component
} // namespace esphome
} // namespace esphome::crc8_test_component
@@ -4,8 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace crc8_test_component {
namespace esphome::crc8_test_component {
class CRC8TestComponent : public Component {
public:
@@ -25,5 +24,4 @@ class CRC8TestComponent : public Component {
uint8_t poly = 0x8C, bool msb_first = false);
};
} // namespace crc8_test_component
} // namespace esphome
} // namespace esphome::crc8_test_component
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#ifdef USE_API
namespace esphome {
namespace custom_api_device_component {
namespace esphome::custom_api_device_component {
static const char *const TAG = "custom_api";
@@ -58,6 +57,5 @@ void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::s
ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility");
}
} // namespace custom_api_device_component
} // namespace esphome
} // namespace esphome::custom_api_device_component
#endif // USE_API
@@ -6,8 +6,7 @@
#include "esphome/components/api/custom_api_device.h"
#ifdef USE_API
namespace esphome {
namespace custom_api_device_component {
namespace esphome::custom_api_device_component {
using namespace api;
@@ -28,6 +27,5 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice {
void on_ha_state_changed(std::string entity_id, std::string state);
};
} // namespace custom_api_device_component
} // namespace esphome
} // namespace esphome::custom_api_device_component
#endif // USE_API
@@ -5,8 +5,7 @@
#include <vector>
#include <chrono>
namespace esphome {
namespace defer_stress_component {
namespace esphome::defer_stress_component {
static const char *const TAG = "defer_stress";
@@ -71,5 +70,4 @@ void DeferStressComponent::run_multi_thread_test() {
ESP_LOGI(TAG, "All threads finished in %lldms. Created %d defer requests", thread_time, this->total_defers_.load());
}
} // namespace defer_stress_component
} // namespace esphome
} // namespace esphome::defer_stress_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include <atomic>
namespace esphome {
namespace defer_stress_component {
namespace esphome::defer_stress_component {
class DeferStressComponent : public Component {
public:
@@ -16,5 +15,4 @@ class DeferStressComponent : public Component {
std::atomic<int> executed_defers_{0};
};
} // namespace defer_stress_component
} // namespace esphome
} // namespace esphome::defer_stress_component
@@ -1,7 +1,6 @@
#include "loop_test_component.h"
namespace esphome {
namespace loop_test_component {
namespace esphome::loop_test_component {
void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); }
@@ -63,5 +62,4 @@ void LoopTestUpdateComponent::update() {
this->update_count_, loop_disabled ? "YES" : "NO");
}
} // namespace loop_test_component
} // namespace esphome
} // namespace esphome::loop_test_component
@@ -6,8 +6,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace loop_test_component {
namespace esphome::loop_test_component {
static const char *const TAG = "loop_test_component";
@@ -79,5 +78,4 @@ class LoopTestUpdateComponent : public PollingComponent {
int disable_loop_after_{0};
};
} // namespace loop_test_component
} // namespace esphome
} // namespace esphome::loop_test_component
@@ -2,8 +2,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/application.h"
namespace esphome {
namespace loop_test_component {
namespace esphome::loop_test_component {
static const char *const ISR_TAG = "loop_test_isr_component";
@@ -76,5 +75,4 @@ void IRAM_ATTR LoopTestISRComponent::simulate_isr_enable() {
// For testing, we'll track the call count and log it from the main loop
}
} // namespace loop_test_component
} // namespace esphome
} // namespace esphome::loop_test_component
@@ -4,8 +4,7 @@
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome {
namespace loop_test_component {
namespace esphome::loop_test_component {
class LoopTestISRComponent : public Component {
public:
@@ -28,5 +27,4 @@ class LoopTestISRComponent : public Component {
int isr_call_count_{0};
};
} // namespace loop_test_component
} // namespace esphome
} // namespace esphome::loop_test_component
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace scheduler_bulk_cleanup_component {
namespace esphome::scheduler_bulk_cleanup_component {
static const char *const TAG = "bulk_cleanup";
@@ -68,5 +67,4 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
}
}
} // namespace scheduler_bulk_cleanup_component
} // namespace esphome
} // namespace esphome::scheduler_bulk_cleanup_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include "esphome/core/application.h"
namespace esphome {
namespace scheduler_bulk_cleanup_component {
namespace esphome::scheduler_bulk_cleanup_component {
class SchedulerBulkCleanupComponent : public Component {
public:
@@ -14,5 +13,4 @@ class SchedulerBulkCleanupComponent : public Component {
void trigger_bulk_cleanup();
};
} // namespace scheduler_bulk_cleanup_component
} // namespace esphome
} // namespace esphome::scheduler_bulk_cleanup_component
@@ -6,8 +6,7 @@
#include <chrono>
#include <random>
namespace esphome {
namespace scheduler_heap_stress_component {
namespace esphome::scheduler_heap_stress_component {
static const char *const TAG = "scheduler_heap_stress";
@@ -100,5 +99,4 @@ void SchedulerHeapStressComponent::run_multi_thread_test() {
ESP_LOGI(TAG, "All threads finished in %lldms. Created %d callbacks", thread_time, this->total_callbacks_.load());
}
} // namespace scheduler_heap_stress_component
} // namespace esphome
} // namespace esphome::scheduler_heap_stress_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include <atomic>
namespace esphome {
namespace scheduler_heap_stress_component {
namespace esphome::scheduler_heap_stress_component {
class SchedulerHeapStressComponent : public Component {
public:
@@ -18,5 +17,4 @@ class SchedulerHeapStressComponent : public Component {
std::atomic<int> executed_callbacks_{0};
};
} // namespace scheduler_heap_stress_component
} // namespace esphome
} // namespace esphome::scheduler_heap_stress_component
@@ -6,8 +6,7 @@
#include <random>
#include <sstream>
namespace esphome {
namespace scheduler_rapid_cancellation_component {
namespace esphome::scheduler_rapid_cancellation_component {
static const char *const TAG = "scheduler_rapid_cancellation";
@@ -76,5 +75,4 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() {
});
}
} // namespace scheduler_rapid_cancellation_component
} // namespace esphome
} // namespace esphome::scheduler_rapid_cancellation_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include <atomic>
namespace esphome {
namespace scheduler_rapid_cancellation_component {
namespace esphome::scheduler_rapid_cancellation_component {
class SchedulerRapidCancellationComponent : public Component {
public:
@@ -18,5 +17,4 @@ class SchedulerRapidCancellationComponent : public Component {
std::atomic<int> total_executed_{0};
};
} // namespace scheduler_rapid_cancellation_component
} // namespace esphome
} // namespace esphome::scheduler_rapid_cancellation_component
@@ -1,8 +1,7 @@
#include "recursive_timeout_component.h"
#include "esphome/core/log.h"
namespace esphome {
namespace scheduler_recursive_timeout_component {
namespace esphome::scheduler_recursive_timeout_component {
static const char *const TAG = "scheduler_recursive_timeout";
@@ -36,5 +35,4 @@ void SchedulerRecursiveTimeoutComponent::run_recursive_timeout_test() {
});
}
} // namespace scheduler_recursive_timeout_component
} // namespace esphome
} // namespace esphome::scheduler_recursive_timeout_component
@@ -2,8 +2,7 @@
#include "esphome/core/component.h"
namespace esphome {
namespace scheduler_recursive_timeout_component {
namespace esphome::scheduler_recursive_timeout_component {
class SchedulerRecursiveTimeoutComponent : public Component {
public:
@@ -16,5 +15,4 @@ class SchedulerRecursiveTimeoutComponent : public Component {
int nested_level_{0};
};
} // namespace scheduler_recursive_timeout_component
} // namespace esphome
} // namespace esphome::scheduler_recursive_timeout_component
@@ -5,8 +5,7 @@
#include <chrono>
#include <sstream>
namespace esphome {
namespace scheduler_simultaneous_callbacks_component {
namespace esphome::scheduler_simultaneous_callbacks_component {
static const char *const TAG = "scheduler_simultaneous_callbacks";
@@ -105,5 +104,4 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test()
});
}
} // namespace scheduler_simultaneous_callbacks_component
} // namespace esphome
} // namespace esphome::scheduler_simultaneous_callbacks_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include <atomic>
namespace esphome {
namespace scheduler_simultaneous_callbacks_component {
namespace esphome::scheduler_simultaneous_callbacks_component {
class SchedulerSimultaneousCallbacksComponent : public Component {
public:
@@ -20,5 +19,4 @@ class SchedulerSimultaneousCallbacksComponent : public Component {
std::atomic<int> max_concurrent_{0};
};
} // namespace scheduler_simultaneous_callbacks_component
} // namespace esphome
} // namespace esphome::scheduler_simultaneous_callbacks_component
@@ -4,8 +4,7 @@
#include <thread>
#include <chrono>
namespace esphome {
namespace scheduler_string_lifetime_component {
namespace esphome::scheduler_string_lifetime_component {
static const char *const TAG = "scheduler_string_lifetime";
@@ -258,5 +257,4 @@ void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() {
});
}
} // namespace scheduler_string_lifetime_component
} // namespace esphome
} // namespace esphome::scheduler_string_lifetime_component
@@ -4,8 +4,7 @@
#include <vector>
#include <string>
namespace esphome {
namespace scheduler_string_lifetime_component {
namespace esphome::scheduler_string_lifetime_component {
class SchedulerStringLifetimeComponent : public Component {
public:
@@ -33,5 +32,4 @@ class SchedulerStringLifetimeComponent : public Component {
int tests_failed_{0};
};
} // namespace scheduler_string_lifetime_component
} // namespace esphome
} // namespace esphome::scheduler_string_lifetime_component
@@ -7,8 +7,7 @@
#include <string>
#include <sstream>
namespace esphome {
namespace scheduler_string_name_stress_component {
namespace esphome::scheduler_string_name_stress_component {
static const char *const TAG = "scheduler_string_name_stress";
@@ -106,5 +105,4 @@ void SchedulerStringNameStressComponent::run_string_name_stress_test() {
});
}
} // namespace scheduler_string_name_stress_component
} // namespace esphome
} // namespace esphome::scheduler_string_name_stress_component
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include <atomic>
namespace esphome {
namespace scheduler_string_name_stress_component {
namespace esphome::scheduler_string_name_stress_component {
class SchedulerStringNameStressComponent : public Component {
public:
@@ -18,5 +17,4 @@ class SchedulerStringNameStressComponent : public Component {
std::atomic<int> executed_callbacks_{0};
};
} // namespace scheduler_string_name_stress_component
} // namespace esphome
} // namespace esphome::scheduler_string_name_stress_component
@@ -0,0 +1,9 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
@@ -0,0 +1,9 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
@@ -0,0 +1,36 @@
esphome:
name: test-script-array-params
host:
api:
actions:
- action: run_array_script
then:
- script.execute:
id: array_script
ints: [42, 100]
floats: [1.5, 2.5]
bools: [true, false]
strings: ["hello", "world"]
logger:
level: DEBUG
script:
- id: array_script
parameters:
ints: int[]
floats: float[]
bools: bool[]
strings: string[]
then:
- lambda: |-
ESP_LOGI("test", "ints size=%u [0]=%d [1]=%d",
(unsigned) ints.size(), ints[0], ints[1]);
ESP_LOGI("test", "floats size=%u [0]=%.2f [1]=%.2f",
(unsigned) floats.size(), floats[0], floats[1]);
ESP_LOGI("test", "bools size=%u [0]=%d [1]=%d",
(unsigned) bools.size(), (int) bools[0], (int) bools[1]);
ESP_LOGI("test", "strings size=%u [0]=%s [1]=%s",
(unsigned) strings.size(), strings[0].c_str(), strings[1].c_str());
+152
View File
@@ -0,0 +1,152 @@
"""End-to-end OTA tests on the host platform.
Exercises the native OTA protocol against a real host binary, then asserts
pid is preserved across the post-OTA execv. A second OTA on the post-exec
instance covers the FD_CLOEXEC path.
"""
from __future__ import annotations
import asyncio
from collections.abc import Generator
from contextlib import contextmanager
import socket
import pytest
from esphome import espota2
from .conftest import run_binary, wait_and_connect_api_client
from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-ota-test"
@contextmanager
def _reserve_port() -> Generator[tuple[int, socket.socket]]:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", 0))
try:
yield s.getsockname()[1], s
finally:
s.close()
async def _wait_for_port(host: str, port: int, timeout: float) -> None:
"""Poll until a TCP port accepts connections, or raise TimeoutError."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
try:
_, writer = await asyncio.open_connection(host, port)
except (ConnectionRefusedError, OSError):
await asyncio.sleep(PORT_POLL_INTERVAL)
continue
writer.close()
await writer.wait_closed()
return
raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s")
@pytest.mark.asyncio
async def test_host_ota_self_update(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Self-OTA: upload the running binary back to itself, expect re-exec."""
api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket):
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
api_socket.close()
ota_socket.close()
loop = asyncio.get_running_loop()
ota_staged = loop.create_future()
rebooted = loop.create_future()
def on_log(line: str) -> None:
if not ota_staged.done() and "OTA staged at" in line:
ota_staged.set_result(True)
if not rebooted.done() and "Rebooting safely" in line:
rebooted.set_result(True)
async with run_binary(binary_path, line_callback=on_log) as (proc, _lines):
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid
async with wait_and_connect_api_client(port=api_port) as client:
info_before = await client.device_info()
assert info_before.name == DEVICE_NAME
# espota2 is blocking; run in executor.
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
)
assert rc == 0, "espota2 reported failure"
await asyncio.wait_for(ota_staged, timeout=10.0)
await asyncio.wait_for(rebooted, timeout=10.0)
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
# execv preserves pid; mismatch means external respawn.
assert proc.returncode is None, "process exited instead of execing"
assert proc.pid == pid_before
async with wait_and_connect_api_client(port=api_port) as client:
info_after = await client.device_info()
assert info_after.name == DEVICE_NAME
assert info_after.name == info_before.name
# Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind).
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
)
assert rc == 0, "second OTA failed -- listener leaked across execv"
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
assert proc.pid == pid_before
@pytest.mark.asyncio
async def test_host_ota_rejects_garbage(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
integration_test_dir,
) -> None:
"""Bogus payload is rejected and the device keeps running."""
api_port, api_socket = reserved_tcp_port
with _reserve_port() as (ota_port, ota_socket):
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
# 192 bytes that are neither ELF nor Mach-O.
bogus_path = integration_test_dir / "bogus.bin"
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
api_socket.close()
ota_socket.close()
async with run_binary(binary_path) as (proc, _lines):
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
pid_before = proc.pid
loop = asyncio.get_running_loop()
rc, _ = await loop.run_in_executor(
None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path
)
assert rc == 1
await asyncio.sleep(0.5)
assert proc.returncode is None, "process died on rejected OTA"
assert proc.pid == pid_before
async with wait_and_connect_api_client(port=api_port) as client:
info = await client.device_info()
assert info.name == DEVICE_NAME
@@ -0,0 +1,71 @@
"""Integration test for script array parameters (issue #16367).
Verifies that script parameters of array types (`int[]`, `float[]`, `bool[]`,
`string[]`) compile and execute correctly. Prior to the fix in
`esphome/components/script/__init__.py`, the `script.execute` codegen emitted
the Python `repr` of the list (e.g. `return [42, 100];`) instead of a C++
braced initializer, causing compile failures.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_array_params(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Execute a script with int[], float[], bool[], string[] parameters."""
loop = asyncio.get_running_loop()
seen: dict[str, str] = {}
done = loop.create_future()
patterns = {
"ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"),
"floats": re.compile(
r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)"
),
"bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"),
"strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"),
}
def check_output(line: str) -> None:
for key, pat in patterns.items():
if (m := pat.search(line)) and key not in seen:
seen[key] = m.group(0)
if len(seen) == len(patterns) and not done.done():
done.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
service = next((s for s in services if s.name == "run_array_script"), None)
assert service is not None, "run_array_script service not found"
await client.execute_service(service, {})
try:
await asyncio.wait_for(done, timeout=5.0)
except TimeoutError:
pytest.fail(f"Did not receive all expected log lines. Saw: {seen}")
assert (m := patterns["ints"].search(seen["ints"]))
assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100"
assert (m := patterns["floats"].search(seen["floats"]))
assert m.group(1) == "2" and m.group(2) == "1.50" and m.group(3) == "2.50"
assert (m := patterns["bools"].search(seen["bools"]))
assert m.group(1) == "2" and m.group(2) == "1" and m.group(3) == "0"
assert (m := patterns["strings"].search(seen["strings"]))
assert m.group(1) == "2" and m.group(2) == "hello" and m.group(3) == "world"
@@ -0,0 +1,98 @@
"""Unit tests for script/build_language_schema.py."""
from __future__ import annotations
import ast
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "script"
/ "build_language_schema.py"
)
def _extract_sort_obj():
# build_language_schema.py runs argparse, loads every component, and
# calls build_schema() at import time, so a plain import isn't viable
# in a unit test. Pull just the pure helper out via AST instead.
tree = ast.parse(SCRIPT_PATH.read_text())
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "sort_obj":
namespace: dict = {"S_TYPE": "type"}
module = ast.Module(body=[node], type_ignores=[])
exec(compile(module, str(SCRIPT_PATH), "exec"), namespace)
return namespace["sort_obj"]
raise AssertionError("sort_obj not found in build_language_schema.py")
sort_obj = _extract_sort_obj()
def test_sort_obj_sorts_dict_keys() -> None:
result = sort_obj({"b": 1, "a": 2, "c": 3})
assert list(result.keys()) == ["a", "b", "c"]
def test_sort_obj_sorts_nested_dicts() -> None:
result = sort_obj({"outer": {"z": 1, "a": 2}})
assert list(result["outer"].keys()) == ["a", "z"]
def test_sort_obj_preserves_enum_values_order() -> None:
config = {
"type": "enum",
"values": {
"2MB": None,
"4MB": None,
"8MB": None,
"16MB": None,
"32MB": None,
},
}
result = sort_obj(config)
assert list(result["values"].keys()) == ["2MB", "4MB", "8MB", "16MB", "32MB"]
def test_sort_obj_sorts_non_enum_values_key() -> None:
config = {"type": "schema", "values": {"z": 1, "a": 2}}
result = sort_obj(config)
assert list(result["values"].keys()) == ["a", "z"]
def test_sort_obj_sorts_other_keys_in_enum() -> None:
config = {
"type": "enum",
"default": "4MB",
"key": "Optional",
"values": {"2MB": None, "4MB": None},
}
result = sort_obj(config)
assert list(result.keys()) == ["default", "key", "type", "values"]
assert list(result["values"].keys()) == ["2MB", "4MB"]
def test_sort_obj_recurses_into_enum_value_entries() -> None:
config = {
"type": "enum",
"values": {
"esp32": {"name": "ESP32", "docs": "Original"},
"esp32-c3": {"name": "ESP32-C3", "docs": "RISC-V"},
},
}
result = sort_obj(config)
assert list(result["values"].keys()) == ["esp32", "esp32-c3"]
assert list(result["values"]["esp32"].keys()) == ["docs", "name"]
def test_sort_obj_handles_lists() -> None:
result = sort_obj([{"b": 1, "a": 2}, {"d": 3, "c": 4}])
assert list(result[0].keys()) == ["a", "b"]
assert list(result[1].keys()) == ["c", "d"]
def test_sort_obj_passes_through_scalars() -> None:
assert sort_obj("hello") == "hello"
assert sort_obj(42) == 42
assert sort_obj(None) is None
assert sort_obj(True) is True
+387
View File
@@ -70,6 +70,17 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture
def mock_native_idf_components_to_test() -> Generator[Mock, None, None]:
"""Mock native_idf_components_to_test from determine_jobs.
main() drives both the ``native_idf`` boolean output and the
``native_idf_components`` CSV from this one function.
"""
with patch.object(determine_jobs, "native_idf_components_to_test") as mock:
yield mock
@pytest.fixture
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
"""Mock determine_cpp_unit_tests from helpers."""
@@ -107,6 +118,7 @@ def test_main_all_tests_should_run(
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_native_idf_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -122,6 +134,7 @@ def test_main_all_tests_should_run(
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_native_idf_components_to_test.return_value = ["api", "esp32"]
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -203,6 +216,8 @@ def test_main_all_tests_should_run(
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["native_idf"] is True
assert output["native_idf_components"] == "api,esp32"
assert output["changed_components"] == ["wifi", "api", "sensor"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -236,6 +251,7 @@ def test_main_no_tests_should_run(
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_native_idf_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -251,6 +267,7 @@ def test_main_no_tests_should_run(
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_native_idf_components_to_test.return_value = []
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files
@@ -291,6 +308,8 @@ def test_main_no_tests_should_run(
assert output["python_linters"] is False
assert output["import_time"] is False
assert output["device_builder"] is False
assert output["native_idf"] is False
assert output["native_idf_components"] == ""
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
@@ -313,6 +332,7 @@ def test_main_with_branch_argument(
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_native_idf_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -328,6 +348,7 @@ def test_main_with_branch_argument(
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_native_idf_components_to_test.return_value = ["esp32"]
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -366,6 +387,7 @@ def test_main_with_branch_argument(
mock_should_run_python_linters.assert_called_once_with("main")
mock_should_run_import_time.assert_called_once_with("main")
mock_should_run_device_builder.assert_called_once_with("main")
mock_native_idf_components_to_test.assert_called_once_with("main")
# Check output
captured = capsys.readouterr()
@@ -379,6 +401,8 @@ def test_main_with_branch_argument(
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["native_idf"] is True
assert output["native_idf_components"] == "esp32"
assert output["changed_components"] == ["mqtt"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -827,6 +851,142 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non
mock_changed.assert_not_called()
_NATIVE_IDF_FULL_LIST_FILES = [
# Core C++/Python changes -- caught by core_changed()
["esphome/core/component.cpp"],
["esphome/core/config.py"],
# Native IDF infrastructure paths
["esphome/espidf/framework.py"],
["esphome/espidf/component.py"],
["esphome/espidf/api.py"],
["esphome/build_gen/espidf.py"],
# Workflow / harness files
["script/test_build_components.py"],
[".github/workflows/ci.yml"],
]
@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES)
def test_native_idf_components_to_test_returns_full_list_on_infrastructure(
changed_files: list[str],
) -> None:
"""Infrastructure / core / harness changes fall back to the full component list."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
# The dep-closure path shouldn't be consulted at all -- if it is,
# the obviously-wrong "wifi" sneaks in and the assertion catches it.
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=["wifi"]
),
):
result = determine_jobs.native_idf_components_to_test()
assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS)
@pytest.mark.parametrize(
("changed_files", "dependency_closure", "expected"),
[
# Single tested component changed -- narrow to just that component.
(
["esphome/components/esp32/__init__.py"],
["esp32"],
["esp32"],
),
# Dependency closure: multiple BLE components in the changed set
# are all intersected with the test list and returned sorted.
(
["esphome/components/esp32_ble/ble.cpp"],
["esp32_ble", "esp32_ble_tracker", "ble_scanner"],
["ble_scanner", "esp32_ble", "esp32_ble_tracker"],
),
# api in the test set -- narrow to [api] even though the closure
# has other (unrelated to native-IDF coverage) entries.
(
["esphome/components/api/api_connection.cpp"],
["api", "logger"],
["api"],
),
# Components outside the test set return an empty list (job skipped).
(
["esphome/components/wifi/wifi_component.cpp"],
["wifi", "network"],
[],
),
# Pure Python-only change outside trigger paths -> empty.
(["esphome/yaml_util.py"], [], []),
# Non-IDF files in esphome/build_gen/ do NOT trigger the full
# list -- only esphome/build_gen/espidf.py is a trigger.
(["esphome/build_gen/platformio.py"], [], []),
# Docs / unrelated files -> empty.
(["README.md"], [], []),
([], [], []),
],
)
def test_native_idf_components_to_test_narrowing(
changed_files: list[str],
dependency_closure: list[str],
expected: list[str],
) -> None:
"""Component changes narrow the test list to the intersection."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
patch.object(
determine_jobs,
"get_components_with_dependencies",
return_value=dependency_closure,
),
):
result = determine_jobs.native_idf_components_to_test()
assert result == expected
def test_native_idf_components_to_test_with_branch() -> None:
"""native_idf_components_to_test passes branch argument through.
Regression test: an earlier version called ``get_changed_components()``,
which silently ignored the branch argument because that helper re-runs
``changed_files()`` with its own default. The current implementation
derives the closure from ``files = changed_files(branch)`` directly,
so a branch arg has to flow through ``changed_files``.
"""
with (
patch.object(determine_jobs, "changed_files") as mock_changed,
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
):
mock_changed.return_value = []
determine_jobs.native_idf_components_to_test("release")
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("components_to_test", "expected"),
[
([], False),
(["esp32"], True),
(["esp32", "api"], True),
],
)
def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None:
"""should_run_native_idf is a thin wrapper around the component list."""
with patch.object(
determine_jobs,
"native_idf_components_to_test",
return_value=components_to_test,
):
assert determine_jobs.should_run_native_idf() is expected
def test_should_run_native_idf_with_branch() -> None:
"""Test should_run_native_idf passes branch argument through."""
with patch.object(
determine_jobs, "native_idf_components_to_test", return_value=[]
) as mock_inner:
determine_jobs.should_run_native_idf("release")
mock_inner.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
@@ -2215,3 +2375,230 @@ def test_should_run_benchmarks_with_branch() -> None:
mock_changed.return_value = []
determine_jobs.should_run_benchmarks("release")
mock_changed.assert_called_with("release")
# ---------------------------------------------------------------------------
# _component_change_is_validate_only
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("component", "changed", "expected"),
[
# Only a base validate file changed.
(
"foo",
["tests/components/foo/validate.esp32-idf.yaml"],
True,
),
# Only a validate variant changed.
(
"foo",
["tests/components/foo/validate-legacy.esp32-idf.yaml"],
True,
),
# Multiple validate files (all validate).
(
"foo",
[
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/foo/validate-legacy.esp32-idf.yaml",
],
True,
),
# Mixed: validate + regular test must NOT be classified as validate-only.
(
"foo",
[
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/foo/test.esp32-idf.yaml",
],
False,
),
# Regular test only.
(
"foo",
["tests/components/foo/test.esp32-idf.yaml"],
False,
),
# Source change disqualifies even if a validate file is also touched.
(
"foo",
[
"esphome/components/foo/foo.cpp",
"tests/components/foo/validate.esp32-idf.yaml",
],
False,
),
# No matching files at all.
("foo", ["esphome/core/helpers.cpp"], False),
# Filenames merely starting with "validate" but not following the
# grammar must not match (defensive against accidental classification).
(
"foo",
["tests/components/foo/validatesomething.yaml"],
False,
),
# An unrelated component's validate change doesn't affect this one.
(
"foo",
["tests/components/bar/validate.esp32-idf.yaml"],
False,
),
# common.yaml change in the component dir disqualifies.
(
"foo",
[
"tests/components/foo/common.yaml",
"tests/components/foo/validate.esp32-idf.yaml",
],
False,
),
],
)
def test_component_change_is_validate_only(
component: str, changed: list[str], expected: bool
) -> None:
"""The validate-only classifier rejects anything beyond validate.* edits."""
assert (
determine_jobs._component_change_is_validate_only(component, changed)
is expected
)
def test_main_emits_validate_only_components(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Directly-changed components whose only edits are validate.*.yaml are
listed in `validate_only_components` so CI can skip their compile stage.
"""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_determine_cpp_unit_tests.return_value = (False, [])
# foo: only validate file changed (qualifies)
# bar: test file changed (does not qualify)
mock_changed_files.return_value = [
"tests/components/foo/validate.esp32-idf.yaml",
"tests/components/bar/test.esp32-idf.yaml",
]
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs,
"get_changed_components",
return_value=["foo", "bar"],
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("tests/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
side_effect=lambda files, deps: ["foo", "bar"],
),
patch.object(determine_jobs, "_component_has_tests", return_value=True),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["foo", "bar"]], {}),
),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
assert output["validate_only_components"] == ["foo"]
def test_main_validate_only_excludes_transitive_components(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A component pulled in only as a dependency must NOT be considered
validate-only, even if it has no source changes -- its dependency moved,
so the compile is still required.
"""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_determine_cpp_unit_tests.return_value = (False, [])
# Only foo's validate file changed directly. bar is a transitive dep.
mock_changed_files.return_value = [
"tests/components/foo/validate.esp32-idf.yaml",
]
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs,
"get_changed_components",
return_value=["foo", "bar"], # bar pulled in via dependencies
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("tests/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
# deps=False -> directly_changed = [foo]; deps=True -> [foo, bar]
side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"],
),
patch.object(determine_jobs, "_component_has_tests", return_value=True),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["foo", "bar"]], {}),
),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
# Only foo (directly changed, validate-only). bar is a transitive dep
# and still needs compile despite no source change of its own.
assert output["validate_only_components"] == ["foo"]
+168
View File
@@ -1624,3 +1624,171 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
platform, signature = next(iter(extra))
assert platform == "esp32"
assert signature.startswith("i2c__conflict")
# ---------------------------------------------------------------------------
# get_component_test_files / is_validate_only_file
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_component_tests(tmp_path: Path) -> Path:
"""Create a fake tests/components/ tree and return the repo root.
Layout for component "demo":
test.esp32-idf.yaml
test.esp8266-ard.yaml
test-variant.esp32-idf.yaml
validate.esp32-idf.yaml
validate-legacy.esp32-idf.yaml
Layout for component "validate_only":
validate.esp32-idf.yaml (only validate files)
Layout for component "no_tests":
common.yaml (no test/validate files at all)
"""
tests_dir = tmp_path / "tests" / "components"
demo = tests_dir / "demo"
demo.mkdir(parents=True)
(demo / "test.esp32-idf.yaml").write_text("")
(demo / "test.esp8266-ard.yaml").write_text("")
(demo / "test-variant.esp32-idf.yaml").write_text("")
(demo / "validate.esp32-idf.yaml").write_text("")
(demo / "validate-legacy.esp32-idf.yaml").write_text("")
validate_only = tests_dir / "validate_only"
validate_only.mkdir(parents=True)
(validate_only / "validate.esp32-idf.yaml").write_text("")
no_tests = tests_dir / "no_tests"
no_tests.mkdir(parents=True)
(no_tests / "common.yaml").write_text("")
return tmp_path
def _names(paths: list[Path]) -> set[str]:
return {p.name for p in paths}
def test_get_component_test_files_default_excludes_validate(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""Default behaviour: only base test.*.yaml; no variants, no validate."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo")
assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"}
def test_get_component_test_files_all_variants_excludes_validate(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""all_variants=True picks up test variants but still skips validate."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo", all_variants=True)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"test-variant.esp32-idf.yaml",
}
def test_get_component_test_files_include_validate_base_only(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""include_validate=True with base-only adds validate.*.yaml only."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files("demo", include_validate=True)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"validate.esp32-idf.yaml",
}
def test_get_component_test_files_include_validate_all_variants(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""include_validate=True with all_variants adds validate variants too."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
files = helpers.get_component_test_files(
"demo", all_variants=True, include_validate=True
)
assert _names(files) == {
"test.esp32-idf.yaml",
"test.esp8266-ard.yaml",
"test-variant.esp32-idf.yaml",
"validate.esp32-idf.yaml",
"validate-legacy.esp32-idf.yaml",
}
def test_get_component_test_files_validate_only_component(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""A component with only validate files is invisible without the flag."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert helpers.get_component_test_files("validate_only") == []
assert helpers.get_component_test_files("validate_only", all_variants=True) == []
files = helpers.get_component_test_files(
"validate_only", all_variants=True, include_validate=True
)
assert _names(files) == {"validate.esp32-idf.yaml"}
def test_get_component_test_files_missing_component(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""Unknown components return an empty list, regardless of flags."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert (
helpers.get_component_test_files(
"does_not_exist", all_variants=True, include_validate=True
)
== []
)
def test_get_component_test_files_component_without_tests(
fake_component_tests: Path, monkeypatch: MonkeyPatch
) -> None:
"""A component with only common.yaml and no test/validate files returns []."""
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
assert (
helpers.get_component_test_files(
"no_tests", all_variants=True, include_validate=True
)
== []
)
@pytest.mark.parametrize(
("filename", "expected"),
[
("validate.esp32-idf.yaml", True),
("validate-legacy.esp32-idf.yaml", True),
("validate.host.yaml", True),
("test.esp32-idf.yaml", False),
("test-variant.esp32-idf.yaml", False),
("common.yaml", False),
# Defensive: a hypothetical name starting with "validate" but not
# following the grammar must not be classified as a validate file.
("validatesomething.yaml", False),
],
)
def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None:
assert helpers.is_validate_only_file(tmp_path / filename) is expected
+159
View File
@@ -0,0 +1,159 @@
"""Tests for esphome.build_gen.espidf module."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.components.esp32 import (
KEY_COMPONENTS,
KEY_ESP32,
KEY_PATH,
KEY_REF,
KEY_REPO,
)
from esphome.const import KEY_CORE
from esphome.core import CORE
@pytest.fixture(autouse=True)
def _reset_core(tmp_path: Path) -> None:
"""Give each test its own CORE.build_path and a clean esp32 data slot."""
CORE.build_path = str(tmp_path)
CORE.data.setdefault(KEY_CORE, {})
CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}}
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
"""Stub a project_description.json with the given component_name -> dir map."""
build_dir = tmp_path / "build"
build_dir.mkdir(exist_ok=True)
(build_dir / "project_description.json").write_text(
json.dumps(
{
"build_component_info": {
name: {"dir": dir_} for name, dir_ in components.items()
}
}
)
)
def test_get_available_components_returns_none_without_build_path() -> None:
"""No build_path set yet: must not raise on Path(None)."""
CORE.build_path = None
from esphome.build_gen.espidf import get_available_components
assert get_available_components() is None
def test_get_available_components_returns_none_without_project_description(
tmp_path: Path,
) -> None:
from esphome.build_gen.espidf import get_available_components
assert get_available_components() is None
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
_write_project_description(
tmp_path,
{
"src": f"{tmp_path}/src",
"esp_lcd": "/idf/components/esp_lcd",
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
"freertos": "/idf/components/freertos",
},
)
from esphome.build_gen.espidf import get_available_components
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
tmp_path: Path,
) -> None:
"""Minimal write must not emit ESPHOME_PROJECT_BUILTIN_COMPONENTS even
when project_description.json exists (the data may be stale on the
first write before the discovery pass refreshes it)."""
_write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"})
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
from esphome.build_gen.espidf import get_project_cmakelists
content = get_project_cmakelists(minimal=True)
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content
def test_get_project_cmakelists_full_emits_builtin_components_property(
tmp_path: Path,
) -> None:
"""Non-minimal write emits one idf_build_set_property line per built-in,
sorted, and excludes src/managed/pio components."""
_write_project_description(
tmp_path,
{
"src": f"{tmp_path}/src",
"esp_lcd": "/idf/components/esp_lcd",
"freertos": "/idf/components/freertos",
"espressif__esp-dsp": f"{tmp_path}/managed_components/esp-dsp",
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
},
)
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
from esphome.build_gen.espidf import get_project_cmakelists
content = get_project_cmakelists(minimal=False)
assert (
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)"
in content
)
assert (
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)"
in content
)
# Excluded by get_available_components filtering.
assert "espressif__esp-dsp APPEND" not in content
assert "JPEGDEC APPEND" not in content
def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path,
) -> None:
"""ESPHOME_PROJECT_MANAGED_COMPONENTS is always emitted (both modes)
from the esp32 add_idf_component registry."""
CORE.data[KEY_ESP32][KEY_COMPONENTS] = {
"espressif/esp-dsp": {KEY_REPO: None, KEY_REF: "1.7.1", KEY_PATH: None},
"espressif/arduino-esp32": {KEY_REPO: None, KEY_REF: "3.3.8", KEY_PATH: None},
}
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
):
from esphome.build_gen.espidf import get_project_cmakelists
for minimal in (True, False):
content = get_project_cmakelists(minimal=minimal)
assert (
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
" espressif__arduino-esp32 APPEND)"
) in content
assert (
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
" espressif__esp-dsp APPEND)"
) in content
@@ -0,0 +1,29 @@
"""Tests for RP2040 component public helpers."""
from esphome.components.rp2040 import board_id_has_wifi
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
+125
View File
@@ -0,0 +1,125 @@
"""Tests for WiFi component public helpers."""
import pytest
from esphome.components.esp32 import const
from esphome.components.wifi import has_native_wifi, variant_has_wifi
from esphome.const import Platform
@pytest.mark.parametrize(
"variant",
[
# Upstream's canonical uppercase form.
const.VARIANT_ESP32,
const.VARIANT_ESP32S2,
const.VARIANT_ESP32S3,
const.VARIANT_ESP32C3,
const.VARIANT_ESP32C6,
# Lowercase form external callers (e.g. device-builder's
# ``Esp32Variant`` StrEnum) surface.
"esp32",
"esp32s3",
"esp32c3",
# Mixed-case — defence in depth against future callers that
# pull the value off some other serialisation.
"Esp32",
],
)
def test_variant_has_wifi_for_native_phy_variants(variant: str) -> None:
"""Variants with a native WiFi PHY → True, case-insensitive."""
assert variant_has_wifi(variant) is True
@pytest.mark.parametrize(
"variant",
[
# Upstream's canonical uppercase form.
const.VARIANT_ESP32H2,
const.VARIANT_ESP32P4,
# Lowercase form external callers (e.g. device-builder's
# ``Esp32Variant`` StrEnum) surface.
"esp32h2",
"esp32p4",
# Mixed-case — defence in depth against future callers that
# pull the value off some other serialisation.
"Esp32H2",
],
)
def test_variant_has_wifi_for_no_phy_variants(variant: str) -> None:
"""Variants that need ``esp32_hosted`` → False, case-insensitive."""
assert variant_has_wifi(variant) is False
def test_has_native_wifi_dispatches_esp32_to_variant_check() -> None:
"""ESP32 platform routes through ``variant_has_wifi``."""
assert (
has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32C3) is True
)
assert (
has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32H2) is False
)
def test_has_native_wifi_esp32_variant_case_insensitive() -> None:
"""has_native_wifi accepts lowercase variant input.
External callers (device-builder's wizard, etc.) may surface
variant strings from their own enums that don't match upstream's
uppercase convention. The dispatcher should classify them
identically.
"""
assert has_native_wifi(platform=Platform.ESP32, variant="esp32h2") is False
assert has_native_wifi(platform=Platform.ESP32, variant="esp32c3") is True
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
def test_has_native_wifi_returns_false_for_nrf52() -> None:
"""nRF52 family is BLE-only — no Wi-Fi PHY in the platform."""
assert has_native_wifi(platform=Platform.NRF52) is False
def test_has_native_wifi_returns_false_for_host() -> None:
"""``host`` platform compiles ESPHome to a host binary — no radio at all."""
assert has_native_wifi(platform=Platform.HOST) is False
def test_has_native_wifi_returns_false_for_unknown_platform() -> None:
"""Unknown platform string fails closed.
A future platform added to ESPHome that's missed here returns
False rather than silently emitting a ``wifi:`` block external
tooling would have to compile and reject — fail-closed surfaces
the gap as an obvious "needs wifi support added" signal.
"""
assert has_native_wifi(platform="not-a-real-platform") is False
@pytest.mark.parametrize(
"platform",
[
Platform.ESP8266,
Platform.BK72XX,
Platform.RTL87XX,
Platform.LN882X,
Platform.LIBRETINY_OLDSTYLE,
],
)
def test_has_native_wifi_returns_true_for_wifi_first_platforms(platform: str) -> None:
"""Catch-all Wi-Fi-first platforms → True regardless of board / variant."""
assert has_native_wifi(platform=platform) is True
def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
"""ESP32 without a variant id falls open to True (the chip family default)."""
assert has_native_wifi(platform=Platform.ESP32) is True
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
+8 -8
View File
@@ -64,15 +64,15 @@ def mock_copy_file_if_changed() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_run_platformio_cli() -> Generator[Mock, None, None]:
"""Mock run_platformio_cli for platformio_api."""
with patch("esphome.platformio_api.run_platformio_cli") as mock:
"""Mock run_platformio_cli for platformio toolchain."""
with patch("esphome.platformio.toolchain.run_platformio_cli") as mock:
yield mock
@pytest.fixture
def mock_run_platformio_cli_run() -> Generator[Mock, None, None]:
"""Mock run_platformio_cli_run for platformio_api."""
with patch("esphome.platformio_api.run_platformio_cli_run") as mock:
"""Mock run_platformio_cli_run for platformio toolchain."""
with patch("esphome.platformio.toolchain.run_platformio_cli_run") as mock:
yield mock
@@ -92,8 +92,8 @@ def mock_esp8266_decode_pc() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_run_external_process() -> Generator[Mock, None, None]:
"""Mock run_external_process for platformio_api."""
with patch("esphome.platformio_api.run_external_process") as mock:
"""Mock run_external_process for platformio toolchain."""
with patch("esphome.platformio.toolchain.run_external_process") as mock:
yield mock
@@ -113,8 +113,8 @@ def mock_subprocess_run() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_get_idedata() -> Generator[Mock, None, None]:
"""Mock get_idedata for platformio_api."""
with patch("esphome.platformio_api.get_idedata") as mock:
"""Mock get_idedata for platformio toolchain."""
with patch("esphome.platformio.toolchain.get_idedata") as mock:
yield mock
+282
View File
@@ -0,0 +1,282 @@
"""Tests for the validated-config cache used by upload/logs."""
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.__main__ import run_esphome
from esphome.compiled_config import (
compiled_config_path,
load_compiled_config,
save_compiled_config,
)
from esphome.const import (
CONF_API,
CONF_ESPHOME,
CONF_NAME,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE
_VALIDATED_CONFIG_YAML = """\
esphome:
name: lite_test
friendly_name: Lite Test Device
esp32:
board: nodemcu-32s
logger:
baud_rate: 115200
api:
port: 6053
encryption:
key: 6dGhpcyBpcyBhIHRlc3Q=
ota:
- platform: esphome
port: 3232
password: secret
wifi:
ssid: ssid
use_address: 192.168.1.42
"""
def _write_storage(storage_path: Path) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"storage_version": 1,
"name": "lite_test",
"friendly_name": "Lite Test Device",
"comment": None,
"esphome_version": "2026.1.0",
"src_version": 1,
"address": "192.168.1.42",
"web_port": None,
"esp_platform": "ESP32",
"build_path": "/build/lite_test",
"firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [],
"no_mdns": False,
"framework": "arduino",
"core_platform": "esp32",
}
storage_path.write_text(json.dumps(data))
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
"""Write the cache file and return it."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body)
return cache_path
def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
"""Force the cache file's mtime relative to the source YAML.
Positive offset → cache is fresh. Negative → cache is stale.
"""
yaml_stat = yaml_path.stat()
os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset))
@pytest.fixture
def fresh_cache_files(tmp_path: Path) -> Path:
"""YAML + StorageJSON + cache, all consistent and fresh."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
return yaml_path
def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
"""The cache file shape is predictable from the YAML filename."""
path = compiled_config_path("device.yaml")
assert path.name == "device.yaml.validated.yaml"
assert path.parent.name == "storage"
def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
"""Fresh cache + sidecar → returns config and populates CORE."""
config = load_compiled_config(fresh_cache_files)
assert config is not None
assert config[CONF_ESPHOME][CONF_NAME] == "lite_test"
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
assert config["ota"][0]["password"] == "secret"
# apply_to_core populated exactly what upload/logs read off CORE.
assert CORE.name == "lite_test"
assert CORE.build_path == Path("/build/lite_test")
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
@pytest.mark.parametrize(
"scenario",
["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"],
)
def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
"""All non-happy cases return None so the caller falls back."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
cache_path = storage_dir / "lite_test.yaml.validated.yaml"
sidecar_path = storage_dir / "lite_test.yaml.json"
if scenario == "missing_cache":
pass # no cache, no sidecar
elif scenario == "stale_cache":
_write_storage(sidecar_path)
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60)
elif scenario == "corrupt_cache":
_write_storage(sidecar_path)
_set_cache_mtime(
_write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5
)
elif scenario == "missing_sidecar":
# Cache fresh + parseable, but no StorageJSON → can't populate CORE.
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5)
assert load_compiled_config(yaml_path) is None
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
command: str,
fresh_cache_files: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""upload/logs skip read_config() when the cache is fresh."""
captured: dict = {}
def _stub(_args, config):
captured["config"] = config
return 0
with (
caplog.at_level("INFO", logger="esphome.__main__"),
patch("esphome.__main__.read_config") as mock_read,
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
):
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
mock_read.assert_not_called()
assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test"
assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
# The success-branch log line is part of the patch; assert on it so
# branch coverage stays unambiguous in CI.
assert "Loaded validated config cache" in caplog.text
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
tmp_path: Path, command: str
) -> None:
"""Without a cache, the dispatcher falls back to read_config()."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
),
):
assert run_esphome(["esphome", command, str(yaml_path)]) == 2
mock_read.assert_called_once()
def test_run_esphome_upload_with_substitution_skips_cache(
fresh_cache_files: Path,
) -> None:
"""`-s key value` forces a fresh validation -- the cache was written
against the prior substitution set, so reusing it would silently
ignore the override."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
),
):
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
mock_read.assert_called_once()
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
"""The compile subcommand always re-validates -- it's what writes the cache."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0},
),
):
run_esphome(["esphome", "compile", str(fresh_cache_files)])
mock_read.assert_called_once()
def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
"""`save_compiled_config` writes the dumped YAML next to the sidecar."""
CORE.config_path = tmp_path / "lite_test.yaml"
save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})
cache_path = compiled_config_path("lite_test.yaml")
assert cache_path.is_file()
body = cache_path.read_text()
assert "name: lite_test" in body
assert "logger:" in body
def test_save_compiled_config_swallows_dump_errors(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Failures during the dump are non-fatal -- a bad cache just means
the next fast path falls back to read_config()."""
CORE.config_path = tmp_path / "lite_test.yaml"
with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")):
save_compiled_config({"esphome": {"name": "lite_test"}})
assert not compiled_config_path("lite_test.yaml").exists()
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
can't drive upload/logs, so the fast path falls back."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
storage_dir.mkdir(parents=True, exist_ok=True)
# StorageJSON with both core_platform and target_platform unset.
(storage_dir / "lite_test.yaml.json").write_text(
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
'"comment": null, "esphome_version": null, "src_version": 1, '
'"address": null, "web_port": null, "esp_platform": null, '
'"build_path": null, "firmware_bin_path": null, '
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
'"framework": null, "core_platform": null}'
)
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache_path, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is None
+184
View File
@@ -793,3 +793,187 @@ def test_update_interval__never_passes_through() -> None:
"""update_interval: never must still map to SCHEDULER_DONT_RUN."""
result = config_validation.update_interval("never")
assert result.total_milliseconds == SCHEDULER_DONT_RUN
# ---------------------------------------------------------------------------
# Visibility UI-hint kwarg
# ---------------------------------------------------------------------------
def test_optional_default_visibility_is_none() -> None:
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
Consumers can read the attribute directly with plain attribute
access; absence (``None``) means "render on the editor's main
form."
"""
o = config_validation.Optional("foo")
assert o.visibility is None
def test_optional_visibility_advanced() -> None:
"""``visibility=Visibility.ADVANCED`` is recorded on the marker."""
o = config_validation.Optional(
"foo", visibility=config_validation.Visibility.ADVANCED
)
assert o.visibility is config_validation.Visibility.ADVANCED
def test_optional_visibility_yaml_only() -> None:
"""``visibility=Visibility.YAML_ONLY`` is recorded on the marker."""
o = config_validation.Optional(
"foo", visibility=config_validation.Visibility.YAML_ONLY
)
assert o.visibility is config_validation.Visibility.YAML_ONLY
def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits.
The schema bundle consumers (catalog generators, third-party
schema-aware tooling) shouldn't need an enum import to read the
field — pinning the on-the-wire spelling here keeps the dump
contract stable.
"""
assert str(config_validation.Visibility.ADVANCED) == "advanced"
assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only"
def test_optional_visibility_does_not_affect_validation() -> None:
"""The kwarg is an advisory UI hint — it must not change how the
validator behaves. A schema with ``visibility`` applied must
accept and reject the same values it would without it.
"""
plain = config_validation.Schema(
{config_validation.Optional("foo", default=42): config_validation.int_}
)
flagged = config_validation.Schema(
{
config_validation.Optional(
"foo",
default=42,
visibility=config_validation.Visibility.YAML_ONLY,
): config_validation.int_
}
)
# Same accept / default-fill behavior.
assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7}
assert plain({}) == flagged({}) == {"foo": 42}
# Same rejection on bad input.
with pytest.raises(Invalid):
plain({"foo": "not-an-int"})
with pytest.raises(Invalid):
flagged({"foo": "not-an-int"})
def test_required_default_visibility_is_none() -> None:
"""``Required`` mirrors ``Optional`` for the ``visibility`` kwarg."""
r = config_validation.Required("foo")
assert r.visibility is None
def test_required_visibility_kwarg() -> None:
"""``Required`` accepts ``visibility`` for symmetry with ``Optional``.
Required fields rarely need the kwarg, but exposing it lets
consumers apply uniform logic across key markers.
"""
r = config_validation.Required(
"foo", visibility=config_validation.Visibility.ADVANCED
)
assert r.visibility is config_validation.Visibility.ADVANCED
def test_polling_component_schema_visibility_opt_in() -> None:
"""``visibility=`` propagates to the inherited ``update_interval``.
Time platforms pass ``Visibility.ADVANCED``; sensors and other
polling components leave it ``None`` and keep the un-flagged shape.
"""
default = config_validation.polling_component_schema("15min")
advanced = config_validation.polling_component_schema(
"15min", visibility=config_validation.Visibility.ADVANCED
)
default_keys = {str(k): k for k in default.schema}
advanced_keys = {str(k): k for k in advanced.schema}
assert default_keys["update_interval"].visibility is None
assert (
advanced_keys["update_interval"].visibility
is config_validation.Visibility.ADVANCED
)
# The opt-in only touches update_interval — setup_priority
# still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA
# in both shapes.
assert (
default_keys["setup_priority"].visibility
is config_validation.Visibility.YAML_ONLY
)
assert (
advanced_keys["setup_priority"].visibility
is config_validation.Visibility.YAML_ONLY
)
def test_polling_component_schema_no_default_ignores_visibility() -> None:
"""``visibility`` is silently ignored when the field is Required.
When ``default_update_interval=None`` the field becomes
``Required``. Hiding a Required field behind an advanced
disclosure is a UX hazard — a collapsed-by-default editor could
let the user submit without noticing the form has an unfilled
required field. The helper accepts the kwarg unconditionally
for caller ergonomics but doesn't honour it on this branch.
"""
schema = config_validation.polling_component_schema(
None, visibility=config_validation.Visibility.ADVANCED
)
keys = {str(k): k for k in schema.schema}
assert isinstance(keys["update_interval"], config_validation.Required)
assert keys["update_interval"].visibility is None
def test_visibility_marker_is_per_field_no_mutation() -> None:
"""Each field's ``visibility`` is recorded as the author wrote it.
Cascading semantics — "a stricter parent forces its descendants
at-least as strict" — live on the consumer side, not in the
marker itself. The schema marker stays as-written so consumers
can walk the parent chain and compute the effective visibility
themselves; mutating the marker would lose the per-field author
intent.
Pin both directions of the no-mutation contract: an inner
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``
on the marker (the consumer's effective-visibility cascade
would also report ``YAML_ONLY`` since it's stricter), and an
un-marked inner field stays ``None`` on the marker (the
cascade's job is to compute ``ADVANCED`` from the parent — a
detail this test deliberately doesn't pin, since it's a
consumer concern).
"""
inner_unset = config_validation.Optional("baz")
inner_yaml_only = config_validation.Optional(
"qux", visibility=config_validation.Visibility.YAML_ONLY
)
parent = config_validation.Optional(
"foo", visibility=config_validation.Visibility.ADVANCED
)
# Wire them into a nested schema — none of the markers' own
# ``visibility`` should change as a result.
schema = config_validation.Schema(
{
parent: config_validation.Schema(
{
inner_unset: config_validation.int_,
inner_yaml_only: config_validation.string,
}
)
}
)
assert schema # touch the schema so any deferred mutation runs
assert parent.visibility is config_validation.Visibility.ADVANCED
assert inner_unset.visibility is None
assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY
+41
View File
@@ -591,6 +591,30 @@ class TestEsphomeCore:
assert target.is_esp32 is False
assert target.is_esp8266 is True
def test_firmware_bin__default(self, target):
"""Default platforms produce <pioenvs>/<name>/firmware.bin."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.firmware_bin == Path(
"foo/build/.pioenvs/test-device/firmware.bin"
)
def test_firmware_bin__libretiny(self, target):
"""The libretiny platform produces firmware.uf2."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"}
assert target.firmware_bin == Path(
"foo/build/.pioenvs/test-device/firmware.uf2"
)
def test_firmware_bin__host(self, target):
"""Host platform produces a native ELF/Mach-O named `program`,
not firmware.bin -- needed for `esphome upload` to find the
right artifact for the host OTA backend."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"}
assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program")
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
def test_data_dir_default_unix(self, target):
"""Test data_dir returns .esphome in config directory by default on Unix."""
@@ -853,6 +877,23 @@ class TestEsphomeCore:
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 3
def test_bootloader_bin__native_idf(self, target):
"""Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin."""
target.toolchain = const.Toolchain.ESP_IDF
assert target.bootloader_bin == Path(
"foo/build/build/bootloader/bootloader.bin"
)
def test_bootloader_bin__platformio(self, target):
"""For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
assert target.bootloader_bin == Path(
"foo/build/.pioenvs/test-device/bootloader.bin"
)
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
+487
View File
@@ -0,0 +1,487 @@
import json
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
)
from esphome.core import CORE, Library
import esphome.espidf.component
from esphome.espidf.component import (
GitSource,
IDFComponent,
InvalidIDFComponent,
URLSource,
_check_library_data,
_collect_filtered_files,
_convert_library_to_component,
_parse_library_json,
_parse_library_properties,
_process_dependencies,
_split_list_by_condition,
generate_cmakelists_txt,
generate_idf_component_yml,
)
@pytest.fixture(name="tmp_component")
def fixture_tmp_component(tmp_path):
c = IDFComponent("owner/name", "1.0.0", source=MagicMock())
c.path = tmp_path
return c
@pytest.fixture(name="esp32_idf_core")
def fixture_esp32_idf_core():
CORE.data[KEY_CORE] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = str(Platform.ESP32)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ESP_IDF)
def test_idf_component_str():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert str(c) == "foo/bar@1.0=http://dummy.com"
def test_idf_component_sanitized_name():
c = IDFComponent("foo/bar bar-bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_sanitized_name() == "foo/bar_bar-bar"
def test_idf_component_require_name():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_require_name() == "foo__bar"
def test_collect_filtered_files_basic(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b" / "b.cpp"
f1.write_text("int a;")
f2.parent.mkdir(parents=True)
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*>"])
assert str(f1) in result
assert str(f2) in result
def test_collect_filtered_files_exclude(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b.cpp"
f1.write_text("int a;")
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
assert str(f1) in result
assert str(f2) not in result
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
matched, rest = _split_list_by_condition(
items, lambda x: x[2:] if x.startswith("-I") else None
)
assert matched == ["include"]
assert "-Llib" in rest
assert "-Wall" in rest
def test_generate_cmakelists_txt_basic(tmp_component):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
f = src_dir / "main.c"
f.write_text("int main() {}")
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert "idf_component_register" in content
assert "main.c" in content
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {
"build": {"flags": ["-Iinclude", "-Llib", "-lmylib", "-Wall", "-DTEST"]}
}
content = generate_cmakelists_txt(tmp_component)
sep = "\\\\" if os.name == "nt" else "/"
assert (
content
== f"""idf_component_register(
SRCS "src{sep}main.c"
INCLUDE_DIRS "src"
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
target_compile_options(${{COMPONENT_LIB}} PUBLIC
"-DTEST"
)
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"-Wall"
)
target_link_directories(${{COMPONENT_LIB}} INTERFACE
"lib"
)
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
"mylib"
)
"""
)
def test_generate_cmakelists_txt_references_project_managed_components_variable(
tmp_component: IDFComponent,
) -> None:
# The CMakeLists is cached under pio_components/<hash>/ and shared
# across projects, so the project-managed REQUIRES list is exposed via
# a CMake variable expanded at configure time rather than baked here.
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content
def test_generate_idf_component_overwrites_bundled_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# A library that ships its own CMakeLists.txt + idf_component.yml must
# have both replaced by ESPHome's generated content. Library authors'
# bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded
# frameworks), so we always regenerate from library.json.
from esphome.espidf.component import _generate_idf_component
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.cpp").write_text("// dummy\n")
(tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"}))
(tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n")
(tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n")
fake_component = IDFComponent(
"owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy")
)
fake_component.path = tmp_path
monkeypatch.setattr(
esphome.espidf.component,
"_convert_library_to_component",
lambda _lib: fake_component,
)
monkeypatch.setattr(fake_component, "download", lambda force=False: None)
_generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None))
cml = (tmp_path / "CMakeLists.txt").read_text()
manifest = (tmp_path / "idf_component.yml").read_text()
assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml
assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest
assert "idf_component_register" in cml
def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component)
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n"
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {}
result = generate_idf_component_yml(tmp_component)
assert (
result
== f"""version: 1.0.0
dependencies:
dep:
version: '1.0'
override_path: {dep.path}
"""
)
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
# A dep without a path and without a recognised source should re-raise
# the underlying RuntimeError instead of silently producing a bad manifest.
dep = IDFComponent("foo/bar", "1.0", source=None)
tmp_component.dependencies = [dep]
tmp_component.data = {}
with pytest.raises(RuntimeError):
generate_idf_component_yml(tmp_component)
def test_check_library_data_valid(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "*"})
def test_check_library_data_valid2(esp32_idf_core):
_check_library_data({"platforms": "*"})
def test_check_library_data_valid3(esp32_idf_core):
_check_library_data({})
def test_check_library_data_valid4(esp32_idf_core):
_check_library_data({"platforms": "espressif32", "frameworks": "*"})
def test_check_library_data_valid5(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "espidf"})
def test_check_library_data_invalid_platform(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": ["other"], "frameworks": "*"})
def test_check_library_data_invalid_framework(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": "*", "frameworks": ["other"]})
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
(tmp_path / "src" / "esp32").mkdir(parents=True)
script = tmp_path / "extra_script.py"
script.write_text(
"Import('env')\n"
"mcu = env.get('BOARD_MCU')\n"
"env.Append(\n"
" LIBPATH=[join('src', mcu)],\n"
" LIBS=['algobsec'],\n"
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
" LINKFLAGS=['-Wl,--gc-sections'],\n"
")\n"
)
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
# where this is in scope). Inject it via the script header so the
# shim's exec namespace can resolve it.
script.write_text("from os.path import join\n" + script.read_text())
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == [os.path.join("src", "esp32")]
assert result.libs == ["algobsec"]
assert ("BAR", "1") in result.cppdefines
assert "FOO" in result.cppdefines
assert result.linkflags == ["-Wl,--gc-sections"]
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert f"-Lsrc{sep}esp32" in flags
assert "-lalgobsec" in flags
assert "-DFOO" in flags
assert "-DBAR=1" in flags
assert "-Wl,--gc-sections" in flags
def test_extra_script_libpath_relative_resolves_against_library_dir(
tmp_path, monkeypatch
):
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
runs)."""
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
(tmp_path / "lib" / "esp32").mkdir(parents=True)
elsewhere = tmp_path.parent / "not_the_library_dir"
elsewhere.mkdir(exist_ok=True)
monkeypatch.chdir(elsewhere)
result = ExtraScriptResult(libpath=["lib/esp32"])
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert flags == [f"-Llib{sep}esp32"]
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
outside = tmp_path.parent / "system_lib"
outside.mkdir(exist_ok=True)
result = ExtraScriptResult(libpath=[str(outside)])
flags = captured_as_build_flags(result, library_dir=tmp_path)
assert flags == [f"-L{outside.resolve()}"]
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
from esphome.espidf.extra_script import run_extra_script
script = tmp_path / "broken.py"
script.write_text("raise RuntimeError('boom')\n")
with caplog.at_level("WARNING"):
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == []
assert result.libs == []
assert "broken.py" in caplog.text
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
from esphome.espidf.component import _apply_extra_script
library_dir = tmp_path / "lib"
library_dir.mkdir()
outside = tmp_path / "evil.py"
outside.write_text("env.Append(LIBS=['pwned'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = library_dir
c.data = {"build": {"extraScript": "../evil.py"}}
_apply_extra_script(c)
# Nothing was folded into flags: the traversal was rejected before
# the script could run.
assert "flags" not in c.data["build"]
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
from esphome.components import esp32 as esp32_module
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
from esphome.espidf.component import _apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=['algobsec'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
_apply_extra_script(c)
assert "-DEXISTING" in c.data["build"]["flags"]
assert "-lalgobsec" in c.data["build"]["flags"]
def test_parse_library_json(tmp_path):
f = tmp_path / "library.json"
f.write_text(json.dumps({"name": "test"}))
result = _parse_library_json(f)
assert result["name"] == "test"
def test_parse_library_properties(tmp_path):
f = tmp_path / "library.properties"
f.write_text(
"""
name=Test
version=1.0
# description=ABCD
empty=
"""
)
result = _parse_library_properties(f)
assert result["name"] == "Test"
assert result["version"] == "1.0"
assert "empty" not in result
def test_convert_library_with_repository():
lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "1.2.3"
assert isinstance(result.source, GitSource)
def test_convert_library_missing_ref():
lib = Library("name", None, "https://github.com/foo/bar.git")
with pytest.raises(ValueError):
_convert_library_to_component(lib)
def test_convert_library_registry(monkeypatch):
lib = Library("foo/bar", "^1.0.0", None)
monkeypatch.setattr(
esphome.espidf.component,
"_get_package_from_pio_registry",
lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"),
)
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "1.2.3"
assert isinstance(result.source, URLSource)
def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch):
tmp_component.data = {
"dependencies": [
{
"name": "foo",
"version": "1.0",
}
]
}
monkeypatch.setattr(
esphome.espidf.component,
"_generate_idf_component",
lambda lib: esphome.espidf.component.IDFComponent(
lib.name, lib.version, source=URLSource("http://dummy.com")
),
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 1
def test_process_dependencies_skips_invalid(tmp_component):
tmp_component.data = {
"dependencies": [
{"name": "foo", "version": "1.0", "platforms": ["arduino"]},
{"invalid": "entry"},
]
}
_process_dependencies(tmp_component)
assert tmp_component.dependencies == []
+98 -27
View File
@@ -135,7 +135,9 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
"""Test receive_exactly raises OTAError on error response."""
mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID])
with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"):
with pytest.raises(
espota2.OTAError, match="receiving auth:.*Authentication invalid"
):
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
mock_socket.close.assert_called_once()
@@ -145,61 +147,69 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
"""Test receive_exactly handles socket errors."""
mock_socket.recv.side_effect = OSError("Connection reset")
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"):
with pytest.raises(espota2.OTAError, match="receiving test response"):
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
@pytest.mark.parametrize(
("error_code", "expected_msg"),
[
(espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"),
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"),
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"),
(espota2.RESPONSE_ERROR_MAGIC, "Invalid magic byte"),
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Couldn't prepare flash memory"),
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Authentication invalid"),
(
espota2.RESPONSE_ERROR_WRITING_FLASH,
"Error: Writing OTA data to flash memory failed",
"Writing OTA data to flash memory failed",
),
(espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"),
(espota2.RESPONSE_ERROR_UPDATE_END, "Finishing update failed"),
(
espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING,
"Error: Please press the reset button",
"Please press the reset button",
),
(
espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG,
"Error: ESP has been flashed with wrong flash size",
"ESP has been flashed with wrong flash size",
),
(
espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG,
"Error: ESP does not have the requested flash size",
"ESP does not have the requested flash size",
),
(
espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE,
"Error: ESP does not have enough space",
"ESP does not have enough space",
),
(
espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE,
"Error: The OTA partition on the ESP is too small",
"The OTA partition on the ESP is too small",
),
(
espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION,
"Error: The OTA partition on the ESP couldn't be found",
"The OTA partition on the ESP couldn't be found",
),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Application MD5 code mismatch"),
(
espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
"Error: Firmware signature verification failed",
"Firmware signature verification failed",
),
(
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
"Error: The requested OTA type is not supported by the device",
"The requested OTA type is not supported by the device",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY,
"Error: The partition table update could not be verified",
"The partition table update could not be verified",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
"Error: An error occurred while updating the partition table",
"An error occurred while updating the partition table",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY,
"The bootloader update could not be verified",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE,
"An error occurred while updating the bootloader",
),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
],
@@ -254,7 +264,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
"""Test send_check handles socket errors."""
mock_socket.sendall.side_effect = OSError("Broken pipe")
with pytest.raises(espota2.OTAError, match="Error sending test"):
with pytest.raises(espota2.OTAError, match="sending test"):
espota2.send_check(mock_socket, b"data", "test")
@@ -409,7 +419,9 @@ def test_perform_ota_md5_auth_wrong_password(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"):
with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure
@@ -433,7 +445,9 @@ def test_perform_ota_sha256_auth_wrong_password(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"):
with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure
@@ -476,7 +490,7 @@ def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None:
# This will actually raise "Unexpected response from ESP" from check_error
with pytest.raises(
espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03"
espota2.OTAError, match=r"receiving auth: Unexpected response from ESP: 0x03"
):
espota2.perform_ota(mock_socket, "password", mock_file, "test.bin")
@@ -512,7 +526,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"):
with pytest.raises(espota2.OTAError, match="receiving chunk result response"):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@@ -596,7 +610,8 @@ def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None:
def test_progress_bar(capsys: CaptureFixture[str]) -> None:
"""Test ProgressBar functionality."""
progress = espota2.ProgressBar()
progress = espota2.ProgressBar("Uploading")
progress.enabled = True # Fake TTY
# Test initial update
progress.update(0.0)
@@ -992,7 +1007,8 @@ def test_perform_ota_non_app_type_requires_extended_protocol(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support extended OTA protocol"
espota2.OTAError,
match="Device does not support the extended OTA protocol",
):
espota2.perform_ota(
mock_socket,
@@ -1026,7 +1042,8 @@ def test_perform_ota_non_app_type_requires_partition_access(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support partition access"
espota2.OTAError,
match=(r"running firmware was built without 'allow_partition_access: true'"),
):
espota2.perform_ota(
mock_socket,
@@ -1037,6 +1054,60 @@ def test_perform_ota_non_app_type_requires_partition_access(
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_partition_access_error_names_bootloader_flag(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Bootloader OTA against a stale device must point at the --bootloader flag."""
recv_responses = [
bytes([espota2.RESPONSE_OK]),
bytes([espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
bytes([0]), # No partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match=r"--bootloader.*recompile and upload.*--bootloader.*retry --bootloader",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_BOOTLOADER,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_partition_access_error_names_partition_table_flag(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Partition-table OTA against a stale device must point at the --partition-table flag."""
recv_responses = [
bytes([espota2.RESPONSE_OK]),
bytes([espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
bytes([0]), # No partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match=r"--partition-table.*retry --partition-table",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_PARTITION_TABLE,
)
def test_check_error_detects_errors_when_expect_is_none() -> None:
"""check_error must surface device error bytes even when expect is None.
@@ -1044,7 +1115,7 @@ def test_check_error_detects_errors_when_expect_is_none() -> None:
during feature negotiation and nonce reads) silently passed error bytes
through, turning clean device errors into confusing later failures.
"""
with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"):
with pytest.raises(espota2.OTAError, match="Authentication invalid"):
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)
+40 -2
View File
@@ -1,9 +1,10 @@
import io
import logging
import os
from pathlib import Path
import socket
import stat
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
from hypothesis import given
@@ -12,7 +13,8 @@ import pytest
from esphome import helpers
from esphome.address_cache import AddressCache
from esphome.core import EsphomeError
from esphome.core import CORE, EsphomeError
from esphome.helpers import ProgressBar
@pytest.mark.parametrize(
@@ -1024,3 +1026,39 @@ def test_resolve_ip_address_mixed_cached_uncached() -> None:
assert "192.168.1.10" in addresses # Direct IP
assert "192.168.1.50" in addresses # From cache
assert "192.168.1.100" in addresses # From resolver
def test_progressbar_enabled_on_tty(monkeypatch) -> None:
"""Interactive TTY: progress writes through (pre-existing behaviour)."""
stream = MagicMock(spec=io.TextIOWrapper)
stream.isatty.return_value = True
monkeypatch.setattr(CORE, "dashboard", False)
bar = ProgressBar("Uploading", stream=stream)
assert bar.enabled is True
def test_progressbar_disabled_on_pipe_without_dashboard(monkeypatch) -> None:
"""Piped output without --dashboard: progress suppressed."""
stream = MagicMock(spec=io.TextIOWrapper)
stream.isatty.return_value = False
monkeypatch.setattr(CORE, "dashboard", False)
bar = ProgressBar("Uploading", stream=stream)
assert bar.enabled is False
def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
r"""Piped output under --dashboard: progress writes through.
The dashboard captures stderr through a pipe (so ``isatty()`` is False)
and parses ``\rUploading: NN%`` frames to drive its progress UI.
Gating purely on ``isatty()`` silently disables every dashboard-side
flash-progress indicator.
"""
stream = MagicMock(spec=io.TextIOWrapper)
stream.isatty.return_value = False
monkeypatch.setattr(CORE, "dashboard", True)
bar = ProgressBar("Uploading", stream=stream)
assert bar.enabled is True
+784 -21
View File
@@ -18,18 +18,20 @@ import pytest
from pytest import CaptureFixture
from zeroconf import ServiceStateChange
from esphome import platformio_api
from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_resolve_network_devices,
_validate_bootloader_binary,
_validate_partition_table_binary,
choose_upload_log_host,
command_analyze_memory,
command_bundle,
command_clean_all,
command_config_hash,
command_rename,
command_run,
command_update_all,
command_wizard,
compile_program,
@@ -45,6 +47,7 @@ from esphome.__main__ import (
has_resolvable_address,
has_web_server_ota,
mqtt_get_ip,
parse_args,
run_esphome,
run_miniterm,
show_logs,
@@ -87,9 +90,15 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE
from esphome.espota2 import (
OTA_TYPE_UPDATE_APP,
OTA_TYPE_UPDATE_BOOTLOADER,
OTA_TYPE_UPDATE_PARTITION_TABLE,
)
from esphome.platformio import toolchain
from esphome.util import BootselResult, FlashImage
from esphome.zeroconf import _await_discovery, discover_mdns_devices
@@ -143,6 +152,7 @@ def setup_core(
config[CONF_WIFI] = {CONF_USE_ADDRESS: address}
CORE.config = config
CORE.toolchain = Toolchain.PLATFORMIO
if platform is not None:
CORE.data[KEY_CORE] = {}
@@ -280,7 +290,7 @@ def mock_run_external_process() -> Generator[Mock]:
@pytest.fixture
def mock_run_external_command_main() -> Generator[Mock]:
"""Mock run_external_command in __main__ module (different from platformio_api)."""
"""Mock run_external_command in __main__ module (different from platformio toolchain)."""
with patch("esphome.__main__.run_external_command") as mock:
mock.return_value = 0 # Default to success
yield mock
@@ -1127,6 +1137,7 @@ class MockArgs:
output: str | None = None
ota_platform: str | None = None
partition_table: bool = False
bootloader: bool = False
def test_upload_program_serial_esp32(
@@ -1191,7 +1202,7 @@ def test_upload_using_esptool_path_conversion(
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
# Create mock IDEData with Path objects
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata = MagicMock(spec=toolchain.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = [
FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
@@ -1269,7 +1280,7 @@ def test_upload_using_esptool_skips_missing_extra_flash_images(
missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin"
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata = MagicMock(spec=toolchain.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = [
FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
@@ -1381,8 +1392,8 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040(
mock_idedata.firmware_elf_path = str(firmware_elf)
with (
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
patch("esphome.platformio_api.run_platformio_cli_run", return_value=0),
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")
@@ -1398,7 +1409,7 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
"""Test that upload_using_platformio doesn't create signed bin for non-RP2040."""
setup_core(platform=PLATFORM_ESP32)
with patch("esphome.platformio_api.run_platformio_cli_run", return_value=0):
with patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0):
result = upload_using_platformio({}, "/dev/ttyUSB0")
assert result == 0
@@ -1496,7 +1507,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None:
config = {}
with (
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
patch("subprocess.run", return_value=mock_result),
):
exit_code = upload_using_picotool(config)
@@ -1516,7 +1527,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
mock_idedata.cc_path = "/fake/path/gcc"
config = {}
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata):
exit_code = upload_using_picotool(config)
assert exit_code == 1
@@ -1536,7 +1547,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
mock_idedata.cc_path = "/fake/path/gcc"
config = {}
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata):
exit_code = upload_using_picotool(config)
assert exit_code == 1
@@ -1570,7 +1581,7 @@ def test_upload_using_picotool_permission_error(tmp_path: Path) -> None:
config = {}
with (
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
patch("subprocess.run", return_value=mock_result),
):
exit_code = upload_using_picotool(config)
@@ -1816,6 +1827,27 @@ def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None:
_validate_partition_table_binary(tmp_path / "does-not-exist.bin")
def test_validate_bootloader_binary_rejects_wrong_magic(tmp_path: Path) -> None:
data = bytearray(_make_bootloader_bytes())
data[0] = 0x00
f = tmp_path / "bootloader.bin"
f.write_bytes(bytes(data))
with pytest.raises(EsphomeError, match="magic"):
_validate_bootloader_binary(f)
def test_validate_bootloader_binary_missing_file(tmp_path: Path) -> None:
with pytest.raises(EsphomeError, match="Cannot read bootloader file"):
_validate_bootloader_binary(tmp_path / "does-not-exist.bin")
def test_validate_bootloader_binary_rejects_empty_file(tmp_path: Path) -> None:
f = tmp_path / "bootloader.bin"
f.write_bytes(b"")
with pytest.raises(EsphomeError, match="is empty"):
_validate_bootloader_binary(f)
def test_upload_program_ota_partition_table_invalid_file(
mock_run_ota: Mock,
mock_get_port_type: Mock,
@@ -1869,7 +1901,155 @@ def test_upload_program_ota_partition_table_without_allow_flag(
with pytest.raises(
EsphomeError,
match="requires 'allow_partition_access: true'",
match=(
r"The option --partition-table requires 'allow_partition_access: true'.*"
r"retry --partition-table"
),
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def _make_bootloader_bytes() -> bytes:
"""Build a minimal bootloader image accepted by _validate_bootloader_binary."""
table = bytearray(b"\xff")
# Starts with: ESP_IMAGE_HEADER_MAGIC (0xE9)
table[0] = 0xE9
return bytes(table)
def test_upload_program_ota_bootloader_with_file_arg(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Test upload_program with OTA and bootloader."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
bootloader_file = tmp_path / "bootloader.bin"
bootloader_file.write_bytes(_make_bootloader_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(bootloader_file), bootloader=True)
devices = ["192.168.1.100"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"],
3232,
None,
bootloader_file,
OTA_TYPE_UPDATE_BOOTLOADER,
)
def test_upload_program_ota_partition_table_and_bootloader_options(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--partition-table and --bootloader can't be used together."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file="partitions.bin", partition_table=True, bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match="--partition-table and --bootloader",
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_bootloader_without_allow_flag(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--bootloader must fail fast when allow_partition_access is not enabled in YAML."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
]
}
args = MockArgs(file="bootloader.bin", bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match=(
r"The option --bootloader requires 'allow_partition_access: true'.*"
r"retry --bootloader"
),
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_bootloader_platform_web_server(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Test bootloader upload with web_server OTA."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
bootloader_file = tmp_path / "bootloader.bin"
bootloader_file.write_bytes(_make_bootloader_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_WEB_SERVER: {
CONF_PORT: 80,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
},
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(bootloader_file), bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match="the web_server OTA path can only update the firmware image",
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
@@ -3260,6 +3440,33 @@ def test_command_wizard(tmp_path: Path) -> None:
mock_wizard.assert_called_once_with(config_file)
def test_command_config_hash(
tmp_path: Path,
capfd: CaptureFixture[str],
) -> None:
"""command_config_hash runs codegen then prints CORE.config_hash.
The printed format must match `0x{config_hash:08x}` used by
generate_build_info_data_cpp so the value can be compared byte-for-byte
against the ESPHOME_CONFIG_HASH embedded in firmware.
"""
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
# generate_cpp_contents requires real components to be loaded; mock it out
# so this test isolates the command's output contract. The command must
# still call it (codegen can mutate config, which affects the hash).
with patch("esphome.__main__.generate_cpp_contents") as mock_generate:
result = command_config_hash(args, CORE.config)
assert result == 0
mock_generate.assert_called_once_with(CORE.config)
output = strip_ansi_codes(capfd.readouterr().out).strip()
assert re.fullmatch(r"0x[0-9a-f]{8}", output)
assert output == f"0x{CORE.config_hash:08x}"
def test_command_rename_invalid_characters(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
@@ -3420,6 +3627,467 @@ esp32:
assert "Rename failed" in captured.out
def test_command_rename_install_failure_reverts(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename when the install (esphome run) step fails."""
config_file = tmp_path / "oldname.yaml"
config_file.write_text("""
esphome:
name: oldname
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
args = MockArgs(name="newname", dashboard=False)
# First call (config validation) succeeds; second (esphome run) fails.
mock_run_external_process.side_effect = [0, 1]
result = command_rename(args, {})
assert result == 1
# New file was unlinked when install failed.
new_file = tmp_path / "newname.yaml"
assert not new_file.exists()
# Old file is preserved so the device stays reachable under the
# original hostname.
assert config_file.exists()
def test_command_rename_target_exists_refuses(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename refuses when the target filename already exists.
Without this guard, the rename would overwrite the unrelated
device's YAML and OTA-install our firmware to the wrong device.
"""
config_file = tmp_path / "oldname.yaml"
config_file.write_text("""
esphome:
name: oldname
esp32:
board: nodemcu-32s
""")
target_file = tmp_path / "newname.yaml"
target_file.write_text("""
esphome:
name: someoneelse
esp32:
board: nodemcu-32s
""")
target_original = target_file.read_text()
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
args = MockArgs(name="newname", dashboard=False)
result = command_rename(args, {})
assert result == 1
# No subprocess work happened — refusal is up-front.
mock_run_external_process.assert_not_called()
# Target file untouched: same content, still on disk.
assert target_file.exists()
assert target_file.read_text() == target_original
# Source file untouched.
assert config_file.exists()
captured = capfd.readouterr()
assert "already exists" in captured.out
def test_command_rename_same_name_refuses(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename refuses when the new name matches the current name.
A same-name rename would otherwise re-write the YAML and queue
a redundant compile + install — wasted work the user almost
certainly didn't intend.
"""
config_file = tmp_path / "samename.yaml"
config_file.write_text("""
esphome:
name: samename
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}}
args = MockArgs(name="samename", dashboard=False)
result = command_rename(args, {})
assert result == 1
mock_run_external_process.assert_not_called()
# File preserved verbatim — no rewrite happened.
assert config_file.exists()
captured = capfd.readouterr()
assert "already" in captured.out.lower()
def test_command_rename_does_not_touch_friendly_name_substring(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
r"""Test rename does not match the ``name:`` substring of ``friendly_name:``.
Without anchoring the regex at line start, the pattern
``\s*name:\s+<old>`` could match the trailing ``name:``
substring inside ``friendly_name: <old>``. The rewrite would
flip both lines to the new name, leaving the user with a
silently corrupted ``friendly_name``.
"""
config_file = tmp_path / "oldname.yaml"
config_file.write_text("""
esphome:
name: oldname
friendly_name: oldname
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
args = MockArgs(name="newname", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "newname.yaml"
content = new_file.read_text()
# esphome.name swapped.
assert 'name: "newname"' in content
# friendly_name kept verbatim.
assert "friendly_name: oldname" in content
def test_command_rename_does_not_match_old_name_as_value_prefix(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
r"""Test rename does not match ``old_name`` as a prefix of a longer value.
With ``old_name = kitchen`` the value ``kitchen2`` (a sensor
or wifi entry) would otherwise match the unanchored
``["']?kitchen["']?`` pattern at the prefix and get
rewritten to the new name. The end-of-value lookahead keeps
the match restricted to whole tokens.
"""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: kitchen
esp32:
board: nodemcu-32s
wifi:
ap:
ssid: kitchen2
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="garage", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "garage.yaml"
content = new_file.read_text()
assert 'name: "garage"' in content
# The wifi ssid value is unrelated and stays intact.
assert "ssid: kitchen2" in content
def test_command_rename_same_resolved_name_refuses(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename refuses when ``new_name`` matches the resolved device name.
The path-equality check only catches the case where the
config filename matches the device name. For a config whose
filename and ``esphome.name`` differ (here ``weird-file.yaml``
holds ``esphome.name: kitchen``), running
``esphome rename weird-file.yaml kitchen`` would otherwise
fall through to the rewrite + install: the YAML's name stays
``kitchen``, the file is renamed to ``kitchen.yaml``, and the
device gets a redundant flash. Refuse up-front so the
"already the device's name" message matches reality.
"""
config_file = tmp_path / "weird-file.yaml"
config_file.write_text("""
esphome:
name: kitchen
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="kitchen", dashboard=False)
result = command_rename(args, {})
assert result == 1
mock_run_external_process.assert_not_called()
# Source file untouched, no derived target written.
assert config_file.exists()
assert not (tmp_path / "kitchen.yaml").exists()
captured = capfd.readouterr()
assert "already" in captured.out.lower()
def test_command_rename_target_path_equals_source_refuses(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename refuses when the new path resolves to the source file.
Reachable only when the YAML's filename and ``esphome.name``
disagree — here ``kitchen.yaml`` holds ``esphome.name: garage``
and the user runs ``esphome rename kitchen.yaml kitchen``. The
name-equality check above passes (``garage != kitchen``), but
``<config_dir>/kitchen.yaml`` resolves to the source file
itself, so the rewrite would clobber the source mid-rename.
Refuse rather than silently overwriting.
"""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: garage
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}}
args = MockArgs(name="kitchen", dashboard=False)
result = command_rename(args, {})
assert result == 1
mock_run_external_process.assert_not_called()
# Source file still present and unmodified.
assert config_file.exists()
assert "name: garage" in config_file.read_text()
captured = capfd.readouterr()
assert "already" in captured.out.lower()
def test_command_rename_does_not_touch_lookalike_name_in_other_blocks(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
"""Test rename only swaps the esphome.name line.
A device whose name happens to match a sensor's / output's
``name:`` value must not have those other names rewritten —
they're independent. Without an anchor for the esphome block
a naive regex would clobber every line whose value matches.
"""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: kitchen
esp32:
board: nodemcu-32s
sensor:
- platform: template
name: kitchen
lambda: 'return 0;'
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="garage", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "garage.yaml"
content = new_file.read_text()
# esphome.name renamed.
assert 'name: "garage"' in content
# Sensor's name is the user's entity name — must not be touched.
assert " name: kitchen\n" in content
def test_command_rename_preserves_trailing_comment(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
"""Test rename preserves a trailing ``# comment`` on the name line."""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: kitchen # primary device
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="garage", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "garage.yaml"
content = new_file.read_text()
assert "# primary device" in content
def test_command_rename_handles_double_quoted_value(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
"""Test rename matches when the existing value is double-quoted."""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: "kitchen"
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="garage", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "garage.yaml"
assert 'name: "garage"' in new_file.read_text()
def test_command_rename_handles_single_quoted_value(
tmp_path: Path,
mock_run_external_process: Mock,
) -> None:
"""Test rename matches when the existing value is single-quoted."""
config_file = tmp_path / "kitchen.yaml"
config_file.write_text("""
esphome:
name: 'kitchen'
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
args = MockArgs(name="garage", dashboard=False)
mock_run_external_process.return_value = 0
result = command_rename(args, {})
assert result == 0
new_file = tmp_path / "garage.yaml"
assert 'name: "garage"' in new_file.read_text()
def test_command_rename_too_many_substitution_matches_refuses(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_run_external_process: Mock,
) -> None:
"""Test rename refuses when ``${var}`` resolves to multiple matches.
When ``esphome.name: ${device_name}`` and the substitution
definition ``device_name: foo`` appears more than once in the
YAML (e.g. inside multiple included blocks), the regex rewrite
can't tell which one to flip. Rather than silently picking one
or rewriting both, the command refuses.
"""
config_file = tmp_path / "oldname.yaml"
config_file.write_text("""
substitutions:
device_name: oldname
esphome:
name: ${device_name}
# A copy-pasted block that re-declares the substitution at the
# same indent level - happens when users splice in a packaged
# fragment without renaming the variable.
example:
device_name: oldname
esp32:
board: nodemcu-32s
""")
setup_core(tmp_path=tmp_path)
CORE.config_path = config_file
CORE.config = {
CONF_ESPHOME: {CONF_NAME: "oldname"},
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
}
args = MockArgs(name="newname", dashboard=False)
result = command_rename(args, {})
assert result == 1
mock_run_external_process.assert_not_called()
# File untouched.
assert config_file.exists()
assert "device_name: oldname" in config_file.read_text()
captured = capfd.readouterr()
assert "Too many matches" in captured.out
def test_command_update_all_path_string_conversion(
tmp_path: Path,
mock_run_external_process: Mock,
@@ -4058,7 +4726,7 @@ def test_command_analyze_memory_success(
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj = MagicMock(spec=toolchain.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
@@ -4130,7 +4798,7 @@ def test_command_analyze_memory_with_external_components(
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj = MagicMock(spec=toolchain.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
@@ -4221,16 +4889,18 @@ def test_command_analyze_memory_no_idedata(
@pytest.fixture
def mock_compile_build_info_run_compile() -> Generator[Mock]:
"""Mock platformio_api.run_compile for build_info tests."""
with patch("esphome.platformio_api.run_compile", return_value=0) as mock:
"""Mock toolchain.run_compile for build_info tests."""
with patch("esphome.platformio.toolchain.run_compile", return_value=0) as mock:
yield mock
@pytest.fixture
def mock_compile_build_info_get_idedata() -> Generator[Mock]:
"""Mock platformio_api.get_idedata for build_info tests."""
"""Mock toolchain.get_idedata for build_info tests."""
mock_idedata = MagicMock()
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock:
with patch(
"esphome.platformio.toolchain.get_idedata", return_value=mock_idedata
) as mock:
yield mock
@@ -5140,7 +5810,7 @@ def test_upload_using_esptool_passes_crystal_callback(
sdkconfig = build_dir / "sdkconfig.test"
sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n")
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata = MagicMock(spec=toolchain.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = []
mock_get_idedata.return_value = mock_idedata
@@ -5170,7 +5840,7 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback(
sdkconfig = build_dir / "sdkconfig.test"
sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n")
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata = MagicMock(spec=toolchain.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = []
mock_get_idedata.return_value = mock_idedata
@@ -5183,3 +5853,96 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback(
call_kwargs = mock_run_external_process.call_args[1]
assert "line_callbacks" in call_kwargs
assert len(call_kwargs["line_callbacks"]) == 1
def test_parse_args_run_no_states() -> None:
"""Test that --no-states is parsed for the run command."""
args = parse_args(["esphome", "run", "--no-states", "device.yaml"])
assert args.no_states is True
def test_parse_args_run_no_states_default() -> None:
"""Test that no_states defaults to False for the run command."""
args = parse_args(["esphome", "run", "device.yaml"])
assert args.no_states is False
def test_parse_args_logs_no_states() -> None:
"""Test that --no-states is parsed for the logs command."""
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
assert args.no_states is True
@patch("esphome.components.api.client.run_logs")
def test_command_run_passes_no_states_to_show_logs(
mock_run_logs: Mock,
) -> None:
"""Test that command_run propagates --no-states through to run_logs."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MDNS: {CONF_DISABLED: False},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
args = MockArgs()
args.no_states = True
args.no_logs = False
args.device = None
with (
patch("esphome.__main__.write_cpp", return_value=0),
patch("esphome.__main__.compile_program", return_value=0),
patch(
"esphome.__main__.choose_upload_log_host",
return_value=["192.168.1.100"],
),
patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")),
patch("esphome.__main__.get_serial_ports", return_value=[]),
):
result = command_run(args, CORE.config)
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=False
)
@patch("esphome.components.api.client.run_logs")
def test_command_run_defaults_subscribe_states_true(
mock_run_logs: Mock,
) -> None:
"""Test that command_run subscribes states by default (no --no-states)."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MDNS: {CONF_DISABLED: False},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
args = MockArgs()
args.no_logs = False
args.device = None
with (
patch("esphome.__main__.write_cpp", return_value=0),
patch("esphome.__main__.compile_program", return_value=0),
patch(
"esphome.__main__.choose_upload_log_host",
return_value=["192.168.1.100"],
),
patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")),
patch("esphome.__main__.get_serial_ports", return_value=[]),
):
result = command_run(args, CORE.config)
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
@@ -1,18 +1,21 @@
"""Tests for platformio_api.py path functions."""
"""Tests for esphome.platformio.toolchain path functions."""
# pylint: disable=protected-access
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shutil
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, call, patch
import pytest
from esphome import platformio_api, platformio_runner
from esphome.core import CORE, EsphomeError
from esphome.platformio import runner, toolchain
from esphome.util import FlashImage
@@ -21,7 +24,7 @@ def test_idedata_firmware_elf_path(setup_core: Path) -> None:
CORE.build_path = setup_core / "build" / "test"
CORE.name = "test"
raw_data = {"prog_path": "/path/to/firmware.elf"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
assert idedata.firmware_elf_path == Path("/path/to/firmware.elf")
@@ -32,7 +35,7 @@ def test_idedata_firmware_bin_path(setup_core: Path) -> None:
CORE.name = "test"
prog_path = str(Path("/path/to/firmware.elf"))
raw_data = {"prog_path": prog_path}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.firmware_bin_path
assert isinstance(result, Path)
@@ -47,7 +50,7 @@ def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None
CORE.name = "test"
prog_path = str(Path("/complex/path/to/build/firmware.elf"))
raw_data = {"prog_path": prog_path}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.firmware_bin_path
expected = Path("/complex/path/to/build/firmware.bin")
@@ -67,7 +70,7 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None:
]
},
}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
images = idedata.extra_flash_images
assert len(images) == 2
@@ -83,7 +86,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None:
CORE.build_path = setup_core / "build" / "test"
CORE.name = "test"
raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
images = idedata.extra_flash_images
assert images == []
@@ -97,7 +100,7 @@ def test_idedata_cc_path(setup_core: Path) -> None:
"prog_path": "/path/to/firmware.elf",
"cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc",
}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
assert (
idedata.cc_path
@@ -132,7 +135,7 @@ def test_load_idedata_returns_dict(
mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}'
config = {"name": "test"}
result = platformio_api._load_idedata(config)
result = toolchain._load_idedata(config)
assert result is not None
assert isinstance(result, dict)
@@ -161,7 +164,7 @@ def test_load_idedata_uses_cache_when_valid(
os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1))
config = {"name": "test"}
result = platformio_api._load_idedata(config)
result = toolchain._load_idedata(config)
# Should not call _run_idedata since cache is valid
mock_run_platformio_cli_run.assert_not_called()
@@ -194,7 +197,7 @@ def test_load_idedata_regenerates_when_platformio_ini_newer(
mock_run_platformio_cli_run.return_value = json.dumps(new_data)
config = {"name": "test"}
result = platformio_api._load_idedata(config)
result = toolchain._load_idedata(config)
# Should call _run_idedata since platformio.ini is newer
mock_run_platformio_cli_run.assert_called_once()
@@ -228,7 +231,7 @@ def test_load_idedata_regenerates_on_corrupted_cache(
mock_run_platformio_cli_run.return_value = json.dumps(new_data)
config = {"name": "test"}
result = platformio_api._load_idedata(config)
result = toolchain._load_idedata(config)
# Should call _run_idedata since cache is corrupted
mock_run_platformio_cli_run.assert_called_once()
@@ -253,7 +256,7 @@ def test_run_idedata_parses_json_from_output(
f"Some preamble\n{json.dumps(expected_data)}\nSome postamble"
)
result = platformio_api._run_idedata(config)
result = toolchain._run_idedata(config)
assert result == expected_data
@@ -267,7 +270,7 @@ def test_run_idedata_raises_on_no_json(
mock_run_platformio_cli_run.return_value = "No JSON in this output"
with pytest.raises(EsphomeError):
platformio_api._run_idedata(config)
toolchain._run_idedata(config)
def test_run_idedata_raises_on_invalid_json(
@@ -279,7 +282,7 @@ def test_run_idedata_raises_on_invalid_json(
# The ValueError from json.loads is re-raised
with pytest.raises(ValueError):
platformio_api._run_idedata(config)
toolchain._run_idedata(config)
def test_run_platformio_cli_sets_environment_variables(
@@ -290,7 +293,7 @@ def test_run_platformio_cli_sets_environment_variables(
with patch.dict(os.environ, {}, clear=False):
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
toolchain.run_platformio_cli("test", "arg")
# Check environment variables were set
assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true"
@@ -303,11 +306,11 @@ def test_run_platformio_cli_sets_environment_variables(
assert "PYTHONWARNINGS" in os.environ
# Check command was called correctly — runs PlatformIO as a subprocess
# via the esphome.platformio_runner entry point.
# via the esphome.platformio.runner entry point.
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert "-m" in args
assert "esphome.platformio_runner" in args
assert "esphome.platformio.runner" in args
assert "test" in args
assert "arg" in args
@@ -342,8 +345,8 @@ def test_strip_win_long_path_prefix(
platform: str, input_path: str, expected: str
) -> None:
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.platformio_api.sys.platform", platform):
assert platformio_api._strip_win_long_path_prefix(input_path) == expected
with patch("esphome.platformio.toolchain.sys.platform", platform):
assert toolchain._strip_win_long_path_prefix(input_path) == expected
def test_run_platformio_cli_strips_win_long_path_prefix(
@@ -366,15 +369,15 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "win32"),
patch("esphome.platformio_api.sys.executable", prefixed_exe),
patch("esphome.platformio.toolchain.sys.platform", "win32"),
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
):
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
# what run_platformio_cli set, not whatever the test runner's
# environment happened to contain.
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
toolchain.run_platformio_cli("test", "arg")
# The subprocess is invoked with the stripped executable path.
mock_run_external_process.assert_called_once()
@@ -398,12 +401,12 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "linux"),
patch("esphome.platformio_api.sys.executable", plain_exe),
patch("esphome.platformio.toolchain.sys.platform", "linux"),
patch("esphome.platformio.toolchain.sys.executable", plain_exe),
):
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
toolchain.run_platformio_cli("test", "arg")
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
@@ -419,7 +422,7 @@ def test_run_platformio_cli_run_builds_command(
mock_run_platformio_cli.return_value = 0
config = {"name": "test"}
platformio_api.run_platformio_cli_run(config, True, "extra", "args")
toolchain.run_platformio_cli_run(config, True, "extra", "args")
mock_run_platformio_cli.assert_called_once_with(
"run", "-d", CORE.build_path, "-v", "extra", "args"
@@ -434,7 +437,7 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
mock_run_platformio_cli_run.return_value = 0
platformio_api.run_compile(config, verbose=True)
toolchain.run_compile(config, verbose=True)
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
@@ -461,22 +464,22 @@ def test_get_idedata_caches_result(
config = {"name": "test"}
# First call should load and cache
result1 = platformio_api.get_idedata(config)
result1 = toolchain.get_idedata(config)
mock_run_platformio_cli_run.assert_called_once()
# Second call should use cache from CORE.data
result2 = platformio_api.get_idedata(config)
result2 = toolchain.get_idedata(config)
mock_run_platformio_cli_run.assert_called_once() # Still only called once
assert result1 is result2
assert isinstance(result1, platformio_api.IDEData)
assert isinstance(result1, toolchain.IDEData)
assert result1.firmware_elf_path == Path("/test/firmware.elf")
def test_idedata_addr2line_path_windows(setup_core: Path) -> None:
"""Test IDEData.addr2line_path on Windows."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.addr2line_path
assert result == "C:\\tools\\addr2line.exe"
@@ -485,7 +488,7 @@ def test_idedata_addr2line_path_windows(setup_core: Path) -> None:
def test_idedata_addr2line_path_unix(setup_core: Path) -> None:
"""Test IDEData.addr2line_path on Unix."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.addr2line_path
assert result == "/usr/bin/addr2line"
@@ -494,7 +497,7 @@ def test_idedata_addr2line_path_unix(setup_core: Path) -> None:
def test_idedata_objdump_path_windows(setup_core: Path) -> None:
"""Test IDEData.objdump_path on Windows."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.objdump_path
assert result == "C:\\tools\\objdump.exe"
@@ -503,7 +506,7 @@ def test_idedata_objdump_path_windows(setup_core: Path) -> None:
def test_idedata_objdump_path_unix(setup_core: Path) -> None:
"""Test IDEData.objdump_path on Unix."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.objdump_path
assert result == "/usr/bin/objdump"
@@ -512,7 +515,7 @@ def test_idedata_objdump_path_unix(setup_core: Path) -> None:
def test_idedata_readelf_path_windows(setup_core: Path) -> None:
"""Test IDEData.readelf_path on Windows."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.readelf_path
assert result == "C:\\tools\\readelf.exe"
@@ -521,7 +524,7 @@ def test_idedata_readelf_path_windows(setup_core: Path) -> None:
def test_idedata_readelf_path_unix(setup_core: Path) -> None:
"""Test IDEData.readelf_path on Unix."""
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
idedata = platformio_api.IDEData(raw_data)
idedata = toolchain.IDEData(raw_data)
result = idedata.readelf_path
assert result == "/usr/bin/readelf"
@@ -547,7 +550,7 @@ def test_patch_structhash(setup_core: Path) -> None:
},
):
# Call patch_structhash
platformio_runner.patch_structhash()
runner.patch_structhash()
# Verify both modules had clean_build_dir patched
# Check that clean_build_dir was set on both modules
@@ -599,7 +602,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -649,7 +652,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -697,7 +700,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -727,7 +730,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None:
),
},
):
platformio_runner.patch_file_downloader()
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -766,7 +769,7 @@ def test_patch_file_downloader_retries_on_failure() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_runner.patch_file_downloader()
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -807,7 +810,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_runner.patch_file_downloader()
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -855,7 +858,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
),
patch("time.sleep"),
):
platformio_runner.patch_file_downloader()
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -867,6 +870,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
mock_session.close.assert_called_once()
def test_patch_file_downloader_retries_on_connection_error() -> None:
"""Test patch_file_downloader retries on transport-layer errors (OSError subclasses).
``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass
``OSError`` and are raised when the connection is aborted before any HTTP
response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must
retry too, not just ``PackageException``.
"""
mock_exception_cls = type("PackageException", (Exception,), {})
call_count = 0
def failing_init(self, *args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError(
f"Connection aborted attempt {call_count}: RemoteDisconnected"
)
with (
patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type(
"FileDownloader", (), {"__init__": failing_init}
)
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
),
patch("time.sleep") as mock_sleep,
):
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
FileDownloader.__init__(instance, "http://example.com/file.zip")
assert call_count == 3
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(2)
mock_sleep.assert_any_call(4)
def test_patch_file_downloader_idempotent() -> None:
"""Test patch_file_downloader does not stack wrappers when called multiple times."""
mock_exception_cls = type("PackageException", (Exception,), {})
@@ -890,9 +943,9 @@ def test_patch_file_downloader_idempotent() -> None:
},
):
# Patch multiple times
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
runner.patch_file_downloader()
runner.patch_file_downloader()
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -903,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None:
assert call_count == 1
@contextmanager
def _flaky_http_server(fail_first_n: int, fail_mode: str):
"""Local HTTP server that fails the first ``fail_first_n`` requests.
``fail_mode="drop"`` closes the TCP connection without responding, so
the client raises ``RemoteDisconnected`` -- the exact CI failure mode.
``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``.
"""
state = {"hits": 0}
class _Handler(BaseHTTPRequestHandler):
def handle_one_request(self) -> None:
state["hits"] += 1
if state["hits"] <= fail_first_n and fail_mode == "drop":
return # Skip read+respond → kernel sends FIN → RemoteDisconnected
super().handle_one_request()
def do_GET(self) -> None: # noqa: N802
if state["hits"] <= fail_first_n and fail_mode == "502":
self.send_error(502)
return
body = b"esphome-test-payload"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None: # noqa: A002
pass # silence default stderr logging
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1], state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
@pytest.mark.parametrize("fail_mode", ["drop", "502"])
def test_patch_file_downloader_recovers_against_real_server(
tmp_path: Path, fail_mode: str
) -> None:
"""End-to-end: real PlatformIO ``FileDownloader`` against a local server
that fails twice then succeeds. Exercises the real
requests/urllib3/http.client stack for both failure modes:
- ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as
``OSError`` by the retry patch (the CI failure path).
- ``502``: HTTP error response → ``PackageException`` (the original path).
"""
runner.patch_file_downloader()
from platformio.package.download import FileDownloader
with (
_flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state),
patch("time.sleep"),
):
fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin")
fd.set_destination(str(tmp_path / "out.bin"))
fd.start(with_progress=False, silent=True)
assert state["hits"] == 3 # 2 failures + 1 success
assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload"
def _filter_through_redirect(line: str) -> str:
"""Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes."""
import io
@@ -910,9 +1031,7 @@ def _filter_through_redirect(line: str) -> str:
from esphome.util import RedirectText
captured = io.StringIO()
redirect = RedirectText(
captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES
)
redirect = RedirectText(captured, filter_lines=runner.FILTER_PLATFORMIO_LINES)
redirect.write(line + "\n")
return captured.getvalue()
+20
View File
@@ -818,3 +818,23 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename(
substitutions.resolve_include(include, [], substitutions.ContextVars())
assert "expanded from" not in str(exc_info.value)
def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
"""!include vars: must substitute into a top-level !lambda value in the included file.
Regression test for the case where the included file's root is a Lambda;
add_context() previously only tagged dict/list/str, so the include's vars
never reached the substitution pass for Lambda content.
"""
included = tmp_path / "lambda.yaml"
included.write_text('!lambda |-\n return "${foo}";\n')
include = yaml_util.IncludeFile(
tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml
)
config = OrderedDict({"value": include.load()})
result = substitutions.do_substitution_pass(config)
assert isinstance(result["value"], Lambda)
assert result["value"].value == 'return "bar";'
+81
View File
@@ -0,0 +1,81 @@
"""Tests for the stable upload-targets classification helpers."""
import pytest
from esphome.upload_targets import PortType, get_port_type
@pytest.mark.parametrize(
"port",
[
"/dev/ttyUSB0",
"/dev/ttyACM0",
"/dev/cu.usbserial-1410",
"/dev/tty.usbmodem1101",
"COM1",
"COM23",
],
)
def test_get_port_type_serial(port: str) -> None:
"""Local serial devices classify as SERIAL."""
assert get_port_type(port) is PortType.SERIAL
def test_get_port_type_bootsel() -> None:
"""``BOOTSEL`` magic string classifies as BOOTSEL."""
assert get_port_type("BOOTSEL") is PortType.BOOTSEL
def test_get_port_type_mqtt() -> None:
"""``MQTT`` magic string classifies as MQTT."""
assert get_port_type("MQTT") is PortType.MQTT
def test_get_port_type_mqttip() -> None:
"""``MQTTIP`` magic string classifies as MQTTIP."""
assert get_port_type("MQTTIP") is PortType.MQTTIP
@pytest.mark.parametrize(
"port",
[
"192.168.1.10",
"fe80::1",
"device.local",
"my-esp.example.com",
],
)
def test_get_port_type_network(port: str) -> None:
"""IP addresses, mDNS, and hostnames classify as NETWORK."""
assert get_port_type(port) is PortType.NETWORK
def test_port_type_values_are_stable() -> None:
"""Member values are part of the stable surface.
External tooling (device-builder, etc.) may compare against the
string values directly. Renaming or changing these breaks
downstream consumers — guard against accidental edits.
"""
assert PortType.SERIAL.value == "SERIAL"
assert PortType.NETWORK.value == "NETWORK"
assert PortType.MQTT.value == "MQTT"
assert PortType.MQTTIP.value == "MQTTIP"
assert PortType.BOOTSEL.value == "BOOTSEL"
def test_main_re_exports_for_backwards_compat() -> None:
"""``esphome.__main__`` re-exports the stable surface.
The CLI entry point pre-dated the stable module and existing
internal callers (and any third-party code that snuck in via
``__main__``) still import from there. The re-export must
resolve to the same objects.
"""
from esphome.__main__ import (
PortType as MainPortType,
get_port_type as main_get_port_type,
)
assert MainPortType is PortType
assert main_get_port_type is get_port_type
+21 -7
View File
@@ -443,6 +443,14 @@ def test_clean_build(
dependencies_lock = tmp_path / "dependencies.lock"
dependencies_lock.write_text("lock file")
# Native ESP-IDF toolchain artifacts.
idf_build_dir = tmp_path / "build"
idf_build_dir.mkdir()
(idf_build_dir / "CMakeCache.txt").write_text("cache")
managed_components_dir = tmp_path / "managed_components"
managed_components_dir.mkdir()
(managed_components_dir / "espressif__arduino-esp32").mkdir()
# Create PlatformIO cache directory
platformio_cache_dir = tmp_path / ".platformio" / ".cache"
platformio_cache_dir.mkdir(parents=True)
@@ -454,12 +462,14 @@ def test_clean_build(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.return_value = dependencies_lock
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify all exist before
assert pioenvs_dir.exists()
assert piolibdeps_dir.exists()
assert dependencies_lock.exists()
assert idf_build_dir.exists()
assert managed_components_dir.exists()
assert platformio_cache_dir.exists()
# Mock PlatformIO's ProjectConfig cache_dir
@@ -482,6 +492,8 @@ def test_clean_build(
assert not pioenvs_dir.exists()
assert not piolibdeps_dir.exists()
assert not dependencies_lock.exists()
assert not idf_build_dir.exists()
assert not managed_components_dir.exists()
assert not platformio_cache_dir.exists()
# Verify logging
@@ -489,6 +501,8 @@ def test_clean_build(
assert ".pioenvs" in caplog.text
assert ".piolibdeps" in caplog.text
assert "dependencies.lock" in caplog.text
assert str(idf_build_dir) in caplog.text
assert str(managed_components_dir) in caplog.text
assert "PlatformIO cache" in caplog.text
@@ -510,7 +524,7 @@ def test_clean_build_partial_exists(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.return_value = dependencies_lock
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify only pioenvs exists
assert pioenvs_dir.exists()
@@ -547,7 +561,7 @@ def test_clean_build_nothing_exists(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.return_value = dependencies_lock
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify nothing exists
assert not pioenvs_dir.exists()
@@ -583,7 +597,7 @@ def test_clean_build_platformio_not_available(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.return_value = dependencies_lock
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify all exist before
assert pioenvs_dir.exists()
@@ -621,7 +635,7 @@ def test_clean_build_empty_cache_dir(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify pioenvs exists before
assert pioenvs_dir.exists()
@@ -1349,7 +1363,7 @@ def test_clean_build_handles_readonly_files(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
@@ -1413,7 +1427,7 @@ def test_clean_build_reraises_for_other_errors(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
try:
# Mock os.access in writer module to return True (writable)
+15
View File
@@ -390,6 +390,21 @@ def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None:
assert len(yaml_util._load_listeners) == before
def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure(
tmp_path: Path,
) -> None:
"""A failed top-level !include must not record any file twice in track_yaml_loads."""
main = tmp_path / "main.yaml"
main.write_text("!include missing.yaml\n")
with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError):
yaml_util.load_yaml(main)
assert len(loaded) == len(set(loaded)), (
f"Files loaded more than once during a failed top-level include: {loaded}"
)
@pytest.mark.parametrize(
"data",
[