mirror of
https://github.com/esphome/esphome.git
synced 2026-09-21 20:18:43 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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,6 +393,37 @@ 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{};
|
||||
|
||||
@@ -382,14 +434,14 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
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>{5000});
|
||||
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.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Write new values
|
||||
@@ -398,23 +450,22 @@ 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_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
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.status_update_start_ms_, std::optional<uint32_t>{6500 - 1000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
@@ -502,7 +553,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
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.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
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.
|
||||
@@ -514,7 +565,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
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
|
||||
@@ -522,8 +573,8 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect)
|
||||
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);
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
|
||||
|
||||
@@ -44,9 +44,9 @@ class TestableMitsubishiCN105 : public 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_;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -21,7 +22,6 @@ from esphome.espidf.component import (
|
||||
_check_library_data,
|
||||
_collect_filtered_files,
|
||||
_convert_library_to_component,
|
||||
_detect_requires,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_process_dependencies,
|
||||
@@ -83,19 +83,6 @@ def test_collect_filtered_files_exclude(tmp_path):
|
||||
assert str(f2) not in result
|
||||
|
||||
|
||||
def test_detect_requires(tmp_path):
|
||||
f = tmp_path / "main.c"
|
||||
f.write_text('#include "mbedtls/foo.h"')
|
||||
|
||||
result = _detect_requires([str(f)])
|
||||
assert "mbedtls" in result
|
||||
|
||||
|
||||
def test_detect_requires_ignores_invalid_file(tmp_path):
|
||||
result = _detect_requires([str(tmp_path / "missing.c")])
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_split_list_by_condition():
|
||||
items = ["-Iinclude", "-Llib", "-Wall"]
|
||||
|
||||
@@ -142,7 +129,7 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
|
||||
== f"""idf_component_register(
|
||||
SRCS "src{sep}main.c"
|
||||
INCLUDE_DIRS "src"
|
||||
REQUIRES dep
|
||||
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
"-DTEST"
|
||||
@@ -160,6 +147,58 @@ target_link_libraries(${{COMPONENT_LIB}} INTERFACE
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -187,27 +226,6 @@ dependencies:
|
||||
)
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_arduino_registry_dep(tmp_component):
|
||||
# Synthetic arduino-esp32 dep with no source / no path: should emit a
|
||||
# version-only entry so the IDF component manager resolves it from the
|
||||
# registry instead of via git.
|
||||
dep = IDFComponent("espressif/arduino-esp32", "3.3.8", source=None)
|
||||
|
||||
tmp_component.dependencies = [dep]
|
||||
tmp_component.data = {}
|
||||
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
|
||||
assert (
|
||||
result
|
||||
== """version: 1.0.0
|
||||
dependencies:
|
||||
espressif/arduino-esp32:
|
||||
version: 3.3.8
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
@@ -250,14 +268,126 @@ def test_check_library_data_invalid_framework(esp32_idf_core):
|
||||
_check_library_data({"platforms": "*", "frameworks": ["other"]})
|
||||
|
||||
|
||||
def test_extra_script_logs_warning(caplog, esp32_idf_core):
|
||||
extra_script = "myscript.sh"
|
||||
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"):
|
||||
_check_library_data({"build": {"extraScript": extra_script}})
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert "not supported" in caplog.text
|
||||
assert "myscript.sh" in caplog.text
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
@@ -456,11 +464,14 @@ def test_clean_build(
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.platformio_cache_dir = str(platformio_cache_dir)
|
||||
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
|
||||
@@ -483,6 +494,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
|
||||
@@ -490,6 +503,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
|
||||
|
||||
|
||||
@@ -511,7 +526,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()
|
||||
@@ -548,7 +563,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()
|
||||
@@ -584,7 +599,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()
|
||||
@@ -622,7 +637,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()
|
||||
@@ -1351,7 +1366,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)
|
||||
@@ -1415,7 +1430,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)
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user