Merge branch 'esp8266-native-pch' into esp32-idf-pch

This commit is contained in:
J. Nick Koston
2026-08-26 22:06:04 -05:00
144 changed files with 7653 additions and 644 deletions
@@ -49,7 +49,7 @@ static void Encode_ListEntitiesSensorResponse(benchmark::State &state) {
auto msg = make_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -69,7 +69,7 @@ static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -117,7 +117,7 @@ static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) {
auto msg = make_binary_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -137,7 +137,7 @@ static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &sta
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -202,7 +202,7 @@ static void Encode_ListEntitiesLightResponse(benchmark::State &state) {
auto msg = make_light_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -222,7 +222,7 @@ static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -23,7 +23,7 @@ static void Encode_LogResponse_Typical(benchmark::State &state) {
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -42,7 +42,7 @@ static void Encode_LogResponse_Short(benchmark::State &state) {
msg.level = enums::LOG_LEVEL_INFO;
msg.set_message(reinterpret_cast<const uint8_t *>(kShortLogLine), strlen(kShortLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -84,7 +84,7 @@ static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -105,7 +105,7 @@ static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -33,7 +33,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
(void) buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -44,7 +44,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(padding + size);
(void) buffer.resize(padding + size);
ProtoWriteBuffer writer(&buffer, padding);
msg.encode(writer);
@@ -70,7 +70,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
(void) buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -85,7 +85,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(offset + padding + size + footer);
(void) buffer.resize(offset + padding + size + footer);
ProtoWriteBuffer writer(&buffer, offset + padding);
msg.encode(writer);
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
template<typename T> static APIBuffer encode_message(const T &msg) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
return buffer;
@@ -19,7 +19,7 @@ static void Encode_SensorStateResponse(benchmark::State &state) {
msg.state = 23.5f;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -60,7 +60,7 @@ static void CalcAndEncode_SensorStateResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -84,7 +84,7 @@ static void CalcAndEncode_SensorStateResponse_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -103,7 +103,7 @@ static void Encode_BinarySensorStateResponse(benchmark::State &state) {
msg.state = true;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -126,7 +126,7 @@ static void Encode_HelloResponse(benchmark::State &state) {
msg.server_info = StringRef::from_lit("esphome v2026.3.0");
msg.name = StringRef::from_lit("living-room-sensor");
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -158,7 +158,7 @@ static void Encode_LightStateResponse(benchmark::State &state) {
msg.warm_white = 0.0f;
msg.effect = StringRef::from_lit("rainbow");
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -243,7 +243,7 @@ static void Encode_DeviceInfoResponse(benchmark::State &state) {
auto msg = make_device_info_response();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
(void) buffer.resize(total_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -264,7 +264,7 @@ static void CalcAndEncode_DeviceInfoResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -285,7 +285,7 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -335,7 +335,7 @@ static void Encode_BLERawAdvs12(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
(void) buffer.resize(total_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -355,7 +355,7 @@ static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -372,7 +372,7 @@ static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
// Encodes `src` into `out`. Caller owns `out` and must keep it alive across
// the decode loop (decoded messages may store pointers back into its bytes).
template<typename T> static void encode_into(APIBuffer &out, const T &src) {
out.resize(src.calculate_size());
(void) out.resize(src.calculate_size());
ProtoWriteBuffer writer(&out, 0);
src.encode(writer);
}
@@ -33,7 +33,7 @@ static void Encode_ZWaveProxyFrame(benchmark::State &state) {
msg.data = kZWaveFrameData;
msg.data_len = sizeof(kZWaveFrameData);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -111,7 +111,7 @@ static void Encode_SerialProxyDataReceived(benchmark::State &state) {
msg.instance = 0;
msg.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -171,7 +171,7 @@ static void Encode_InfraredRFReceiveEvent(benchmark::State &state) {
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -254,7 +254,7 @@ static APIBuffer build_infrared_rf_transmit_wire() {
put_varint(1);
APIBuffer buf;
buf.resize(len);
(void) buf.resize(len);
std::memcpy(buf.data(), bytes, len);
return buf;
}
@@ -58,7 +58,7 @@ BENCHMARK(ProtoVarInt_Parse_FiveByte);
static void Encode_Varint_Small(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -73,7 +73,7 @@ BENCHMARK(Encode_Varint_Small);
static void Encode_Varint_Large(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -88,7 +88,7 @@ BENCHMARK(Encode_Varint_Large);
static void Encode_Varint_MaxUint32(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -0,0 +1,63 @@
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A plain string with a return statement compiles as a lambda and warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
assert "return millis();" in main_cpp
# The source text must not be sent as a static string value.
assert '"return millis();"' not in main_cpp
assert "missing the !lambda tag" in caplog.text
def test_static_string_is_kept_as_static_value(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A static string stays static, PROGMEM wrapped, with no warning."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert (
main_cpp.count(
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
)
== 2
)
assert "static value" not in caplog.text
def test_static_id_value_stays_literal_with_hint(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Lambda source without a return stays literal text but warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
assert "sent as literal text" in caplog.text
def test_explicit_lambda_tag_is_compiled_as_lambda(
generate_main: Callable[[str | Path], str],
) -> None:
"""A !lambda value keeps working unchanged."""
main_cpp = generate_main(CONFIG)
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
assert "return App.get_name();" in main_cpp
@@ -0,0 +1,32 @@
esphome:
name: test
on_boot:
then:
# Plain strings with a return statement compile as lambdas
- homeassistant.event:
event: esphome.test_event
data_template:
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
tagged_var: !lambda return App.get_name();
hint_var: id(test_sensor).state
- homeassistant.action:
action: notify.notify
data_template:
message: "{{ lambda_var }} {{ static_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
api:
@@ -1,16 +1,21 @@
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
"""Tests for the external_components config pass."""
import logging
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from esphome.components.external_components import do_external_components_pass
from esphome.const import (
CONF_EXTERNAL_COMPONENTS,
CONF_PATH,
CONF_REFRESH,
CONF_SOURCE,
CONF_URL,
TYPE_GIT,
TYPE_LOCAL,
)
from esphome.core import CORE, TimePeriodSeconds
@@ -69,3 +74,112 @@ def test_external_components_normal_refresh(
mock_clone_or_update.assert_called_once()
call_args = mock_clone_or_update.call_args
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
def test_external_components_logs_built_in_override(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source that provides a component with the same name as a built-in one logs an info message."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
for name in ("gpio", "some_custom_component"):
component_dir = tmp_path / "components" / name
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert (
"External components are overriding built-in components:\n"
" source: https://github.com/test/components\n"
" components: gpio" in caplog.text
)
assert "some_custom_component" not in caplog.text
def test_external_components_override_log_includes_ref(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A git source with a ref logs the ref appended to the url."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main"
component_dir = tmp_path / "components" / "gpio"
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert " source: https://github.com/test/components.git@main\n" in caplog.text
def test_external_components_override_log_includes_git_path(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A git source with a subdirectory path logs the path after the url."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components"
component_dir = tmp_path / "components" / "gpio"
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert " source: https://github.com/test/components (components)\n" in caplog.text
def test_external_components_override_log_local_source(
tmp_path: Path,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A local source logs its resolved path."""
components_dir = tmp_path / "my_components"
gpio_dir = components_dir / "gpio"
gpio_dir.mkdir(parents=True)
(gpio_dir / "__init__.py").write_text("# Test component")
CORE.config_path = tmp_path / "dummy.yaml"
config = {
CONF_EXTERNAL_COMPONENTS: [
{CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}}
]
}
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert f" source: {components_dir}\n" in caplog.text
assert " components: gpio" in caplog.text
def test_external_components_no_override_no_log(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source that only provides components not shipped with ESPHome logs nothing."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert "are overriding built-in components" not in caplog.text
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
watchdog_timeout: 20s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
watchdog_timeout: 60s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
@@ -0,0 +1,13 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,13 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,42 @@
"""Tests for the http_request watchdog timeout default."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.const import CONF_WATCHDOG_TIMEOUT
from esphome.core import CORE, TimePeriodMilliseconds
@pytest.mark.parametrize(
("yaml_file", "expected_ms"),
[
# stock 4.5s timeout: 3 x 4.5s plus 1s margin
("test_esp32_stock.yaml", 14500),
# 3 x 10s plus 1s margin
("test_esp32_default.yaml", 31000),
# esp32.watchdog_timeout: 60s is wider than the derived value and wins
("test_esp32_platform_wider.yaml", 60000),
# explicit value is kept as is
("test_esp32_explicit.yaml", 20000),
],
)
def test_esp32_watchdog_timeout(
component_config_path: Callable[[str], Path], yaml_file: str, expected_ms: int
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert config["http_request"][CONF_WATCHDOG_TIMEOUT] == TimePeriodMilliseconds(
milliseconds=expected_ms
)
@pytest.mark.parametrize("yaml_file", ["test_esp8266.yaml", "test_rp2040.yaml"])
def test_other_platforms_leave_watchdog_unset(
component_config_path: Callable[[str], Path], yaml_file: str
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert CONF_WATCHDOG_TIMEOUT not in config["http_request"]
@@ -0,0 +1,36 @@
esphome:
name: test-dropdown-update-event
on_boot:
- lvgl.dropdown.update:
id: test_dropdown
selected_index: 2
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- dropdown:
id: test_dropdown
options:
- First
- Second
- Third
on_update:
- lambda: |-
ESP_LOGD("test", "dropdown updated");
@@ -0,0 +1,41 @@
"""Regression test: lvgl.dropdown.update with selected_index must fire on_value/on_update.
LvSelect (backing both dropdown and roller) did not set `value_property`, so the generic
update-action machinery in automation.py never sent the synthetic update event for a
`selected_index:` change made via `lvgl.dropdown.update`/`lvgl.roller.update`, unlike `value:`
on number widgets or `text:` on text widgets. Fixed by setting `LvSelect.value_property` to
`CONF_SELECTED_INDEX`.
"""
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:
config_path = (
Path(request.fspath).parent / "config" / "dropdown_update_fires_event_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_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_dropdown_update_sends_update_event(main_cpp: str) -> None:
assert (
"lv_obj_send_event(test_dropdown->obj, lvgl::lv_update_event, nullptr)"
in main_cpp
)
@@ -0,0 +1,48 @@
"""non_blocking is family-gated at config validation; the CI build boards never compile
the ISR paths, so this gate is the only CI-reachable coverage for the platform matrix."""
import pytest
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_FAMILY,
KEY_LIBRETINY,
)
from esphome.components.remote_transmitter import _validate_non_blocking_platform
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize(
("platform_framework", "family", "accepted"),
[
(PlatformFramework.ESP32_IDF, None, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False),
(PlatformFramework.ESP8266_ARDUINO, None, False),
],
)
def test_non_blocking_platform_gate(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
family: str | None,
accepted: bool,
) -> None:
set_core_config(platform_framework)
if family is not None:
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family}
if accepted:
assert _validate_non_blocking_platform(True) is True
else:
with pytest.raises(cv.Invalid, match="non_blocking is only supported on"):
_validate_non_blocking_platform(True)
+227
View File
@@ -0,0 +1,227 @@
"""Tests for SPI PSRAM DMA configuration validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
from esphome.components.spi import (
CONF_INTERFACE_INDEX,
CONF_PSRAM_DMA,
_final_validate,
spi_device_schema,
)
from esphome.config import Config
from esphome.const import CONF_ID, CONF_SPI_ID, KEY_FRAMEWORK_VERSION, PlatformFramework
from esphome.core import CORE, ID
from tests.component_tests.types import SetCoreConfigCallable
def _schema() -> cv.Schema:
return spi_device_schema(
cs_pin_required=False,
default_data_rate="1MHz",
default_mode="MODE0",
)
def _stage(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
variant: str,
version: cv.Version,
) -> None:
set_core_config(
platform_framework,
core_data={KEY_FRAMEWORK_VERSION: version},
platform_data={KEY_BOARD: "test-board", KEY_VARIANT: variant},
)
CORE.loaded_integrations.add("psram")
def test_psram_dma_accepts_supported_idf_target(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_IDF,
VARIANT_ESP32S3,
cv.Version(5, 5, 3),
)
config = _schema()({CONF_PSRAM_DMA: True})
assert config[CONF_PSRAM_DMA] is True
def test_psram_dma_accepts_esp32s31(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_IDF,
VARIANT_ESP32S31,
cv.Version(6, 0, 0),
)
config = _schema()({CONF_PSRAM_DMA: True})
assert config[CONF_PSRAM_DMA] is True
def test_psram_dma_rejects_arduino(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_ARDUINO,
VARIANT_ESP32S3,
cv.Version(5, 5, 3),
)
with pytest.raises(cv.Invalid, match="only available with framework"):
_schema()({CONF_PSRAM_DMA: True})
def test_psram_dma_rejects_target_without_capability(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_IDF,
VARIANT_ESP32,
cv.Version(5, 5, 3),
)
with pytest.raises(cv.Invalid, match="PSRAM DMA is only available"):
_schema()({CONF_PSRAM_DMA: True})
def test_psram_dma_false_is_portable(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_ARDUINO,
VARIANT_ESP32,
cv.Version(5, 5, 2),
)
config = _schema()({CONF_PSRAM_DMA: False})
assert config[CONF_PSRAM_DMA] is False
def test_psram_dma_rejects_older_idf(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_IDF,
VARIANT_ESP32S3,
cv.Version(5, 5, 2),
)
with pytest.raises(cv.Invalid, match="requires at least framework version 5.5.3"):
_schema()({CONF_PSRAM_DMA: True})
def test_psram_dma_requires_psram_component(
set_core_config: SetCoreConfigCallable,
) -> None:
_stage(
set_core_config,
PlatformFramework.ESP32_IDF,
VARIANT_ESP32S3,
cv.Version(5, 5, 3),
)
CORE.loaded_integrations.remove("psram")
with pytest.raises(cv.Invalid, match="requires component psram"):
_schema()({CONF_PSRAM_DMA: True})
def _full_spi_config(*, hardware: bool, with_device: bool = True) -> tuple[Config, ID]:
bus_id = ID("spi_bus", is_declaration=True, type="SPIComponent")
bus = {CONF_ID: bus_id}
if hardware:
bus[CONF_INTERFACE_INDEX] = 0
full = Config()
full["spi"] = [bus]
if with_device:
full["spi_device_test"] = {
CONF_SPI_ID: ID("spi_bus"),
CONF_PSRAM_DMA: True,
}
full.declare_ids.append((bus_id, ["spi", 0, CONF_ID]))
return full, ID("spi_bus", is_declaration=False, type="SPIComponent")
def test_psram_dma_accepts_hardware_spi(
set_core_config: SetCoreConfigCallable,
) -> None:
full_config, _ = _full_spi_config(hardware=True)
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
_final_validate(full_config["spi"])
def test_psram_dma_rejects_software_spi(
set_core_config: SetCoreConfigCallable,
) -> None:
full_config, _ = _full_spi_config(hardware=False)
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error:
_final_validate(full_config["spi"])
assert error.value.path[-2:] == ["spi_device_test", CONF_PSRAM_DMA]
def test_spi_bus_rejects_psram_dma_device_without_component_final_validation(
set_core_config: SetCoreConfigCallable,
) -> None:
full_config, bus_id = _full_spi_config(hardware=False, with_device=False)
full_config["device_without_final_validation"] = {
CONF_SPI_ID: bus_id,
CONF_PSRAM_DMA: True,
}
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error:
_final_validate(full_config["spi"])
assert error.value.path[-2:] == [
"device_without_final_validation",
CONF_PSRAM_DMA,
]
def test_psram_dma_accepts_hardware_device_with_mixed_buses(
set_core_config: SetCoreConfigCallable,
) -> None:
software_bus_id = ID("software_bus", is_declaration=True, type="SPIComponent")
hardware_bus_id = ID("hardware_bus", is_declaration=True, type="SPIComponent")
full_config = Config()
full_config["spi"] = [
{CONF_ID: software_bus_id},
{CONF_ID: hardware_bus_id, CONF_INTERFACE_INDEX: 0},
]
full_config["spi_device_test"] = {
CONF_SPI_ID: ID("hardware_bus"),
CONF_PSRAM_DMA: True,
}
full_config.declare_ids.extend(
(
(software_bus_id, ["spi", 0, CONF_ID]),
(hardware_bus_id, ["spi", 1, CONF_ID]),
)
)
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
_final_validate(full_config["spi"])
+8
View File
@@ -9,6 +9,14 @@ esphome:
event: esphome.button_pressed
data:
message: Button was pressed
- homeassistant.event:
event: esphome.button_pressed_with_variables
data_template:
message: Button {{ button_name }} ({{ button_index }}) was pressed from {{ button_source }}
variables:
button_name: !lambda 'return std::string("test_button");'
button_index: !lambda 'return 1;'
button_source: static_value
- homeassistant.action:
action: notify.html5
data:
@@ -54,7 +54,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) {
size_t ref_len = reference_encode(mac, ref_buf);
APIBuffer api_buf;
api_buf.resize(16);
ASSERT_TRUE(api_buf.resize(16));
uint8_t *pos = api_buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
+73
View File
@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include "esphome/components/climate/climate.h"
namespace esphome::climate::testing {
// Minimal concrete Climate that offers a fixed set of modes, so the restore path can be exercised
// without any hardware or platform component.
class TestClimate : public Climate {
public:
ClimateTraits traits() override {
auto traits = ClimateTraits();
traits.set_supported_modes({CLIMATE_MODE_OFF, CLIMATE_MODE_COOL});
traits.set_supported_fan_modes({CLIMATE_FAN_LOW, CLIMATE_FAN_HIGH});
return traits;
}
protected:
void control(const ClimateCall &call) override {}
};
TEST(ClimateRestoreStateTest, RestoresASupportedMode) {
TestClimate climate;
// Value-initialized: several members (mode, swing_mode, the temperature union) have no default
// member initializer, so leaving the {} off would read indeterminate values.
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_COOL;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, DoesNotRestoreAnUnsupportedMode) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
// The device never advertised HEAT, so the mode stays where it was.
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
}
TEST(ClimateRestoreStateTest, LeavesTheCurrentModeAloneRatherThanForcingOff) {
TestClimate climate;
// apply() is public and nothing restricts it to setup(), so the entity is not necessarily off
// when an unsupported mode is dropped. It keeps what it had rather than being forced to OFF.
climate.mode = CLIMATE_MODE_COOL;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.target_temperature = 21.0f;
state.uses_custom_fan_mode = false;
state.fan_mode = CLIMATE_FAN_HIGH;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
EXPECT_FLOAT_EQ(climate.target_temperature, 21.0f);
// Compared as an optional: this asserts both that the fan mode was restored and what it holds.
EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH);
}
} // namespace esphome::climate::testing
@@ -12,5 +12,8 @@ climate:
- platform: climate_ir_lg
name: LG Climate
transmitter_id: xmitr
header_high: 3300us
header_low: 9840us
advanced_commands_support: true
sensor: climate_ir_lg_temp_sensor
humidity_sensor: humidity_sensor
@@ -0,0 +1,72 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/button/hoermann_hcp_button.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
// The intermediate positions are named in the second register, which repeats that name on release.
TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) {
TestableHoermannHcp door;
HoermannHcpVentButton vent(&door);
connect_controller(door);
vent.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x4000);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x4000);
}
TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) {
TestableHoermannHcp door;
HoermannHcpHalfOpenButton half_open(&door);
connect_controller(door);
half_open.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x0400);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x0400);
}
// The door drives to the vent position on its own, so a position the cover was still travelling to must not
// stop it on the way there.
TEST(HoermannHcpButtonTest, VentAbandonsAnArmedTarget) {
TestableHoermannHcp door; // starts out fully closed
HoermannHcpVentButton vent(&door);
connect_controller(door);
door.set_position(0.5f);
consume_command(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
vent.press();
consume_command(door);
// Position 120/200 = 0.6 is past the abandoned target, which must no longer stop the door.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A button carries no state, so a refused press is simply dropped rather than fired once the controller
// turns up, which could be much later.
TEST(HoermannHcpButtonTest, PressWithoutABusControllerSendsNothing) {
HoermannHcp door; // never contacted by a bus controller
HoermannHcpVentButton vent(&door);
vent.press();
EXPECT_EQ(poll_command(door).first, 0x0000);
}
} // namespace esphome::hoermann_hcp::testing
@@ -12,6 +12,13 @@ binary_sensor:
is_connected:
name: Garage Connected
button:
- platform: hoermann_hcp
vent:
name: Garage Vent
half_open:
name: Garage Half Open
light:
- platform: hoermann_hcp
name: Garage Light
+2 -2
View File
@@ -12,7 +12,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
- homeassistant.action:
action: notify.html5
data:
@@ -24,7 +24,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
wifi:
ssid: MySSID
@@ -0,0 +1,11 @@
wifi:
ssid: MySSID
password: password1
# Serial logging off; on a dedicated UART bus improv_serial must not
# require the logger's serial settings
logger:
baud_rate: 0
improv_serial:
uart_id: uart_bus
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
improv_serial: !include common-uart-bus.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
improv_serial: !include common-uart-bus.yaml
@@ -13,7 +13,7 @@ namespace esphome::modbus_controller::testing {
// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with
// a log instead.
TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
ModbusController controller;
ModbusController controller(nullptr, 1);
std::vector<bool> coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true);
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size()));
@@ -21,7 +21,7 @@ TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce.
TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
ModbusController controller;
ModbusController controller(nullptr, 1);
const std::vector<bool> coils{true, false, true, true};
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
ASSERT_EQ(cmd.payload.size(), 1u);
@@ -2,6 +2,7 @@ remote_transmitter:
id: xmitr
pin: GPIO26
carrier_duty_percent: 50%
# non_blocking is bk7231n/bk7238-only; the CI board is a BK7252
packages:
buttons: !include common-buttons.yaml
@@ -2,6 +2,7 @@ remote_transmitter:
id: xmitr
pin: GPIO12
carrier_duty_percent: 50%
# non_blocking is rtl8720c-only; the CI board is an RTL8710B
packages:
buttons: !include common-buttons.yaml
@@ -0,0 +1,10 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
common: !include common.yaml
psram:
mode: octal
spi_device:
- id: spi_device_psram_dma_test
psram_dma: true
data_rate: 1MHz
spi_mode: 0
@@ -0,0 +1,39 @@
"""Host-only stub of the wifi component for integration tests.
HOST-ONLY TEST COMPONENT: this shadows the real wifi component for EVERY
fixture that uses the shared external_components directory. Any host fixture
with a wifi block gets this stub, not the real component: fixed scan results,
is_connected() hardwired true, and save_wifi_sta that only logs. See
wifi_component.h for the full behavior.
"""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PASSWORD, CONF_SSID, CONF_USE_ADDRESS
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
wifi_ns = cg.esphome_ns.namespace("wifi")
WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(WiFiComponent),
# Accepted for fixture realism; the stub ignores them
cv.Optional(CONF_SSID): cv.string,
cv.Optional(CONF_PASSWORD): cv.string,
# Read by StorageJSON via CORE.address whenever a wifi block exists
cv.Optional(CONF_USE_ADDRESS, default="localhost"): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
def check_placeholder_credentials(config: ConfigType) -> None:
"""Compile-time hook the esphome CLI imports from the wifi module; no-op here."""
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_define("USE_WIFI")
@@ -0,0 +1 @@
../../../../../esphome/components/wifi/scan_list.h
@@ -0,0 +1,40 @@
#include "wifi_component.h"
#include "esphome/core/log.h"
namespace esphome::wifi {
static const char *const TAG = "wifi_stub";
WiFiComponent *global_wifi_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
void WiFiComponent::setup() { ESP_LOGI(TAG, "Stub wifi ready"); }
void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, "Stub wifi"); }
void WiFiComponent::start_scanning() {
// Duplicate TestNet entry (weaker) and a hidden entry exercise the
// should_show_scan_entry dedup and filtering logic
this->scan_result_.clear();
this->scan_result_.emplace_back("TestNet", -50, true, false);
this->scan_result_.emplace_back("TestNet", -60, true, false);
this->scan_result_.emplace_back("OpenNet", -70, false, false);
this->scan_result_.emplace_back("", -40, false, true);
ESP_LOGI(TAG, "Scan complete with %zu results", this->scan_result_.size());
}
void WiFiComponent::set_sta(const WiFiAP &ap) { ESP_LOGI(TAG, "set_sta ssid=%s", ap.get_ssid().c_str()); }
void WiFiComponent::start_connecting(const WiFiAP &ap) {
ESP_LOGI(TAG, "start_connecting ssid=%s", ap.get_ssid().c_str());
}
void WiFiComponent::clear_sta() { ESP_LOGI(TAG, "clear_sta"); }
void WiFiComponent::save_wifi_sta(StringRef ssid, StringRef password) {
ESP_LOGI(TAG, "save_wifi_sta ssid=%s password_len=%zu", ssid.c_str(), password.size());
}
} // namespace esphome::wifi
@@ -0,0 +1,77 @@
#pragma once
// ============================================================================
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
//
// Stub of the real wifi component with just enough API surface for
// improv_serial to build and run on the host platform. Scan results are
// fixed, "connecting" succeeds immediately, and save_wifi_sta only logs so
// tests can assert on the log output.
// ============================================================================
#include "esphome/components/network/ip_address.h"
#include "esphome/core/component.h"
#include "esphome/core/string_ref.h"
#include <string>
#include <vector>
namespace esphome::wifi {
class WiFiAP {
public:
void set_ssid(const char *ssid) { this->ssid_ = ssid; }
void set_password(const char *password) { this->password_ = password; }
StringRef get_ssid() const { return StringRef(this->ssid_); }
StringRef get_password() const { return StringRef(this->password_); }
protected:
std::string ssid_;
std::string password_;
};
class WiFiScanResult {
public:
WiFiScanResult(const char *ssid, int8_t rssi, bool with_auth, bool hidden)
: ssid_(ssid), rssi_(rssi), with_auth_(with_auth), hidden_(hidden) {}
StringRef get_ssid() const { return StringRef(this->ssid_); }
int8_t get_rssi() const { return this->rssi_; }
bool get_with_auth() const { return this->with_auth_; }
bool get_is_hidden() const { return this->hidden_; }
bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; }
protected:
std::string ssid_;
int8_t rssi_;
bool with_auth_;
bool hidden_;
};
class WiFiComponent : public Component {
public:
WiFiComponent();
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::WIFI; }
bool has_sta() const { return false; }
bool is_disabled() const { return false; }
// Always connected so network::is_connected() keeps the API server accepting clients
bool is_connected() const { return true; }
void start_scanning();
const std::vector<WiFiScanResult> &get_scan_result() const { return this->scan_result_; }
void set_sta(const WiFiAP &ap);
void start_connecting(const WiFiAP &ap);
void clear_sta();
void save_wifi_sta(StringRef ssid, StringRef password);
// Called by network::util on any USE_WIFI build
const char *get_use_address() const { return "localhost"; }
network::IPAddresses get_ip_addresses() { return {}; }
protected:
std::vector<WiFiScanResult> scan_result_;
};
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::wifi
@@ -0,0 +1,40 @@
esphome:
# Short name keeps the device info payload under uart_mock's 64 byte log cap
name: improv-uart
host:
api:
actions:
- action: uart_inject
variables:
payload: int[]
then:
- uart_mock.inject_rx:
id: mock_uart
data: !lambda return std::vector<uint8_t>(payload.begin(), payload.end());
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Host-only stub shadowing the real wifi component (see external_components/wifi)
wifi:
ssid: TestNet
password: password1
# Dummy uart entry so the uart component sources are part of the build; the
# actual bus used by improv_serial is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
improv_serial:
uart_id: mock_uart
@@ -0,0 +1,106 @@
esphome:
name: uart-mock-modbus-dep-buffer
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
# entity no matter how many writes happen.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x10
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
payload.push_back(0x0106);
payload.push_back(0x0010);
payload.push_back((uint16_t) x);
return {};
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
sensor:
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg10);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# The test drives the writes via number_command; the mock is autostart.
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-modbus-lambda-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,138 @@
esphome:
name: uart-mock-modbus-reg-offset
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
write_lambda: id(reg13) = x; return true;
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for asserting on log output in integration tests."""
from __future__ import annotations
import asyncio
class LineWaiter:
"""Collects log lines and lets a test await one containing all needles.
Pass ``callback`` as ``run_compiled``'s ``line_callback``; the callback runs
on the test's own event loop, so futures are resolved directly. Only one
``wait_for`` may be outstanding at a time (tests await sequentially).
"""
def __init__(self) -> None:
self.lines: list[str] = []
self._needles: tuple[str, ...] = ()
self._future: asyncio.Future | None = None
def callback(self, line: str) -> None:
self.lines.append(line)
if (
self._future is not None
and not self._future.done()
and all(n in line for n in self._needles)
):
self._future.set_result(line)
self._future = None
async def wait_for(self, *needles: str, timeout: float = 10.0) -> str:
"""Return the first line, past or future, containing every needle."""
for line in self.lines:
if all(n in line for n in needles):
return line
assert self._future is None or self._future.done(), "concurrent wait_for"
self._needles = needles
self._future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._future, timeout)
finally:
self._future = None
self._needles = ()
@@ -0,0 +1,140 @@
"""Integration test for improv_serial over a mocked UART bus.
Drives the improv serial protocol end to end on the host platform:
the fixture wires improv_serial to a uart_mock bus and shadows the wifi
component with a host stub. The test injects improv frames through an API
action and asserts on the framed responses that uart_mock logs as TX lines.
Covered:
1. Get Current State reports AUTHORIZED
2. Get Device Info returns the firmware/device info RPC response
3. Get Wi-Fi Networks returns deduplicated scan results and a terminator
4. Wi-Fi Settings provisions: saves credentials and reports PROVISIONED
"""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
# Improv serial framing (improv_serial_component.h)
IMPROV_HEADER = b"IMPROV"
IMPROV_VERSION = 1
TYPE_CURRENT_STATE = 0x01
TYPE_RPC = 0x03
TYPE_RPC_RESPONSE = 0x04
# improv::Command values
CMD_GET_CURRENT_STATE = 0x02
CMD_GET_DEVICE_INFO = 0x03
CMD_GET_WIFI_NETWORKS = 0x04
CMD_WIFI_SETTINGS = 0x01
def build_rpc_frame(command: int, data: bytes = b"") -> list[int]:
"""Build a full improv serial frame carrying one RPC command."""
payload = bytes([command, len(data)]) + data
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC, len(payload)]) + payload
checksum = sum(frame) & 0xFF
return list(frame + bytes([checksum]) + b"\n")
def state_frame_hex(state: int) -> str:
"""Full 12 byte current-state frame as hex, checksum and newline included."""
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_CURRENT_STATE, 1, state])
checksum = sum(frame) & 0xFF
return ":".join(f"{b:02X}" for b in frame + bytes([checksum]) + b"\n")
def rpc_footer_hex(payload: bytes) -> str:
"""Checksum and newline footer written after an RPC response payload."""
header = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC_RESPONSE, len(payload)])
checksum = (sum(header) + sum(payload)) & 0xFF
return f"{checksum:02X}:0A"
def wifi_settings_data(ssid: str, password: str) -> bytes:
ssid_b = ssid.encode()
pass_b = password.encode()
return bytes([len(ssid_b)]) + ssid_b + bytes([len(pass_b)]) + pass_b
def hex_of(text: str) -> str:
"""Colon separated uppercase hex as logged by format_hex_pretty."""
return ":".join(f"{b:02X}" for b in text.encode())
@pytest.mark.asyncio
async def test_improv_serial_uart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
inject = next(s for s in services if s.name == "uart_inject")
# 1. Get Current State: expect the complete current-state frame reporting
# AUTHORIZED (0x02), checksum and newline included
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_CURRENT_STATE)}
)
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x02)}")
# 2. Get Device Info: the always logged 9 byte response header, then the
# payload with the firmware name (must stay under uart_mock's 64 byte
# hex dump cap or the payload line reads "too large to log")
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_DEVICE_INFO)}
)
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04")
await waiter.wait_for("uart_mock", "TX ", hex_of("ESPHome"))
# 3. Get Wi-Fi Networks: stub scan has TestNet twice (dedup keeps the
# stronger), OpenNet, and a hidden entry (filtered). Expect one response
# per visible network plus the empty terminator.
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_WIFI_NETWORKS)}
)
await waiter.wait_for("uart_mock", hex_of("TestNet"))
await waiter.wait_for("uart_mock", hex_of("OpenNet"))
# Terminator: all three writes of the response frame; 9 byte header,
# payload [0x04, 0x00, 0x00], then the checksum and newline footer
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04:03")
await waiter.wait_for("uart_mock", "TX 3 bytes: 04:00:00")
await waiter.wait_for(
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x04, 0x00, 0x00]))}"
)
testnet_count = sum(
1
for line in waiter.lines
if "uart_mock" in line and "TX " in line and hex_of("TestNet") in line
)
assert testnet_count == 1, (
f"Duplicate scan entry not deduplicated: {testnet_count} TestNet responses"
)
# 4. Wi-Fi Settings: stub connects immediately; expect the credentials
# saved, the PROVISIONED state frame (0x04), and the settings response
await client.execute_service(
inject,
{
"payload": build_rpc_frame(
CMD_WIFI_SETTINGS, wifi_settings_data("NewNet", "secret123")
)
},
)
await waiter.wait_for("save_wifi_sta ssid=NewNet")
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x04)}")
# Settings RPC response with no URLs: payload [0x01, 0x00, 0x00] and footer
await waiter.wait_for("uart_mock", "TX 3 bytes: 01:00:00")
await waiter.wait_for(
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x01, 0x00, 0x00]))}"
)
+146 -1
View File
@@ -24,7 +24,7 @@ from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .state_utils import SensorTracker, find_entity
from .state_utils import SensorTracker, find_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -965,3 +965,148 @@ async def test_uart_mock_modbus_client_read_write(
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.xfail(
strict=True,
reason="Byte-accurate register-offset writes land in the follow-up offset fix; "
"until then the byte offset is folded into the address (writes 0x12 instead of "
"0x11). The write and read assertions both flip via the same switch-constructor "
"fold. Remove this marker when that change merges.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that a byte offset on a holding-register write is byte-accurate.
`offset` is a byte offset, so a holding-register write at address 0x10 with offset: 2 must target
register 0x10 + 2/2 = 0x11. The pre-fix behavior folded the byte offset into the address as a register
count (0x10 + 2 = 0x12). The switch is assumed_state (write-only), so reg_11 turning 0xFFFF pins the
fix; had the write landed on 0x12 the wait would time out and reg_12 would change instead.
"""
tracker = SensorTracker(["reg_10", "reg_11", "reg_12"])
initial = tracker.expect_all({"reg_10": 100, "reg_11": 200, "reg_12": 300})
wrote_11 = tracker.expect("reg_11", 65535)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_all(initial, timeout=4.0)
switch = find_entity(entities, "offset_switch", SwitchInfo)
assert switch is not None, "offset_switch not found"
client.switch_command(switch.key, True)
# reg_11 (0x10 + offset 2/2) must receive the write; if the write went to 0x12 this times out.
await tracker.await_change(wrote_11, "reg_11", timeout=4.0)
# And 0x12 (the pre-fix register-offset target) must be untouched.
assert tracker.sensor_states["reg_12"][-1] == 300, (
"reg_12 (0x12) should be untouched - offset is byte-based, so the write targets 0x11; "
f"got {tracker.sensor_states['reg_12']}"
)
# Read path: read_offset_switch has byte offset 6. Post-fix the switch folds the whole registers
# into its address (0x10 + 6/2 = 0x13, residual byte 0) and joins the 0x10..0x13 range, so the
# read lands in-bounds on 0xABCD (bit 0 set) -> ON. Pre-fix the whole byte offset folded into the
# address (0x16); the server answers ILLEGAL_DATA_ADDRESS there and the switch never publishes.
read_switch = find_entity(entities, "read_offset_switch", SwitchInfo)
assert read_switch is not None, "read_offset_switch not found"
# The ON transition happened at the first poll and switch states are deduped, so this relies on
# wait_for_state's fresh subscribe_states re-dumping every entity's current state.
await wait_for_state(
client,
lambda s: (
getattr(s, "key", None) == read_switch.key
and getattr(s, "state", None) is True
),
timeout=6.0,
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a write_lambda that drives the write through the entity itself (item is the command).
`cross_switch` is a coil-type switch whose write_lambda ignores its own type and calls
item->write_single_register(0x30, ...) - a register write issued from a coil entity. The lambda
returns an empty optional, so the write path detects the lambda already dispatched a frame and does
not fall back to the default coil write. Success is reg_30 reading back the value the lambda wrote,
which proves both the new item->write_* path and cross-type flexibility.
"""
tracker = SensorTracker(["reg_30"])
initial = tracker.expect("reg_30", 0)
wrote_30 = tracker.expect("reg_30", 1234)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_change(initial, "reg_30", timeout=4.0)
switch = find_entity(entities, "cross_switch", SwitchInfo)
assert switch is not None, "cross_switch not found"
client.switch_command(switch.key, True)
# The coil switch's lambda wrote register 0x30 via item->write_single_register(); reg_30 must
# read back 1234. If the entity-as-command dispatch were broken, no register write would go out
# and this would time out.
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the deprecated write_lambda buffer path still works, and warns once per entity.
buf_number's write_lambda fills the old `payload` buffer with a legacy raw frame as words (device
address + function code + data) and returns {} instead of calling item->write_*. Both writes must
land - a filled buffer is sent, as the docs have always described - and the one-time deprecation
warning must fire exactly once per entity regardless of how many writes happen.
"""
warn_count = 0
def line_callback(line: str) -> None:
nonlocal warn_count
if "write_lambda buffer" in line:
warn_count += 1
tracker = SensorTracker(["written_value"])
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
number = find_entity(entities, "buf_number", NumberInfo)
assert number is not None, "buf_number not found"
# First write via the deprecated buffer path.
client.number_command(number.key, 111)
await tracker.await_change(
tracker.expect("written_value", 111), "written_value", timeout=4.0
)
# Second write: lands too, but must not warn again (warn-once per entity).
client.number_command(number.key, 222)
await tracker.await_change(
tracker.expect("written_value", 222), "written_value", timeout=4.0
)
assert warn_count == 1, (
f"deprecation warning should fire exactly once per entity, got {warn_count}"
)
@@ -0,0 +1,649 @@
"""Tests for script/platformio_install_deps.py."""
from argparse import Namespace
import importlib.util
import inspect
from pathlib import Path
import shutil
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from platformio import fs
from platformio.cache import ContentCache
from platformio.exception import InvalidJSONFile
from platformio.package.manager._install import PackageManagerInstallMixin
from platformio.package.manager.base import BasePackageManager
from platformio.package.manager.library import LibraryPackageManager
from platformio.package.manager.tool import ToolPackageManager
from platformio.package.meta import PackageCompatibility, PackageItem, PackageSpec
import pytest
from semantic_version import Version
_SCRIPT = Path(__file__).parents[2] / "script" / "platformio_install_deps.py"
def _load_script():
spec = importlib.util.spec_from_file_location("platformio_install_deps", _SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# The real ContentCache would create dirs under the user's core dir
module.ContentCache = lambda *_: None
return module
def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key."""
mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0"
)
url = "https://github.com/pioarduino/platform-espressif32/releases/download/{v}/platform-espressif32.zip"
assert mod.spec_key(url.format(v="55.03.311")) == mod.spec_key(
url.format(v="54.03.20")
)
def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"""Parsing skips unpinned and interpolated entries; the CLI rebuild
keeps the original flag pairing."""
ini = tmp_path / "platformio.ini"
ini.write_text(
"[env:a]\n"
"platform = fake/platform@1\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.21\n"
" ${common.lib_deps}\n"
" internal_lib\n"
"[env:b]\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.21\n"
)
mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.21"]
assert platforms == ["fake/platform@1"]
assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [
"-l",
"esphome/noise-c @ 0.1.21",
"-p",
"fake/platform@1",
]
class _FakeManager:
"""Scripted manager_cls: records installs, raises on demand."""
installed: set = set()
fail: set = set()
calls: list = []
lock_events: list = []
base_dir: str = "" # per-test tmp base; set by _reset_fake
def __init__(self, package_dir) -> None:
assert package_dir is None
@staticmethod
def _key(spec) -> str:
return spec if isinstance(spec, str) else str(spec)
def get_package(self, spec):
if self._key(spec) in self.installed:
return SimpleNamespace(path="/tmp/fake-pkg", spec=self._key(spec))
return None
def memcache_reset(self) -> None:
type(self).resets = getattr(type(self), "resets", 0) + 1
@property
def package_dir(self) -> str:
return str(Path(type(self).base_dir) / "packages")
def get_download_dir(self) -> str:
return str(Path(type(self).base_dir) / "downloads")
def get_tmp_dir(self) -> str:
return str(Path(type(self).base_dir) / "tmp")
def lock(self) -> None:
type(self).lock_events.append("lock")
def unlock(self) -> None:
type(self).lock_events.append("unlock")
def _install(self, spec, skip_dependencies, compatibility=None):
assert skip_dependencies is True
if self._key(spec) in self.fail:
raise RuntimeError("boom")
type(self).calls.append(spec)
type(self).compat_calls.append((self._key(spec), compatibility))
type(self).installed.add(self._key(spec)) # atomic under the GIL
def get_pkg_dependencies(self, pkg):
return getattr(type(self), "deps", {}).get(pkg.spec)
dependency_to_spec = staticmethod(BasePackageManager.dependency_to_spec)
def _reset_fake(base_dir: str = "", **kwargs) -> type:
# A fresh subclass per test: nothing leaks between tests through the
# class-level scripted state
return type(
"_ScriptedManager",
(_FakeManager,),
{
"base_dir": base_dir,
"installed": kwargs.get("installed", set()),
"fail": kwargs.get("fail", set()),
"calls": [],
"compat_calls": [],
"lock_events": [],
},
)
def test_parallel_install_empty_specs_is_a_no_op(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
mod.parallel_install(cls, [])
assert cls.calls == [] and cls.lock_events == []
def test_parallel_install_behavior(tmp_path: Path) -> None:
"""Duplicates collapse to one install, installed specs are filtered,
URL specs stay out of the wave, and the lock wraps the pool."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/already @ 1.0"})
mod.parallel_install(
cls,
[
"esphome/noise-c @ 0.1.21",
"esphome/noise-c @ 0.1.21",
"esphome/already @ 1.0",
"https://x/framework.tar.xz",
],
)
assert cls.calls == ["esphome/noise-c @ 0.1.21"]
assert cls.lock_events == ["lock", "unlock"]
def test_parallel_install_failure_cleans_torn_destination(
tmp_path: Path, capsys
) -> None:
"""A failed install resets the memcache, removes what get_package can
see, and reports; the others still install."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
removed = []
torn = str(tmp_path / "packages" / "torn-pkg") # never created; only rmtree'd
def get_package(self, spec):
if spec == "esphome/bad @ 1.0" and getattr(cls, "resets", 0):
return SimpleNamespace(path=torn, spec=spec)
return _FakeManager.get_package(self, spec)
cls.get_package = get_package # throwaway subclass; nothing to restore
with patch.object(mod.fs, "rmtree", side_effect=removed.append):
mod.parallel_install(cls, ["esphome/bad @ 1.0", "esphome/good @ 1.0"])
assert "esphome/good @ 1.0" in cls.calls
assert removed == [torn]
out = capsys.readouterr().out
assert "Pre-install of esphome/bad @ 1.0 failed" in out
assert "Pre-install failed for 1 of 2 package(s)" in out
def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
"""Dependencies of wave-installed packages install in a second wave,
deduped by name; name-only platform libs stay with the serial pass."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.21": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
],
"esphome/wg @ 1.0": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.21"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency
def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
"""A dependency pinned to a URL surfaces as spec.uri; it must stay out
of the wave like string URL specs do."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.21": [
{"name": "vendored", "version": "https://github.com/x/y.git"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
def test_failed_cleanup_fails_the_build(tmp_path: Path) -> None:
"""A torn destination still on disk after rmtree must fail the build:
fs.rmtree never raises (its onexc handler prints), so only the
destination's absence proves the cleanup worked."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
torn = tmp_path / "packages" / "torn-pkg"
torn.mkdir(parents=True)
def get_package(self, spec):
if getattr(cls, "resets", 0):
return SimpleNamespace(path=str(torn), spec=spec)
return None
cls.get_package = get_package # throwaway subclass; nothing to restore
with (
patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed
pytest.raises(mod.CleanupError, match="could not remove"),
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert cls.lock_events == ["lock", "unlock"] # still released
def test_unverifiable_torn_destination_fails_the_build(tmp_path: Path) -> None:
"""When the scan fails, the spec's own .piopm decides: an unremovable
leftover fails the build."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
dest = Path(cls.base_dir) / "packages" / "bad"
dest.mkdir(parents=True)
(dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}')
def bad_reset(self):
raise OSError("scan broken")
cls.memcache_reset = bad_reset
with (
patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed
pytest.raises(mod.CleanupError, match="could not remove"),
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
def test_unverifiable_scan_without_leftover_degrades(tmp_path: Path, capsys) -> None:
"""A failing scan with no destination on disk is never a build
failure blaming this spec."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
resets = {"n": 0}
def bad_reset(self):
# Fail clean_torn's reset; the coordinator's later reset works
resets["n"] += 1
if resets["n"] <= 1:
raise OSError("scan broken")
cls.memcache_reset = bad_reset
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert "No resolvable destination to clean" in capsys.readouterr().out
def test_unresolvable_torn_destination_is_printed(tmp_path: Path, capsys) -> None:
"""A failed install with no resolvable package prints, so an invisible
torn directory is at least traceable."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert "No resolvable destination to clean" in capsys.readouterr().out
def test_unparsable_torn_destination_is_removed(tmp_path: Path, capsys) -> None:
"""A torn dir get_package cannot resolve but whose .piopm names the
spec is removed instead of surviving into the serial pass."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
dest = Path(cls.base_dir) / "packages" / "bad"
dest.mkdir(parents=True)
(dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}')
with patch.object(mod.fs, "rmtree", shutil.rmtree):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert not dest.exists()
assert "Removed torn destination" in capsys.readouterr().out
def test_parse_specs_tools_branch(tmp_path: Path) -> None:
"""platform_packages parsing keeps owner'd tools and rewrites github
URL pins to bare URLs the wave then skips via parsed.uri."""
mod = _load_script()
ini = tmp_path / "platformio.ini"
ini.write_text(
"[env:t]\n"
"platform_packages =\n"
" ${common.platform_packages}\n"
" platformio/tool-scons@~4.40801.0\n"
" framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip\n"
)
args = Namespace(libraries=False, platforms=False, tools=True)
libs, platforms, tools = mod.parse_specs(str(ini), args)
assert libs == [] and platforms == []
assert tools == [
"platformio/tool-scons@~4.40801.0",
"https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip",
]
assert mod.build_cli_args([], [], tools)[:2] == ["-t", tools[0]]
def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"})
cls.deps = {
"esphome/noise-c @ 0.1.21": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
def test_worker_system_exit_still_cleans(tmp_path: Path, capsys) -> None:
"""A worker SystemExit runs the torn cleanup before propagating; the
serial pass must never trust its leftovers."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
torn = tmp_path / "packages" / "torn-pkg"
torn.mkdir(parents=True)
def exiting_install(self, spec, skip_dependencies, compatibility=None):
raise SystemExit(0)
def get_package(self, spec):
if getattr(cls, "resets", 0):
return SimpleNamespace(path=str(torn), spec=spec)
return None
cls._install = exiting_install
cls.get_package = get_package
def real_rmtree(path):
Path(path).rmdir()
with (
patch.object(mod.fs, "rmtree", real_rmtree),
pytest.raises(SystemExit),
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert not torn.exists()
def test_unlock_failure_is_fatal(tmp_path: Path) -> None:
"""A failed unlock must fail the build: the serial pass in another
process would block on the held flock."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
def bad_unlock(self):
raise OSError("flock broke")
cls.unlock = bad_unlock
with pytest.raises(mod.LockReleaseError, match="manager lock"):
mod.parallel_install(cls, ["esphome/good @ 1.0"])
def test_unlock_failure_keeps_inflight_error_as_context(tmp_path: Path) -> None:
"""An in-flight CleanupError stays attached when the unlock fault
takes over the raise."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
torn = tmp_path / "packages" / "bad"
torn.mkdir(parents=True)
def get_package(self, spec):
if getattr(cls, "resets", 0):
return SimpleNamespace(path=str(torn), spec=spec)
return None
def bad_unlock(self):
raise OSError("flock broke")
cls.get_package = get_package
cls.unlock = bad_unlock
with (
patch.object(mod.fs, "rmtree", lambda path: None), # leaves torn
pytest.raises(mod.LockReleaseError) as err,
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert isinstance(err.value.__cause__.__context__, mod.CleanupError)
def test_chdir_failure_does_not_fail_the_wave(tmp_path: Path, monkeypatch) -> None:
"""A lost cwd is suppressed: further waves may misbehave and fall to
the serial pass, whose cwd is pinned."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
monkeypatch.setattr(mod.os, "chdir", MagicMock(side_effect=OSError("gone")))
mod.parallel_install(cls, ["esphome/good @ 1.0"])
assert cls.calls == ["esphome/good @ 1.0"]
def test_piopm_match_removes_manifest_named_torn_dir(tmp_path: Path, capsys) -> None:
"""A torn dir named by its manifest (not the registry spec) is found
through its .piopm and removed."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
torn = tmp_path / "packages" / "ManifestName"
torn.mkdir(parents=True)
(torn / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}')
innocent = tmp_path / "packages" / "innocent"
innocent.mkdir()
(innocent / ".piopm").write_text('{"spec": {"owner": "o", "name": "other"}}')
with patch.object(mod.fs, "rmtree", shutil.rmtree):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert not torn.exists()
assert innocent.exists() # another package's valid metadata survives
assert "Removed torn destination" in capsys.readouterr().out
def test_unscannable_package_dir_fails_the_build(tmp_path: Path) -> None:
"""A storage dir the cleanup cannot scan is not proof of cleanliness."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
real_iterdir = Path.iterdir
def broken_iterdir(self):
if self.name == "packages":
raise PermissionError("denied")
return real_iterdir(self)
with (
patch.object(Path, "iterdir", broken_iterdir),
pytest.raises(mod.CleanupError, match="cleanup failed"),
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
def test_stray_file_in_package_dir_is_ignored(tmp_path: Path) -> None:
"""A plain file (or a pio-link) beside the packages is skipped by
pio's own scan and must never hard-fail the build."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
(tmp_path / "packages").mkdir(parents=True)
(tmp_path / "packages" / "stray.pio-link").write_text("x")
(tmp_path / "packages" / "no-metadata").mkdir() # pio overwrites these
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert (tmp_path / "packages" / "stray.pio-link").exists()
assert (tmp_path / "packages" / "no-metadata").exists()
def test_unreadable_piopm_dir_is_removed(tmp_path: Path) -> None:
"""A persistently corrupt .piopm under this spec's own name would
crash pio's storage scan; the dir is removed rather than left to
break the serial pass."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
torn = tmp_path / "packages" / "bad"
torn.mkdir(parents=True)
(torn / ".piopm").write_text("{not json")
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert not torn.exists()
def test_unreadable_piopm_under_other_name_survives(tmp_path: Path) -> None:
"""A corrupt .piopm in another package's dir may be a worker mid-copy;
a failing spec must not remove a directory it does not own."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
other = tmp_path / "packages" / "innocent"
other.mkdir(parents=True)
(other / ".piopm").write_text("{not json")
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
assert other.exists()
def test_unexpected_cleanup_class_becomes_cleanup_error(tmp_path: Path) -> None:
"""Cleanup failures of any class fail the build; nothing may be
downgraded to the serial fallback over a torn directory."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"})
with (
patch.object(
mod, "piopm_matches", MagicMock(side_effect=ValueError("bad spec"))
),
pytest.raises(mod.CleanupError, match="cleanup failed"),
):
mod.parallel_install(cls, ["esphome/bad @ 1.0"])
def test_main_cleanup_error_fails_before_generic_fallback(tmp_path: Path) -> None:
"""A CleanupError must escape main's serial fallback: the clause order
decides whether a stuck torn package fails the image build."""
mod = _load_script()
ini = tmp_path / "platformio.ini"
ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n")
with (
patch.object(
mod, "parallel_install", side_effect=mod.CleanupError("stuck torn pkg")
),
patch.object(mod.subprocess, "check_call"),
patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]),
pytest.raises(mod.CleanupError),
):
mod.main()
def test_main_generic_failure_still_runs_serial_pass(tmp_path: Path) -> None:
"""A non-CleanupError wave failure prints, dumps the traceback, and
still reaches the authoritative serial pass with the pinned cwd."""
mod = _load_script()
ini = tmp_path / "platformio.ini"
ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n")
with (
patch.object(mod, "parallel_install", side_effect=RuntimeError("boom")),
patch.object(mod.subprocess, "check_call") as mock_call,
patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]),
):
mod.main()
mock_call.assert_called_once()
args, kwargs = mock_call.call_args
assert args[0][:4] == ["platformio", "pkg", "install", "-g"]
assert "esphome/x @ 1.0" in args[0]
assert kwargs["cwd"] == Path.cwd()
def test_content_cache_creates_its_dir(tmp_path: Path, monkeypatch) -> None:
"""The cold-cache hardening relies on ContentCache.__init__ creating
the namespace dir; pin the side effect, not mere callability."""
monkeypatch.setenv("PLATFORMIO_CACHE_DIR", str(tmp_path / "cache"))
ContentCache("http")
assert (tmp_path / "cache" / "http").is_dir()
def test_piopm_matches_without_name_matches_nothing(tmp_path: Path) -> None:
"""A spec with no derivable name can never match a directory."""
mod = _load_script()
assert mod.piopm_matches(str(tmp_path), "") == []
def test_unresolvable_spec_stays_out_of_the_wave(tmp_path: Path, capsys) -> None:
"""A spec with no derivable name is left to the serial pass; a raw
string key would break the one-per-destination dedupe."""
mod = _load_script()
cls = _reset_fake(str(tmp_path))
nameless = PackageSpec(requirements="^1.0")
mod.parallel_install(cls, [nameless])
assert cls.calls == []
assert "Skipping unresolvable spec" in capsys.readouterr().out
def test_parallel_install_unlocks_when_pool_fails(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
with (
patch.object(mod, "ThreadPoolExecutor", side_effect=RuntimeError("no")),
pytest.raises(RuntimeError),
):
mod.parallel_install(cls, ["esphome/a @ 1.0"])
assert cls.lock_events == ["lock", "unlock"]
def test_parse_specs_unreadable_ini_fails_loudly(tmp_path: Path) -> None:
"""A bad path must not silently build an image with no dependencies."""
mod = _load_script()
args = Namespace(libraries=True, platforms=False, tools=False)
with pytest.raises(SystemExit):
mod.parse_specs(str(tmp_path / "missing.ini"), args)
def test_platformio_surface_for_install_deps_script() -> None:
"""A PlatformIO bump that changes these members must fail here, not
silently turn the docker image's parallel preinstall into a no-op."""
# The script calls these positionally; pin the positions, not just
# membership, so a parameter reorder trips the wire too
params = inspect.signature(PackageManagerInstallMixin._install).parameters
assert list(params)[1] == "spec"
assert "skip_dependencies" in params
assert "compatibility" in params
for cls in (ToolPackageManager, LibraryPackageManager):
assert list(inspect.signature(cls.__init__).parameters)[1] == "package_dir"
for name in (
"lock",
"unlock",
"get_package",
"memcache_reset",
"get_pkg_dependencies",
"dependency_to_spec",
"get_download_dir",
"get_tmp_dir",
):
assert callable(getattr(BasePackageManager, name))
# Losing any of these turns the wave into main()'s silent serial
# fallback: ensure_spec runs in the coordinator, the spec attributes
# feed the dedupe, cleanup, and dependency filters
assert callable(BasePackageManager.ensure_spec)
spec = PackageSpec("owner/name @ ^1.0")
assert spec.name == "name"
assert spec.owner == "owner"
assert spec.uri is None
assert spec.external is False
assert Version("1.5.0") in spec.requirements
# The failure-cleanup path degrades to a single line if these vanish
assert callable(fs.rmtree)
assert callable(fs.load_json)
# piopm_matches only tolerates a corrupt .piopm through this base;
# losing it would flip a wave failure from degrade to build failure
assert issubclass(InvalidJSONFile, ValueError)
assert PackageItem("pkg-dir").path == "pkg-dir"
assert callable(PackageCompatibility.from_dependency)
-28
View File
@@ -3,7 +3,6 @@
from collections.abc import Callable
import os
from pathlib import Path
import types
from typing import Any
from unittest.mock import MagicMock, Mock, patch
@@ -705,33 +704,6 @@ def test_include_file_with_c_header(
assert '#include "c_library.h"' in mock_raw_statement.text
def test_get_usable_cpu_count() -> None:
"""Test get_usable_cpu_count returns CPU count."""
count = config.get_usable_cpu_count()
assert isinstance(count, int)
assert count > 0
def test_get_usable_cpu_count_with_process_cpu_count() -> None:
"""Test get_usable_cpu_count uses process_cpu_count when available."""
# Test with process_cpu_count (Python 3.13+)
# Create a mock os module with process_cpu_count
mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4)
with patch("esphome.core.config.os", mock_os):
# When process_cpu_count exists, it should be used
count = config.get_usable_cpu_count()
assert count == 8
# Test fallback to cpu_count when process_cpu_count not available
mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4)
with patch("esphome.core.config.os", mock_os_no_process):
count = config.get_usable_cpu_count()
assert count == 4
def test_list_target_platforms(tmp_path: Path) -> None:
"""Test _list_target_platforms returns available platforms."""
# Create mock components directory structure
@@ -2565,6 +2565,52 @@ def test_returning_lambda_no_return() -> None:
cv.returning_lambda(Lambda("int x = 5;"))
def test_returning_lambda_return_only_in_comment() -> None:
with pytest.raises(Invalid, match="return statement"):
cv.returning_lambda(Lambda("// return 5;\nint x = 5;"))
def test_returning_lambda_missing_semicolon_is_accepted() -> None:
"""A forgotten semicolon is left for the C++ compiler to report."""
assert isinstance(cv.returning_lambda(Lambda("return x")), Lambda)
@pytest.mark.parametrize(
("value", "expected"),
[
("return 5;", True),
("if (x) { return x; } return 0;", True),
("if (x) return 1; else return 0;", True),
("switch (x) { case 0: return 1; }", True),
# a semicolon means code: any return keyword counts
("return not x;", True),
("return a and b;", True),
("please return the sensor; then wait", True),
# a forgotten semicolon is still lambda source; the compiler reports it
("return id(x).state", True),
("return x", True),
("return 5", True),
("return not x", True),
# accepted: a one-word tail is indistinguishable from 'return x'
("return soon", True),
("Alert: return home", True),
("static value", False),
("no returns here", False),
("the_return_value", False),
# without a semicolon, prose is not lambda source
("please return the item", False),
("return to sender", False),
("return a and b", False),
# return only inside a comment is not a return statement
("// return 5;\nint x = 5;", False),
("/* return 5; */ int x = 5;", False),
("return 5; // done", True),
],
)
def test_looks_like_returning_lambda(value: str, expected: bool) -> None:
assert cv.looks_like_returning_lambda(value) is expected
# ---------------------------------------------------------------------------
# dimensions
# ---------------------------------------------------------------------------
+10 -10
View File
@@ -912,7 +912,7 @@ def test_prefetch_leaves_unverifiable_entries_to_the_installer(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
@@ -935,7 +935,7 @@ def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
@@ -953,7 +953,7 @@ def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
@@ -968,7 +968,7 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
@@ -1012,7 +1012,7 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
@@ -1033,7 +1033,7 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
@@ -1066,7 +1066,7 @@ def test_prefetch_failures_never_raise(
with (
patch("esphome.espidf.framework.run_command", return_value=run_result),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=download_error,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1088,7 +1088,7 @@ def test_prefetch_total_failure_logs_error(
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=OSError("proxy refuses everything"),
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1113,7 +1113,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=_fail_cmake_download,
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1134,7 +1134,7 @@ def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> Non
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume"),
patch("esphome.framework_helpers.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
@@ -2280,6 +2280,32 @@ class TestGetProjectCxxCompileFlags:
assert get_project_cxx_compile_flags() == []
def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
"""The batch runner passes the tracker positionally; the shared adapter
must deliver it as download_with_resume's progress keyword."""
from esphome.framework_helpers import resume_fetch_job
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
fetch = resume_fetch_job("https://x/a.zip", tmp_path / "a", sha256="ff", size=9)
tracker = lambda done: None # noqa: E731
fetch(tracker)
mock_download.assert_called_once_with(
"https://x/a.zip", tmp_path / "a", progress=tracker, sha256="ff", size=9
)
def test_warn_prefetch_failures_names_each_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The shared failure loop warns per job with the failure reason."""
from esphome.framework_helpers import warn_prefetch_failures
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
assert "Could not prefetch toolchain-x@1: down" in caplog.text
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
assert "Prefetch of lib failed: gone" in caplog.text
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
@@ -2312,3 +2338,18 @@ def test_strip_win_long_path_prefix(
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.framework_helpers.sys.platform", platform):
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
def test_discard_partial_download_logs_undeletable(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unremovable staging file leaves a debug trace; the caller's
cache is never pruned, so silence would hide unbounded growth."""
dest = tmp_path / "archive"
dest.write_bytes(b"stale")
with (
patch.object(Path, "unlink", side_effect=OSError("busy")),
caplog.at_level(logging.DEBUG),
):
framework_helpers.discard_partial_download(dest)
assert "Could not remove" in caplog.text
+24
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import socket
import stat
import types
from unittest.mock import MagicMock, patch
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
@@ -1154,3 +1155,26 @@ def test_progressbar_interrupt_keeps_finished_bar_done(monkeypatch) -> None:
def test_format_duration(seconds: float, expected: str) -> None:
"""Test that durations are rendered as short human-readable strings."""
assert helpers.format_duration(seconds) == expected
def test_get_usable_cpu_count() -> None:
"""Returns a positive int on the real host."""
count = helpers.get_usable_cpu_count()
assert isinstance(count, int)
assert count > 0
def test_get_usable_cpu_count_sources() -> None:
"""Prefers process_cpu_count, falls back to cpu_count, degrades to 1."""
mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4)
with patch("esphome.helpers.os", mock_os):
assert helpers.get_usable_cpu_count() == 8
mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4)
with patch("esphome.helpers.os", mock_os_no_process):
assert helpers.get_usable_cpu_count() == 4
# An undeterminable count degrades to one worker, never zero
mock_os_unknown = types.SimpleNamespace(cpu_count=lambda: None)
with patch("esphome.helpers.os", mock_os_unknown):
assert helpers.get_usable_cpu_count() == 1
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -932,8 +932,13 @@ 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
toolchain.run_compile(config, verbose=True)
with patch(
"esphome.platformio.prefetch.prefetch_platformio_packages"
) as mock_prefetch:
toolchain.run_compile(config, verbose=True)
# The only wiring of the prefetch into a build lives here
mock_prefetch.assert_called_once_with()
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
@@ -947,7 +952,8 @@ def test_run_compile_without_process_limit(
config = {CONF_ESPHOME: {}}
mock_run_platformio_cli_run.return_value = 0
toolchain.run_compile(config, verbose=False)
with patch("esphome.platformio.prefetch.prefetch_platformio_packages"):
toolchain.run_compile(config, verbose=False)
mock_run_platformio_cli_run.assert_called_once_with(config, False)
@@ -1677,8 +1683,8 @@ def pio_core_dir(tmp_path: Path) -> Path:
def test_current_python_minor_matches_running_interpreter() -> None:
"""_current_python_minor returns major.minor of the running interpreter."""
assert toolchain._current_python_minor() == _CURRENT_MINOR
"""current_python_minor returns major.minor of the running interpreter."""
assert toolchain.current_python_minor() == _CURRENT_MINOR
def test_pio_stamp_round_trip(tmp_path: Path) -> None: