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

This commit is contained in:
J. Nick Koston
2026-09-28 11:36:03 +02:00
262 changed files with 8670 additions and 3593 deletions
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components.light import generate_gamma_table
from esphome.components.light import gamma_table_initializer
from tests.testing_helpers import ComponentManifestOverride
@@ -12,15 +12,13 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
await original_to_code(config)
cg.add_define("USE_LIGHT_GAMMA_LUT")
# Use the light component's own generate_gamma_table() so the
# Use the light component's own gamma_table_initializer() so the
# benchmark stays in sync with any formula changes.
forward = generate_gamma_table(2.8)
values = ", ".join(f"0x{int(v):04X}" for v in forward)
# Use extern-visible (non-static) array so the benchmark .cpp
# can reference it via extern declaration.
# Extern-visible (non-static) so the benchmark .cpp can reference it.
cg.add_global(
cg.RawStatement(
f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};"
"extern const esphome::light::GammaTable bench_gamma_2_8 PROGMEM = "
f"{gamma_table_initializer(2.8)};"
)
)
@@ -4,8 +4,8 @@
#include "esphome/components/light/light_state.h"
// Gamma 2.8 forward LUT generated by the light component's Python codegen
// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table())
extern const uint16_t bench_gamma_2_8_fwd[256];
// (see tests/benchmarks/components/light/__init__.py which calls gamma_table_initializer())
extern const esphome::light::GammaTable bench_gamma_2_8;
namespace esphome::benchmarks {
@@ -37,8 +37,7 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) {
output.traits_.set_max_mireds(500.0f);
light.configure("test_light");
light.set_default_transition_length(0);
light.set_gamma_correct(2.8f);
light.set_gamma_table(bench_gamma_2_8_fwd);
light.set_gamma_table(&bench_gamma_2_8);
}
// --- LightCall::perform() with instant RGB color change (Home Assistant API path) ---
@@ -0,0 +1,17 @@
esphome:
name: test
on_boot:
then:
- counter.set_value: 5
- counter.set_value:
value: 7
- counter.increment:
value: -3
esp32:
board: esp32dev
sensor:
- platform: counter
id: only_counter
name: Only Counter
@@ -0,0 +1,42 @@
esphome:
name: test
on_boot:
then:
- counter.set_value:
id: counter_a
value: 100
- counter.set_value:
id: counter_b
value: -5000000000
- counter.increment: counter_a
- counter.increment:
id: counter_a
value: -7
- counter.increment:
id: counter_b
value: !lambda "return 3;"
esp32:
board: esp32dev
sensor:
- platform: template
id: source_sensor
lambda: return 1.0;
- platform: counter
id: counter_a
name: Counter A
sensor: source_sensor
- platform: counter
id: counter_b
name: Counter B
restore: false
initial_value: -5000000000
- platform: counter
id: counter_c
name: Counter C
binary_sensor: source_binary_sensor
binary_sensor:
- platform: template
id: source_binary_sensor
@@ -0,0 +1,99 @@
"""Tests for the counter sensor."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE
INT64_MAX = 2**63 - 1
def test_counter_constructor_arguments(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Restore (on by default) and initial value (zero by default) are constructor arguments."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp
assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp
def test_counter_sensor_option_registers_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only the counter with a sensor option counts that sensor's updates."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "counter_a->count_updates_from(source_sensor);" in main_cpp
assert main_cpp.count("count_updates_from") == 1
def test_counter_binary_sensor_option_registers_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only the counter with a binary_sensor option counts that sensor's changes to true."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp
assert main_cpp.count("count_true_from") == 1
def test_counter_actions(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Values reach the counter as 64-bit integers; increment defaults to one."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "::counter_a->set_value(100);" in main_cpp
assert "::counter_b->set_value(-5000000000LL);" in main_cpp
assert "::counter_a->increment(1);" in main_cpp
assert "::counter_a->increment(-7);" in main_cpp
assert "::counter_b->increment(static_cast<int64_t>(3));" in main_cpp
def test_counter_actions_without_id_use_only_counter(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With one counter configured, the id may be left out of an action."""
main_cpp = generate_main(component_config_path("counter_single.yaml"))
assert "::only_counter->set_value(5);" in main_cpp
assert "::only_counter->set_value(7);" in main_cpp
assert "::only_counter->increment(-3);" in main_cpp
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
def test_counter_initial_value_must_be_int64(value: float) -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value})
def test_counter_cannot_count_itself() -> None:
"""A counter watching its own updates would recurse forever."""
with pytest.raises(cv.Invalid, match="cannot count its own updates"):
CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"})
def test_counter_accepts_other_source() -> None:
config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"})
assert config["sensor"].id == "other"
@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX])
def test_counter_value_accepts_int64_range(value: int) -> None:
assert COUNTER_VALUE(value) == value
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
def test_counter_value_rejects_out_of_range(value: float) -> None:
with pytest.raises(cv.Invalid):
COUNTER_VALUE(value)
@@ -0,0 +1,37 @@
"""Limits and flash layout that let LightState stay small."""
import pytest
from esphome import config_validation as cv
from esphome.components.light.effects import (
MAX_EFFECTS,
MONOCHROMATIC_EFFECTS,
validate_effects,
)
def _effects(count: int) -> list[dict[str, dict[str, str]]]:
return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)]
def test_rejects_more_effects_than_the_index_holds() -> None:
with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"):
validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1))
def test_accepts_a_normal_effect_list() -> None:
assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3
def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None:
from esphome.components.light import gamma_table_initializer, generate_gamma_table
init = gamma_table_initializer(2.8)
lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8))
assert init == f"{{{{{lut}}}, 280}}"
def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None:
from esphome.components.light import gamma_table_initializer
assert gamma_table_initializer(1000.0).endswith(", 65535}")
@@ -0,0 +1,59 @@
"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option
the configuration does not use, so ``lv_conf.h`` must leave alone the options
the user defines in build flags. Otherwise the header, which is included after
the compiler ``-D`` flags, turns the option off again.
Build flags can come from ``esphome: build_flags:`` or from the deprecated
``esphome: platformio_options: build_flags:``; both must be honoured.
"""
from __future__ import annotations
import logging
import pytest
from esphome.components.lvgl import defines as df, generate_lv_conf_h
from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS
from esphome.core import CORE
def _set_esphome_config(
build_flags: list[str] | None = None,
pio_build_flags: list[str] | str | None = None,
) -> None:
pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags}
CORE.config = {
CONF_ESPHOME: {
CONF_PLATFORMIO_OPTIONS: pio_options,
CONF_BUILD_FLAGS: build_flags or [],
}
}
def test_unused_define_is_disabled_without_build_flag() -> None:
_set_esphome_config()
assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines()
@pytest.mark.parametrize(
"flags",
[
{"build_flags": ["-DLV_USE_OBSERVER=1"]},
{"build_flags": ["-D LV_USE_OBSERVER"]},
{"pio_build_flags": ["-DLV_USE_OBSERVER=1"]},
{"pio_build_flags": "-DLV_USE_OBSERVER=1"},
],
ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"],
)
def test_build_flag_define_is_not_disabled(flags: dict) -> None:
_set_esphome_config(**flags)
assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines()
def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None:
_set_esphome_config(build_flags=["-DLV_USE_ARC=1"])
df.add_define("LV_USE_ARC")
with caplog.at_level(logging.WARNING):
generate_lv_conf_h()
assert "LV_USE_ARC" in caplog.text
@@ -0,0 +1,8 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
zephyr_ble_server:
@@ -0,0 +1,11 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
ble_nus:
type: logs
@@ -0,0 +1,18 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
zephyr_ble_server:
ble_nus:
type: logs
ota:
- platform: zephyr_mcumgr
transport:
ble: true
@@ -0,0 +1,52 @@
"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_PRJ_CONF
from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu
MTU_KEYS = (
"CONFIG_BT_L2CAP_TX_MTU",
"CONFIG_BT_BUF_ACL_TX_SIZE",
"CONFIG_BT_BUF_ACL_RX_SIZE",
)
def _mtu_values() -> tuple[int | None, ...]:
prj_conf = zephyr_data()[KEY_PRJ_CONF][""]
return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS)
@pytest.mark.parametrize(
("fixture", "expected"),
[
("no_request.yaml", (None, None, None)),
("nus.yaml", (247, 251, 251)),
# The largest request wins; only the TX buffer is capped at 251
("nus_mcumgr.yaml", (498, 251, 502)),
],
)
def test_l2cap_mtu_prj_conf(
fixture: str,
expected: tuple[int | None, ...],
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
assert _mtu_values() == expected
@pytest.mark.asyncio
async def test_request_after_emit_raises(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("no_request.yaml"))
request_ble_l2cap_mtu(247)
await _emit_ble_mtu()
with pytest.raises(RuntimeError, match="after it was written"):
request_ble_l2cap_mtu(498)
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <gtest/gtest.h>
#include <cstdint>
#include <limits>
#include <utility>
#include "esphome/core/helpers.h"
#include "esphome/components/counter/counter_sensor.h"
namespace esphome::counter::testing {
constexpr int64_t INT64_MAX_VALUE = std::numeric_limits<int64_t>::max();
constexpr int64_t INT64_MIN_VALUE = std::numeric_limits<int64_t>::min();
/// Stands in for a binary sensor: reports each state to its callbacks.
struct FakeBinarySource {
template<typename F> void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward<F>(callback)); }
void publish(bool state) { this->callbacks_.call(state); }
CallbackManager<void(bool)> callbacks_;
};
// Restore is off so no preference storage is needed.
class CounterTest : public ::testing::Test {
protected:
CounterSensor counter_{false, 0};
};
} // namespace esphome::counter::testing
+37
View File
@@ -0,0 +1,37 @@
esphome:
on_boot:
then:
- counter.set_value:
id: counter_sensor
value: 100
- counter.increment: counter_sensor
- counter.increment:
id: counter_sensor
value: -5
- counter.increment:
id: counter_sensor
value: !lambda "return 5000000000LL;"
sensor:
- platform: counter
id: counter_sensor
name: Counter
- platform: counter
id: counter_no_restore
name: Counter No Restore
restore: false
initial_value: 10
- platform: template
id: source_sensor
lambda: return 1.0;
update_interval: 1s
- platform: counter
name: Counter Of Sensor
sensor: source_sensor
- platform: counter
name: Counter Of Binary Sensor
binary_sensor: source_binary_sensor
binary_sensor:
- platform: template
id: source_binary_sensor
@@ -0,0 +1,84 @@
#include "../common.h"
namespace esphome::counter::testing {
TEST_F(CounterTest, StartsAtZero) {
this->counter_.setup();
EXPECT_EQ(this->counter_.state, 0.0f);
}
TEST(CounterInitialValue, PublishedAtSetupWhenNotRestoring) {
CounterSensor counter(false, -12);
counter.setup();
EXPECT_EQ(counter.state, -12.0f);
counter.increment();
EXPECT_EQ(counter.state, -11.0f);
}
TEST_F(CounterTest, SetValuePublishesState) {
this->counter_.set_value(42);
EXPECT_EQ(this->counter_.state, 42.0f);
}
TEST_F(CounterTest, IncrementDefaultsToOne) {
this->counter_.increment();
this->counter_.increment();
EXPECT_EQ(this->counter_.state, 2.0f);
}
TEST_F(CounterTest, IncrementAcceptsNegativeAmounts) {
this->counter_.set_value(10);
this->counter_.increment(-25);
EXPECT_EQ(this->counter_.state, -15.0f);
}
TEST_F(CounterTest, ValueBeyondInt32) {
this->counter_.set_value(5000000000LL);
this->counter_.increment(5000000000LL);
EXPECT_EQ(this->counter_.state, 1.0e10f);
}
TEST_F(CounterTest, IncrementWrapsAtInt64Limits) {
this->counter_.set_value(INT64_MAX_VALUE);
this->counter_.increment(1);
EXPECT_EQ(this->counter_.state, static_cast<float>(INT64_MIN_VALUE));
this->counter_.set_value(INT64_MIN_VALUE);
this->counter_.increment(-1);
EXPECT_EQ(this->counter_.state, static_cast<float>(INT64_MAX_VALUE));
}
TEST_F(CounterTest, CountsEachPublishFromSource) {
sensor::Sensor source;
this->counter_.count_updates_from(&source);
// The counted value is unrelated to what the source publishes.
source.publish_state(10.0f);
source.publish_state(10.0f);
source.publish_state(-3.5f);
EXPECT_EQ(this->counter_.state, 3.0f);
}
TEST_F(CounterTest, SourceUpdatesAddToCurrentValue) {
sensor::Sensor source;
this->counter_.count_updates_from(&source);
this->counter_.set_value(100);
source.publish_state(1.0f);
EXPECT_EQ(this->counter_.state, 101.0f);
}
TEST_F(CounterTest, CountsOnlyTrueFromBinarySource) {
FakeBinarySource source;
this->counter_.count_true_from(&source);
source.publish(true);
source.publish(false);
EXPECT_EQ(this->counter_.state, 1.0f);
source.publish(true);
source.publish(false);
source.publish(false);
EXPECT_EQ(this->counter_.state, 2.0f);
}
} // namespace esphome::counter::testing
@@ -0,0 +1,2 @@
packages:
counter: !include common.yaml
@@ -0,0 +1,12 @@
packages:
counter: !include common.yaml
esphome:
on_shutdown:
then:
- counter.set_value:
id: counter_no_restore
value: -9223372036854775807
- counter.increment:
id: counter_no_restore
value: 9223372036854775807
+17 -3
View File
@@ -35,11 +35,18 @@ inline void connect_controller(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
}
// Runs one command poll (write 2 / read 8) and returns both key-press registers.
inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter
// with command 0x03 here; most tests do not care and pass zero.
inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) {
door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
return response;
}
// Runs one command poll (write 2 / read 8) and returns both key-press registers.
inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
const RegisterValues response = status_answer(door);
EXPECT_EQ(response.size(), 8u);
if (response.size() != 8u)
return {0xFFFF, 0xFFFF};
@@ -59,7 +66,14 @@ class TestableHoermannHcp : public HoermannHcp {
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
#ifdef USE_HOERMANN_HCP_IDENTITY
using HoermannHcp::identity_asked_at_;
using HoermannHcp::identity_request_;
using HoermannHcp::firmware_unreadable_;
using HoermannHcp::serial_unreadable_;
#endif
using HoermannHcp::is_light_toggle_pending_;
using HoermannHcp::key_press_delay_ms_;
using HoermannHcp::light_toggle_released_at_;
using HoermannHcp::light_toggles_in_flight_;
using HoermannHcp::set_valid_;
@@ -22,3 +22,12 @@ button:
light:
- platform: hoermann_hcp
name: Garage Light
text_sensor:
- platform: hoermann_hcp
door_state:
name: Garage Door State
serial_number:
name: Garage Motor Serial Number
version:
name: Garage Motor Firmware Version
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The platform's own to_code needs a configured hub; only its define is wanted here.
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_HOERMANN_HCP_IDENTITY")
manifest.to_code = to_code_testing
@@ -0,0 +1,125 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
namespace {
// A status broadcast with the door state in the high byte of its third register.
void broadcast_state(HoermannHcp &door, uint16_t state_reg) {
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, state_reg}));
door.update();
}
struct DoorStateFixture {
DoorStateFixture() {
this->sensor.setup();
this->sensor.add_on_state_callback([this](const std::string & /*state*/) { this->publishes++; });
}
TestableHoermannHcp door;
HoermannHcpDoorStateTextSensor sensor{&door};
int publishes{0};
};
} // namespace
// Polls and bus scans make the connection valid before any broadcast has said where the door is, and an
// undecodable state says nothing either. None of these may show the default state.
TEST(HoermannHcpDoorStateTest, NothingBeforeTheDoorReportsAState) {
DoorStateFixture fixture;
auto &door = fixture.door;
door.update();
EXPECT_FALSE(fixture.sensor.has_state());
status_answer(door, 0x0003);
door.update();
ASSERT_TRUE(door.is_valid());
EXPECT_FALSE(fixture.sensor.has_state());
RegisterValues scan;
door.on_read_holding_registers(STATE_REG, 5, scan);
door.update();
EXPECT_FALSE(fixture.sensor.has_state());
broadcast_state(door, 0x1000);
EXPECT_FALSE(fixture.sensor.has_state());
// The first real state is shown even when it equals the default.
broadcast_state(door, 0x4000);
EXPECT_EQ(fixture.sensor.get_state(), "Closed");
}
// Every state the door reports is shown, including the vent and half-open positions and the moves to them.
TEST(HoermannHcpDoorStateTest, FollowsTheDoorState) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
const std::pair<uint16_t, const char *> states[] = {
{0x2000, "Open"}, {0x0200, "Closing"}, {0x4000, "Closed"}, {0x0900, "Moving to vent"},
{0x0A00, "Vent position"}, {0x0500, "Moving to half"}, {0x8000, "Half open"}, {0x0100, "Opening"},
{0x0000, "Stopped"}, {0x0061, "Vent position"},
};
for (const auto &[reg, text] : states) {
broadcast_state(door, reg);
EXPECT_EQ(fixture.sensor.get_state(), text) << "state register 0x" << std::hex << reg;
}
}
// Any hub change runs the publish path, so an unchanged door state is not published again, and a state the
// door is not known to report keeps the last one.
TEST(HoermannHcpDoorStateTest, EachStateIsPublishedOnce) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
broadcast_state(door, 0x2000);
ASSERT_EQ(fixture.publishes, 1);
// The lamp changes, the door state does not.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x2000, 0x0000, 0x0000, 0x0000, 0x0010}));
door.update();
EXPECT_EQ(fixture.publishes, 1);
broadcast_state(door, 0x1000);
EXPECT_EQ(fixture.publishes, 1);
EXPECT_EQ(fixture.sensor.get_state(), "Open");
}
// While the bus controller is gone the last state stays. Once it is back, a poll alone shows nothing new; the
// next broadcast is published again even if it repeats the old state.
TEST(HoermannHcpDoorStateTest, LastStateStaysUntilTheNextBroadcast) {
DoorStateFixture fixture;
auto &door = fixture.door;
connect_controller(door);
broadcast_state(door, 0x8000);
ASSERT_EQ(fixture.publishes, 1);
door.set_valid_(false);
door.update();
EXPECT_EQ(fixture.sensor.get_state(), "Half open");
connect_controller(door);
door.update();
EXPECT_EQ(fixture.publishes, 1);
broadcast_state(door, 0x8000);
EXPECT_EQ(fixture.publishes, 2);
EXPECT_EQ(fixture.sensor.get_state(), "Half open");
}
// A sensor set up after the hub already decoded a state shows it right away.
TEST(HoermannHcpDoorStateTest, LateSetupShowsTheCurrentState) {
TestableHoermannHcp door;
connect_controller(door);
broadcast_state(door, 0x0A00);
HoermannHcpDoorStateTextSensor sensor(&door);
sensor.setup();
EXPECT_EQ(sensor.get_state(), "Vent position");
}
} // namespace esphome::hoermann_hcp::testing
@@ -0,0 +1,510 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <string>
#include <thread>
#include "esphome/components/text_sensor/text_sensor.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
namespace {
// Made up. 26 bytes on the wire: the first 14 arrive in one transfer, the other 12 in the next.
constexpr const char *SERIAL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// Made up as well, padded with a space to the 12 bytes the motor sends.
constexpr const char *FIRMWARE = "FW-TEST 1.0 ";
constexpr uint8_t SUB_SERIAL = 0x0C;
constexpr uint8_t SUB_FIRMWARE = 0x0D;
constexpr uint8_t FIRST_HALF = 0x80;
// A status poll as the bus controller writes it: its counter in the high byte, command 0x03 in the low.
RegisterValues status_poll(HoermannHcp &door, uint8_t counter = 0x05) {
return status_answer(door, static_cast<uint16_t>((counter << 8) | 0x03));
}
// The write half of a payload transfer: the motor writes the bytes into the command block.
void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len) {
RegisterValues written;
written.push_back(static_cast<uint16_t>((counter << 8) | 0x04));
written.push_back(static_cast<uint16_t>(sub_code << 8));
for (size_t i = 0; i < len; i += 2) {
written.push_back(
static_cast<uint16_t>((static_cast<uint8_t>(bytes[i]) << 8) | static_cast<uint8_t>(bytes[i + 1])));
}
door.on_write_registers(COMMAND_REG, written);
}
// A whole payload transfer, returning the answer the motor reads back.
RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len,
uint16_t read_registers = 8) {
write_transfer(door, counter, sub_code, bytes, len);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, read_registers, response);
return response;
}
// The first status poll gets an ordinary answer, the next one carries the serial number request.
void request_serial(HoermannHcp &door) {
status_poll(door, 0x03);
status_poll(door, 0x04);
}
void send_serial(HoermannHcp &door) {
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12);
}
// A hub with both sensors configured, which is what makes it ask.
struct IdentityFixture {
IdentityFixture() {
this->door.set_serial_number_text_sensor(&this->serial);
this->door.set_version_text_sensor(&this->version);
}
// What the sensors show once the loop has turned; empty while they have no state.
std::string serial_shown() {
this->door.update();
return this->serial.has_state() ? this->serial.get_state() : "";
}
std::string version_shown() {
this->door.update();
return this->version.has_state() ? this->version.get_state() : "";
}
TestableHoermannHcp door;
text_sensor::TextSensor serial;
text_sensor::TextSensor version;
};
// The whole exchange as the motor runs it, with the loop turning in between as it would.
void run_identity_exchange(HoermannHcp &door) {
request_serial(door);
send_serial(door);
door.update();
status_poll(door, 0x07);
transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12);
door.update();
}
} // namespace
// With no sensor configured, polls and transfers are answered exactly as before.
TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) {
HoermannHcp door;
for (int poll = 0; poll < 2; poll++) {
const RegisterValues response = status_poll(door);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[1], 0x0301);
EXPECT_EQ(response[2], 0x0000);
}
const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
EXPECT_EQ(answer[1] & 0x00FF, 0x0001);
}
// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the
// request, echoing the status command like any status answer. It is asked again only once 30 s have passed.
TEST(HoermannHcpTextSensorTest, SerialNumberIsAskedForAfterOneOrdinaryAnswer) {
IdentityFixture fixture;
auto &door = fixture.door;
EXPECT_EQ(status_poll(door)[1], 0x0301);
const RegisterValues response = status_poll(door);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[0], 0x0500);
EXPECT_EQ(response[1], 0x0322);
EXPECT_EQ(response[2], 0x0500);
door.identity_asked_at_ -= 29000;
EXPECT_EQ(status_poll(door)[1], 0x0301);
door.identity_asked_at_ -= 2000;
EXPECT_EQ(status_poll(door)[1], 0x0322);
}
// Three attempts at the serial number, then the firmware version is asked for anyway, three times as well.
TEST(HoermannHcpTextSensorTest, GivesUpAfterThreeAttemptsEach) {
IdentityFixture fixture;
auto &door = fixture.door;
status_poll(door);
for (int attempt = 0; attempt < 3; attempt++) {
const RegisterValues response = status_poll(door);
EXPECT_EQ(response[1], 0x0322);
EXPECT_EQ(response[2], 0x0500);
door.identity_asked_at_ -= 31000;
}
EXPECT_EQ(status_poll(door)[1], 0x0301);
for (int attempt = 0; attempt < 3; attempt++) {
const RegisterValues response = status_poll(door);
EXPECT_EQ(response[1], 0x0322);
EXPECT_EQ(response[2], 0x0600);
door.identity_asked_at_ -= 31000;
}
EXPECT_EQ(status_poll(door)[1], 0x0301);
EXPECT_EQ(door.identity_request_(), 0);
}
// A first half left behind by a serial number that never completed is not shown.
TEST(HoermannHcpTextSensorTest, HalfASerialNumberIsNeverShown) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
for (int attempt = 0; attempt < 4; attempt++) {
door.identity_asked_at_ -= 31000;
status_poll(door);
}
EXPECT_EQ(fixture.serial_shown(), "");
}
// Each half is acknowledged with the counter it came with, minus the half marker. The serial number is shown as
// soon as it is whole, and the firmware version is asked for right after.
TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
ASSERT_EQ(answer.size(), 8u);
EXPECT_EQ(answer[0], 0x0500);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(fixture.serial_shown(), "");
answer = transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12);
EXPECT_EQ(answer[0], 0x0600);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(fixture.serial_shown(), SERIAL);
EXPECT_EQ(fixture.version_shown(), "");
const RegisterValues response = status_poll(door);
EXPECT_EQ(response[1], 0x0322);
EXPECT_EQ(response[2], 0x0600);
answer = transfer(door, 0x07, SUB_FIRMWARE, FIRMWARE, 12);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0");
}
// Once both values are in, nothing more is asked, however long the device keeps running.
TEST(HoermannHcpTextSensorTest, FinishedExchangeStaysFinished) {
IdentityFixture fixture;
auto &door = fixture.door;
run_identity_exchange(door);
door.identity_asked_at_ -= 31000;
EXPECT_EQ(status_poll(door)[1], 0x0301);
EXPECT_EQ(door.identity_request_(), 0);
}
// The text ends at the first byte that is not printable. 0xFF is below the printable range where char is signed,
// as on the host, and above it where char is unsigned, as on most targets. DEL is above it either way.
TEST(HoermannHcpTextSensorTest, PaddingEndsTheText) {
for (const char pad : {'\xFF', '\x7F'}) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
const char first[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', pad};
const char second[] = {pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad};
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, first, sizeof(first));
transfer(door, 0x06, SUB_SERIAL, second, sizeof(second));
EXPECT_EQ(fixture.serial_shown(), "1234567890123");
}
}
// A half that cannot be used is still acknowledged but not kept, so the request stays open for the retry: a
// first half too short, a second half too short, a second half without a first.
TEST(HoermannHcpTextSensorTest, UnusableSerialHalvesAreNotKept) {
{
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
EXPECT_EQ(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12)[1], 0x04FD);
transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_EQ(door.identity_request_(), 0x05);
}
{
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 10);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_EQ(door.identity_request_(), 0x05);
}
}
// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. The bytes are
// the ones a B1 sent, with the serial number made up.
TEST(HoermannHcpTextSensorTest, SerialNumberInOneFrameThenTheFirmwareVersion) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0};
EXPECT_EQ(transfer(door, 0x05, SUB_SERIAL, one_frame, 12)[1], 0x04FD);
EXPECT_EQ(door.identity_request_(), 0x06);
EXPECT_EQ(fixture.serial_shown(), "123456789B1");
auto answer = status_poll(door, 0x06);
EXPECT_EQ(answer[1], 0x0322);
EXPECT_EQ(answer[2], 0x0600);
}
// The frames as a B1 motor sends them on the bus, which reads a transfer answer back as 2 registers. The serial
// number is made up.
TEST(HoermannHcpTextSensorTest, ExchangeWithTheFrameSizesOfAB1) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0};
EXPECT_THAT(transfer(door, 0x05, SUB_SERIAL, one_frame, 12, 2), ::testing::ElementsAre(0x0500, 0x04FD));
EXPECT_THAT(status_poll(door, 0x06), ::testing::ElementsAre(0x0600, 0x0322, 0x0600, 0, 0, 0, 0, 0));
const char zeros[12] = {};
EXPECT_THAT(transfer(door, 0x07, SUB_FIRMWARE, zeros, 12, 2), ::testing::ElementsAre(0x0700, 0x04FD));
EXPECT_EQ(door.identity_request_(), 0);
EXPECT_EQ(fixture.serial_shown(), "123456789B1");
EXPECT_EQ(fixture.version_shown(), "");
EXPECT_EQ(status_poll(door, 0x08)[1], 0x0301);
}
// A transfer of the value not asked for is acknowledged but not kept, and the request stays open.
TEST(HoermannHcpTextSensorTest, TheValueNotAskedForIsNotKept) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
EXPECT_EQ(transfer(door, 0x05, SUB_FIRMWARE, FIRMWARE, 12)[1], 0x04FD);
EXPECT_EQ(door.identity_request_(), 0x05);
EXPECT_EQ(fixture.version_shown(), "");
send_serial(door);
EXPECT_EQ(fixture.serial_shown(), SERIAL);
status_poll(door, 0x07);
const char other[14] = {'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'};
EXPECT_EQ(transfer(door, FIRST_HALF | 0x08, SUB_SERIAL, other, 14)[1], 0x04FD);
EXPECT_EQ(transfer(door, 0x09, SUB_SERIAL, other, 12)[1], 0x04FD);
EXPECT_EQ(door.identity_request_(), 0x06);
EXPECT_EQ(fixture.serial_shown(), SERIAL);
}
// A transfer before the request has gone out, as from a motor still finishing an exchange from before a restart,
// is acknowledged but not kept.
TEST(HoermannHcpTextSensorTest, ATransferBeforeTheRequestIsNotKept) {
IdentityFixture fixture;
auto &door = fixture.door;
status_poll(door);
const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0};
EXPECT_EQ(transfer(door, 0x04, SUB_SERIAL, one_frame, 12)[1], 0x04FD);
EXPECT_EQ(door.identity_request_(), 0x05);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_EQ(status_poll(door, 0x05)[1], 0x0322);
}
// After a frame with the half marker, one without it can only be the second half, even if the first was unusable.
TEST(HoermannHcpTextSensorTest, ASecondHalfIsNeverTakenForTheWholeNumber) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12);
transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_EQ(door.identity_request_(), 0x05);
}
// A serial number without any text at its start is not shown but logged, in one frame or in two halves. The
// firmware version is still asked for.
TEST(HoermannHcpTextSensorTest, SerialNumberThatIsNotTextIsLoggedNotShown) {
const char zeros[14] = {};
{
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
transfer(door, 0x05, SUB_SERIAL, zeros, 12);
EXPECT_TRUE(door.serial_unreadable_); // kept for the log
EXPECT_EQ(door.identity_request_(), 0x06);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_FALSE(door.serial_unreadable_); // logged once
}
{
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, zeros, 14);
transfer(door, 0x06, SUB_SERIAL, zeros, 12);
EXPECT_TRUE(door.serial_unreadable_);
EXPECT_EQ(door.identity_request_(), 0x06);
EXPECT_EQ(fixture.serial_shown(), "");
EXPECT_FALSE(door.serial_unreadable_);
}
}
// A firmware version too short is not kept, and is asked for again.
TEST(HoermannHcpTextSensorTest, ShortFirmwareVersionIsNotKept) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
send_serial(door);
status_poll(door, 0x07);
transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10);
EXPECT_TRUE(door.firmware_unreadable_); // kept for the log
EXPECT_EQ(fixture.version_shown(), "");
EXPECT_FALSE(door.firmware_unreadable_); // logged once
EXPECT_EQ(door.identity_request_(), 0x06);
}
// A firmware version without any payload is logged, not shown.
TEST(HoermannHcpTextSensorTest, EmptyFirmwareVersionIsLoggedNotShown) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
send_serial(door);
status_poll(door, 0x07);
transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 0);
EXPECT_TRUE(door.firmware_unreadable_);
EXPECT_EQ(fixture.version_shown(), "");
EXPECT_FALSE(door.firmware_unreadable_);
EXPECT_EQ(door.identity_request_(), 0x06);
}
// A readable firmware version right after a short one, before the loop has turned, is still shown.
TEST(HoermannHcpTextSensorTest, ReadableFirmwareVersionAfterAShortOneIsShown) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
send_serial(door);
status_poll(door, 0x07);
transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10);
transfer(door, 0x09, SUB_FIRMWARE, FIRMWARE, 12);
EXPECT_FALSE(door.firmware_unreadable_);
EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0");
}
// All zeros is how a motor that does not report its version says so: nothing shown, not asked for again.
TEST(HoermannHcpTextSensorTest, AllZeroFirmwareVersionMeansNoneIsReported) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
send_serial(door);
status_poll(door, 0x07);
const char zeros[12] = {};
transfer(door, 0x08, SUB_FIRMWARE, zeros, 12);
EXPECT_EQ(fixture.version_shown(), "");
EXPECT_FALSE(door.firmware_unreadable_);
EXPECT_EQ(door.identity_request_(), 0);
}
// A firmware version that is not text is not shown, is logged, and is not asked for again: it would come back
// the same.
TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) {
IdentityFixture fixture;
auto &door = fixture.door;
request_serial(door);
send_serial(door);
status_poll(door, 0x07);
const char binary[12] = {0x01, 0x12, 0x34, 0x00, 0, 0, 0, 0, 0, 0, 0, 0};
transfer(door, 0x08, SUB_FIRMWARE, binary, 12);
EXPECT_TRUE(door.firmware_unreadable_);
EXPECT_EQ(fixture.version_shown(), ""); // the raw bytes are not published
EXPECT_FALSE(door.firmware_unreadable_);
EXPECT_EQ(door.identity_request_(), 0);
}
// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a
// status poll instead, it would carry the key press waiting in the slot.
TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) {
IdentityFixture fixture;
auto &door = fixture.door;
run_identity_exchange(door);
connect_controller(door);
door.open_door();
const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(answer[2], 0x0000);
EXPECT_EQ(status_poll(door)[2], 0x0210);
}
// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves
// nothing behind for the next poll.
TEST(HoermannHcpTextSensorTest, AnAnswerBelongsToItsFrame) {
IdentityFixture fixture;
auto &door = fixture.door;
status_poll(door);
write_transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
RegisterValues ignored;
door.on_read_holding_registers(COMMAND_REG, 8, ignored);
EXPECT_EQ(status_poll(door)[1] & 0x00FF, 0x0022);
}
// Only the answer to a status poll carries a request, not the answer to another frame of the same length, and
// other transfers are not this exchange's to answer.
TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) {
IdentityFixture fixture;
auto &door = fixture.door;
status_poll(door);
const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2);
EXPECT_EQ(other[1] & 0x00FF, 0x0001);
EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322);
}
// The request travels in the registers a key press would, so it waits for the press, the hold and the release.
TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) {
IdentityFixture fixture;
auto &door = fixture.door;
door.key_press_delay_ms_ = 100;
connect_controller(door);
status_poll(door);
door.open_door();
EXPECT_EQ(status_poll(door)[2], 0x0210);
const RegisterValues held = status_poll(door);
EXPECT_EQ(held[1], 0x0301);
EXPECT_EQ(held[2], 0x0000);
door.key_press_delay_ms_ = 0;
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
const RegisterValues release = status_poll(door);
EXPECT_EQ(release[1], 0x0301);
EXPECT_EQ(release[2], 0x0110);
EXPECT_EQ(status_poll(door)[1], 0x0322);
}
// Each value is published once it is in, and only once.
TEST(HoermannHcpTextSensorTest, EachValueIsPublishedOnce) {
IdentityFixture fixture;
int serial_publishes = 0;
int version_publishes = 0;
fixture.serial.add_on_state_callback([&serial_publishes](const std::string & /*state*/) { serial_publishes++; });
fixture.version.add_on_state_callback([&version_publishes](const std::string & /*state*/) { version_publishes++; });
fixture.door.update();
EXPECT_EQ(serial_publishes, 0);
EXPECT_EQ(version_publishes, 0);
run_identity_exchange(fixture.door);
EXPECT_EQ(fixture.serial.get_state(), SERIAL);
EXPECT_EQ(fixture.version.get_state(), "FW-TEST 1.0");
fixture.door.update();
fixture.door.update();
EXPECT_EQ(serial_publishes, 1);
EXPECT_EQ(version_publishes, 1);
}
// Configuring only one of the two is enough to ask.
TEST(HoermannHcpTextSensorTest, OneSensorIsEnough) {
TestableHoermannHcp version_only;
text_sensor::TextSensor version;
version_only.set_version_text_sensor(&version);
run_identity_exchange(version_only);
EXPECT_EQ(version.get_state(), "FW-TEST 1.0");
TestableHoermannHcp serial_only;
text_sensor::TextSensor serial;
serial_only.set_serial_number_text_sensor(&serial);
run_identity_exchange(serial_only);
EXPECT_EQ(serial.get_state(), SERIAL);
}
} // namespace esphome::hoermann_hcp::testing
@@ -53,8 +53,8 @@ esphome:
# Regression test for esphome/esphome#16224: a light.turn_on action
# nested inside on_response with capture_response: true puts
# `std::string &` into the trigger's Ts..., which exposed a codegen
# bug where the apply lambda's parameter list did not match the
# ApplyFn signature.
# bug where the generated function's parameter list did not match
# the automation's arguments.
- light.turn_on:
id: test_regression_light
brightness: 100%
+2
View File
@@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
cg.add_define("USE_WEBSERVER")
# api_connection.cpp reports the port whenever USE_WEBSERVER is set
cg.add_define("USE_WEBSERVER_PORT", 80)
# The gamma table and its stored gamma are behind this define
cg.add_define("USE_LIGHT_GAMMA_LUT")
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["json"]
+2
View File
@@ -217,6 +217,7 @@ light:
name: Binary Light
output: light_test_binary
restore_mode: RESTORE_AND_ON
resume_effect: true
effects:
- strobe:
on_state:
@@ -254,6 +255,7 @@ light:
state: INITIAL
brightness: INITIAL
- platform: monochromatic
flash_transition_length: 100ms
id: test_monochromatic_light
name: Monochromatic Light
output: test_ledc_1
@@ -0,0 +1,68 @@
#include <gtest/gtest.h>
#include "esphome/components/light/light_effect.h"
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
namespace esphome::light::testing {
namespace {
class BrightnessOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::BRIGHTNESS});
return traits;
}
void write_state(LightState *state) override {}
};
class NoopEffect : public LightEffect {
public:
using LightEffect::LightEffect;
void apply() override {}
};
// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t.
class TestableLightState : public LightState {
public:
using LightState::LightState;
using LightState::start_effect_;
};
} // namespace
// add_effects() is public, so an external component can exceed the codegen cap on effect count;
// an index the uint16_t can't hold must be ignored rather than wrap onto another effect.
TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) {
BrightnessOutput output;
TestableLightState state(&output);
NoopEffect effect("Noop");
state.add_effects({&effect});
state.start_effect_(1);
ASSERT_EQ(state.get_current_effect_index(), 1u);
state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect
EXPECT_EQ(state.get_current_effect_index(), 1u);
}
// get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals.
TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) {
static constexpr GammaTable TABLE{{}, 280};
BrightnessOutput output;
LightState state(&output);
state.set_gamma_table(&TABLE);
EXPECT_FLOAT_EQ(state.get_gamma_correct(), 2.8f);
EXPECT_EQ(state.get_gamma_table(), TABLE.lut);
}
TEST(LightStateGamma, IsZeroWithoutATable) {
BrightnessOutput output;
LightState state(&output);
EXPECT_FLOAT_EQ(state.get_gamma_correct(), 0.0f);
EXPECT_EQ(state.get_gamma_table(), nullptr);
}
} // namespace esphome::light::testing
+15
View File
@@ -0,0 +1,15 @@
import esphome.codegen as cg
from esphome.components.lvgl import LVGL_VERSION
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real to_code needs a display and widgets. The unit tests only need the LVGL
# headers and the animation code.
async def to_code_testing(config: ConfigType) -> None:
cg.add_library("lvgl/lvgl", LVGL_VERSION)
cg.add_build_flag("-DLV_CONF_SKIP=1")
cg.add_define("USE_LVGL_ANIMATION")
manifest.to_code = to_code_testing
+184
View File
@@ -0,0 +1,184 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
#include "esphome/components/lvgl/animation.h"
namespace esphome::lvgl::testing {
namespace {
// The update callback is a plain function pointer, so it cannot capture; results go to a global.
std::vector<lv_coord_t> updates; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void record_update(const lv_coord_t *data) { updates.push_back(data[0]); }
// Exposes the running state so tests can wait for the animation to finish.
class TestAnimation : public LvAnimation<1> {
public:
TestAnimation(lv_coord_t from, lv_coord_t to, uint32_t duration_ms, uint32_t start_delay_ms = 0)
: LvAnimation<1>(record_update, {TemplatableValue<lv_coord_t>(from)}, {TemplatableValue<lv_coord_t>(to)}) {
this->set_duration(duration_ms);
this->set_start_delay(start_delay_ms);
this->add_on_start_callback([this]() { this->start_count++; });
this->add_on_stop_callback([this]() { this->stop_count++; });
}
bool is_running() const { return this->state_ != AnimationState::STOPPED; }
// Returns true if the animation stopped before the timeout.
bool run_until_stopped(uint32_t timeout_ms = 1000) {
const uint32_t begin = millis();
while (this->is_running() && millis() - begin < timeout_ms) {
this->loop();
delay(1);
}
return !this->is_running();
}
void run_for(uint32_t duration_ms) {
const uint32_t begin = millis();
while (millis() - begin < duration_ms) {
this->loop();
delay(1);
}
}
int start_count{0};
int stop_count{0};
};
lv_coord_t max_update() {
lv_coord_t result = updates.front();
for (auto value : updates)
result = std::max(result, value);
return result;
}
} // namespace
class LvAnimationTest : public ::testing::Test {
protected:
void SetUp() override { updates.clear(); }
};
TEST_F(LvAnimationTest, LinearRunsToEndAndStopsOnce) {
TestAnimation anim(0, 100, 20);
anim.start();
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(updates.front(), 0);
EXPECT_EQ(updates.back(), 100);
EXPECT_EQ(anim.start_count, 1);
EXPECT_EQ(anim.stop_count, 1);
// Nothing more happens once stopped.
const size_t update_count = updates.size();
anim.run_for(10);
EXPECT_EQ(updates.size(), update_count);
EXPECT_EQ(anim.stop_count, 1);
}
TEST_F(LvAnimationTest, ZeroDurationDoesNotStart) {
TestAnimation anim(0, 100, 0);
anim.start();
EXPECT_FALSE(anim.is_running());
EXPECT_EQ(anim.start_count, 0);
EXPECT_TRUE(updates.empty());
}
TEST_F(LvAnimationTest, StartDelayHoldsBackUpdates) {
TestAnimation anim(0, 100, 20, 30);
anim.start();
EXPECT_TRUE(anim.is_running());
EXPECT_TRUE(updates.empty());
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(updates.back(), 100);
}
// Round trip maps the end of the duration back to the start value, so completion must not depend on the
// mapped value reaching 1.0.
TEST_F(LvAnimationTest, RoundTripStopsAtStartValue) {
LvAnimationTimingRoundTrip timing(0.0f);
TestAnimation anim(0, 100, 20);
anim.add_timing(&timing);
anim.start();
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(updates.back(), 0);
EXPECT_EQ(anim.stop_count, 1);
}
// The pause maps to 1.0 in the middle of the duration; the animation must still play the return leg.
TEST_F(LvAnimationTest, RoundTripWithPausePlaysReturnLeg) {
LvAnimationTimingRoundTrip timing(0.5f);
TestAnimation anim(0, 100, 40);
anim.add_timing(&timing);
anim.start();
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(max_update(), 100);
EXPECT_EQ(updates.back(), 0);
EXPECT_EQ(anim.stop_count, 1);
}
TEST_F(LvAnimationTest, GravityStops) {
LvAnimationTimingGravity timing(0.5f, 0.5f);
TestAnimation anim(0, 100, 20);
anim.add_timing(&timing);
anim.start();
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(anim.stop_count, 1);
}
TEST_F(LvAnimationTest, EaseInOutEndsAtTarget) {
LvAnimationTimingEaseInOut timing(1.0f);
TestAnimation anim(0, 100, 20);
anim.add_timing(&timing);
anim.start();
ASSERT_TRUE(anim.run_until_stopped());
EXPECT_EQ(updates.back(), 100);
}
TEST_F(LvAnimationTest, LoopRestartsOnlyAfterReachingEnd) {
TestAnimation anim(0, 100, 20);
anim.set_loop(true);
anim.start();
anim.run_for(100);
anim.stop();
EXPECT_GE(anim.start_count, 2);
// Each restart is a drop in value, and must follow a completed cycle that reached the end value.
for (size_t i = 1; i < updates.size(); i++) {
if (updates[i] < updates[i - 1])
EXPECT_EQ(updates[i - 1], 100) << "update " << i << " restarted before the end was reached";
}
}
// The first frame of the next cycle must not be drawn in the same pass as the final frame of the last one.
TEST_F(LvAnimationTest, LoopRestartKeepsFinalFrame) {
TestAnimation anim(0, 100, 20);
anim.set_loop(true);
anim.start();
const uint32_t begin = millis();
while (millis() - begin < 100) {
const size_t before = updates.size();
anim.loop();
ASSERT_LE(updates.size() - before, 1u);
delay(1);
}
anim.stop();
EXPECT_GE(anim.start_count, 2);
}
TEST_F(LvAnimationTest, LoopWithRoundTripKeepsCycling) {
LvAnimationTimingRoundTrip timing(0.0f);
TestAnimation anim(0, 100, 20);
anim.add_timing(&timing);
anim.set_loop(true);
anim.start();
anim.run_for(100);
EXPECT_GE(anim.start_count, 2);
EXPECT_GE(anim.stop_count, 1);
anim.stop();
}
} // namespace esphome::lvgl::testing
+52 -37
View File
@@ -13,60 +13,75 @@ media_player:
speaker: test_speaker
format: NONE
on_state:
- media_player.play:
- media_player.play_media: http://localhost/media.mp3
- media_player.play_media: !lambda |-
return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3";
- media_player.play: test_media_player
- media_player.play_media:
id: test_media_player
media_url: http://localhost/media.mp3
- media_player.play_media:
id: test_media_player
media_url: !lambda |-
return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3";
on_idle:
- media_player.pause:
- media_player.pause: test_media_player
on_play:
- media_player.stop: test_media_player
- media_player.stop:
- media_player.stop:
id: test_media_player
announcement: true
- media_player.stop:
id: test_media_player
announcement: !lambda 'return id(test_media_player).is_muted();'
on_announcement:
- media_player.play:
- media_player.play: test_media_player
on_turn_on:
- media_player.play:
- media_player.play: test_media_player
on_turn_off:
- media_player.stop:
- media_player.stop: test_media_player
on_pause:
- media_player.toggle:
- media_player.turn_on:
- media_player.turn_off:
- media_player.next:
- media_player.previous:
- media_player.mute:
- media_player.unmute:
- media_player.repeat_off:
- media_player.repeat_one:
- media_player.repeat_all:
- media_player.shuffle:
- media_player.unshuffle:
- media_player.group_join:
- media_player.clear_playlist:
- media_player.toggle: test_media_player
- media_player.turn_on: test_media_player
- media_player.turn_off: test_media_player
- media_player.next: test_media_player
- media_player.previous: test_media_player
- media_player.mute: test_media_player
- media_player.unmute: test_media_player
- media_player.repeat_off: test_media_player
- media_player.repeat_one: test_media_player
- media_player.repeat_all: test_media_player
- media_player.shuffle: test_media_player
- media_player.unshuffle: test_media_player
- media_player.group_join: test_media_player
- media_player.clear_playlist: test_media_player
- wait_until:
media_player.is_idle:
media_player.is_idle: test_media_player
- wait_until:
media_player.is_playing:
media_player.is_playing: test_media_player
- wait_until:
media_player.is_announcing:
media_player.is_announcing: test_media_player
- wait_until:
media_player.is_paused:
media_player.is_paused: test_media_player
- wait_until:
media_player.is_on:
media_player.is_on: test_media_player
- wait_until:
media_player.is_off:
media_player.is_off: test_media_player
- wait_until:
media_player.is_muted:
- media_player.volume_up:
- media_player.volume_down:
- media_player.volume_set: 50%
- media_player.volume_set: !lambda 'return id(test_media_player).volume / 2;'
- media_player.enqueue: http://localhost/media.mp3
- media_player.enqueue: !lambda |-
return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3";
media_player.is_muted: test_media_player
- media_player.volume_up: test_media_player
- media_player.volume_down: test_media_player
- media_player.volume_set:
id: test_media_player
volume: 50%
- media_player.volume_set:
id: test_media_player
volume: !lambda 'return id(test_media_player).volume / 2;'
- media_player.enqueue:
id: test_media_player
media_url: http://localhost/media.mp3
- media_player.enqueue:
id: test_media_player
media_url: !lambda |-
return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3";
- media_player.enqueue:
id: test_media_player
media_url: http://localhost/media.mp3
announcement: true
+59 -15
View File
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <array>
#include <deque>
#include <span>
#include "esphome/components/pn532/pn532.h"
@@ -14,6 +16,7 @@ class FakePN532 : public PN532 {
using PN532::auth_mifare_classic_block_;
using PN532::read_mifare_ultralight_bytes_;
using PN532::read_mifare_classic_block_;
using PN532::write_command_;
using PN532::write_mifare_classic_block_;
using PN532::write_mifare_ultralight_page_;
@@ -22,24 +25,26 @@ class FakePN532 : public PN532 {
protected:
bool is_read_ready() override { return true; }
bool write_data(const std::vector<uint8_t> &data) override {
this->written.push_back(data);
bool write_data(std::span<const uint8_t> data) override {
this->written.emplace_back(data.begin(), data.end());
return true;
}
// only used for ACK frames; index 0 is the I2C status byte
bool read_data(std::vector<uint8_t> &data, uint8_t len) override {
bool read_data(PN532Frame &data, size_t len) override {
data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00};
return true;
}
bool read_response(uint8_t command, std::vector<uint8_t> &data) override {
bool read_response(uint8_t command, PN532Frame &data) override {
if (this->responses.empty())
return false;
data = this->responses.front();
data.assign(this->responses.front().begin(), this->responses.front().end());
this->responses.pop_front();
return true;
}
};
std::vector<uint8_t> bytes_of(std::span<const uint8_t> bytes) { return {bytes.begin(), bytes.end()}; }
// Extracts the command bytes (after TFI) from a normal information frame
std::vector<uint8_t> frame_data(const std::vector<uint8_t> &frame) {
// preamble, start code (2), LEN, LCS, TFI, data..., DCS, postamble
@@ -58,40 +63,79 @@ TEST(PN532TagType, FromSelRes) {
EXPECT_EQ(tag_type_from_sel_res(0x40), nfc::TAG_TYPE_UNKNOWN);
}
// The frame wraps the command in preamble, start code, LEN, LCS, TFI, DCS and postamble (UM0701-02, 6.2.1.1).
TEST(PN532Frame, WrapsCommand) {
FakePN532 pn532;
ASSERT_TRUE(pn532.write_command_({0x4A, 0x01, 0x00}));
ASSERT_EQ(pn532.written.size(), 1u);
EXPECT_EQ(pn532.written[0], (std::vector<uint8_t>{0x00, 0x00, 0xFF, 0x04, 0xFC, 0xD4, 0x4A, 0x01, 0x00, 0xE1, 0x00}));
}
// A command that cannot fit a normal information frame is refused rather than truncated.
TEST(PN532Frame, RejectsOversizedCommand) {
FakePN532 pn532;
std::array<uint8_t, PN532_FRAME_MAX_DATA_SIZE + 1> too_long{};
EXPECT_FALSE(pn532.write_command_(too_long));
EXPECT_TRUE(pn532.written.empty());
EXPECT_TRUE(pn532.write_command_(std::span<const uint8_t>(too_long).first(PN532_FRAME_MAX_DATA_SIZE)));
ASSERT_EQ(pn532.written.size(), 1u);
EXPECT_EQ(pn532.written[0].size(), PN532_FRAME_MAX_DATA_SIZE + 8);
}
// A failed write (status byte other than 0x00) must be reported as a failure.
TEST(PN532Mifare, ClassicWriteChecksStatus) {
FakePN532 pn532;
const uint8_t block[16] = {};
pn532.responses.push_back({0x14}); // authentication error
EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block, sizeof(block)));
EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block));
pn532.responses.push_back({0x00});
EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block, sizeof(block)));
EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block));
}
TEST(PN532Mifare, UltralightWriteChecksStatus) {
FakePN532 pn532;
const uint8_t page[4] = {};
pn532.responses.push_back({0x01}); // timeout
EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page)));
EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page));
pn532.responses.push_back({0x00});
EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page)));
EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page));
}
TEST(PN532Mifare, ClassicReadRejectsBadResponses) {
FakePN532 pn532;
std::vector<uint8_t> data;
MifareReadData data{};
pn532.responses.emplace_back(); // empty response
EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data));
data.clear();
pn532.responses.push_back({0x00, 0x01, 0x02}); // short block
EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data));
std::vector<uint8_t> good(17, 0xAB);
good[0] = 0x00;
pn532.responses.push_back(good);
data.clear();
EXPECT_TRUE(pn532.read_mifare_classic_block_(4, data));
EXPECT_EQ(data, std::vector<uint8_t>(16, 0xAB));
EXPECT_EQ(bytes_of(data), std::vector<uint8_t>(16, 0xAB));
}
// The NDEF TLV is type 0x03, a one-byte length below 255 (three bytes otherwise), the message, terminator 0xFE,
// then zero padding out to the requested length.
TEST(PN532Ndef, FillsTlv) {
FixedVector<uint8_t> buffer;
const std::array<uint8_t, 3> message = {0xD1, 0x01, 0x02};
nfc::fill_ndef_tlv(message, 8, buffer);
EXPECT_EQ(bytes_of(std::span<const uint8_t>(buffer)),
(std::vector<uint8_t>{0x03, 0x03, 0xD1, 0x01, 0x02, 0xFE, 0x00, 0x00}));
std::vector<uint8_t> long_message(300, 0xAA);
nfc::fill_ndef_tlv(long_message, 320, buffer);
ASSERT_EQ(buffer.size(), 320u);
EXPECT_EQ(buffer[0], 0x03);
EXPECT_EQ(buffer[1], 0xFF);
EXPECT_EQ(buffer[2], 0x01); // 300 = 0x012C
EXPECT_EQ(buffer[3], 0x2C);
EXPECT_EQ(buffer[4], 0xAA);
EXPECT_EQ(buffer[303], 0xAA);
EXPECT_EQ(buffer[304], 0xFE);
EXPECT_EQ(buffer[319], 0x00);
}
// Authentication carries exactly 4 UID bytes: the last 4 of a 7-byte UID.
@@ -119,7 +163,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) {
pn532.responses.push_back(first);
pn532.responses.push_back(second);
std::vector<uint8_t> data;
UltralightReadBuffer data;
ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data));
ASSERT_EQ(data.size(), 20u);
EXPECT_EQ(data[15], 15);
@@ -133,7 +177,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) {
TEST(PN532Mifare, UltralightReadRejectsBadResponses) {
FakePN532 pn532;
std::vector<uint8_t> data;
UltralightReadBuffer data;
pn532.responses.push_back({0x00, 0x01, 0x02}); // short response
EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data));
@@ -0,0 +1,10 @@
button:
- platform: template
name: PN532 write state
on_press:
- if:
condition:
pn532.is_writing:
id: pn532_nfcc_i2c
then:
- logger.log: Writing
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
pn532_i2c: !include common.yaml
actions: !include common-actions.yaml
+68 -5
View File
@@ -12,6 +12,9 @@ namespace {
class FakePN71xx : public PN71xx {
public:
using PN71xx::card_emu_t4t_get_response_;
using PN71xx::discovered_endpoint_;
using PN71xx::erase_tag_;
using PN71xx::find_or_add_tag_;
using PN71xx::transceive_;
std::deque<std::vector<uint8_t>> to_read;
@@ -36,7 +39,8 @@ class FakePN71xx : public PN71xx {
this->write_failures--;
return nfc::STATUS_FAILED;
}
this->written.push_back(tx.encode());
const auto encoded = tx.encode();
this->written.emplace_back(encoded.begin(), encoded.end());
return nfc::STATUS_OK;
}
};
@@ -48,9 +52,9 @@ std::vector<uint8_t> apdu(std::initializer_list<uint8_t> bytes) {
}
std::vector<uint8_t> respond(FakePN71xx &nfcc, std::initializer_list<uint8_t> bytes) {
std::vector<uint8_t> response;
CardEmuResponse response;
nfcc.card_emu_t4t_get_response_(apdu(bytes), response);
return response;
return {response.begin(), response.end()};
}
void select_ndef_file(FakePN71xx &nfcc) {
@@ -58,6 +62,10 @@ void select_ndef_file(FakePN71xx &nfcc) {
respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04});
}
std::vector<uint8_t> bytes_of(const nfc::NciMessage &msg) {
return {msg.get_message().begin(), msg.get_message().end()};
}
const std::vector<uint8_t> SW_OK = {0x90, 0x00};
const std::vector<uint8_t> SW_NOT_FOUND = {0x6A, 0x82};
@@ -80,7 +88,7 @@ TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) {
nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00});
nfc::NciMessage rx;
EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK);
EXPECT_EQ(rx.get_message(), (std::vector<uint8_t>{0x41, 0x06, 0x01, 0x00}));
EXPECT_EQ(bytes_of(rx), (std::vector<uint8_t>{0x41, 0x06, 0x01, 0x00}));
EXPECT_EQ(nfcc.written.size(), 1u);
}
@@ -100,10 +108,65 @@ TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) {
nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00});
nfc::NciMessage rx;
EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK);
EXPECT_EQ(rx.get_message(), (std::vector<uint8_t>{0x41, 0x03, 0x01, 0x00}));
EXPECT_EQ(bytes_of(rx), (std::vector<uint8_t>{0x41, 0x03, 0x01, 0x00}));
EXPECT_EQ(nfcc.written.size(), 1u);
}
nfc::NfcTagUid uid_of(uint8_t last) { return {0x04, 0x00, 0x00, last}; }
// Erasing an entry keeps the others in order and frees the tag of the slot that is dropped.
TEST(PN71xxTagCache, EraseKeepsOrderAndFreesTail) {
FakePN71xx nfcc;
for (uint8_t i = 0; i < 3; i++) {
nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i));
}
nfcc.erase_tag_(1);
ASSERT_EQ(nfcc.discovered_endpoint_.size(), 2u);
EXPECT_EQ(nfcc.discovered_endpoint_[0].tag->get_uid()[3], 0);
EXPECT_EQ(nfcc.discovered_endpoint_[1].tag->get_uid()[3], 2);
EXPECT_EQ(nfcc.discovered_endpoint_.data()[2].tag, nullptr);
nfcc.erase_tag_(1);
ASSERT_EQ(nfcc.discovered_endpoint_.size(), 1u);
EXPECT_EQ(nfcc.discovered_endpoint_.data()[1].tag, nullptr);
}
// A full cache evicts the entry seen longest ago instead of refusing the new tag.
TEST(PN71xxTagCache, FullCacheEvictsOldest) {
FakePN71xx nfcc;
for (uint8_t i = 0; i < MAX_DISCOVERED_ENDPOINTS; i++) {
const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i));
nfcc.discovered_endpoint_[loc].last_seen = 100 + i;
}
nfcc.discovered_endpoint_[3].last_seen = 1; // seen longest ago
const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99));
ASSERT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS);
EXPECT_EQ(nfcc.discovered_endpoint_[loc].tag->get_uid()[3], 0x99);
for (const auto &endpoint : nfcc.discovered_endpoint_) {
EXPECT_NE(endpoint.tag->get_uid()[3], 3);
}
// a known UID is found, not added again
EXPECT_EQ(nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)), loc);
EXPECT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS);
}
// Bytes that do not fit the packet are dropped and the length byte stays consistent.
TEST(PN71xxNciMessage, AppendStopsAtPacketSize) {
nfc::NciMessage msg(nfc::NCI_PKT_MT_DATA, {0x01});
std::vector<uint8_t> big(300, 0xAA);
msg.append(big);
const auto encoded = msg.encode();
EXPECT_EQ(encoded.size(), nfc::NCI_PKT_MAX_SIZE);
EXPECT_EQ(msg.get_payload_size(), nfc::NCI_PKT_MAX_PAYLOAD_SIZE);
}
// A read that could not fit the status bytes into one packet is refused.
TEST(PN71xxCardEmulation, OversizedReadIsRejected) {
FakePN71xx nfcc;
nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/pulse_ce");
select_ndef_file(nfcc);
EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0xFE}), SW_NOT_FOUND);
}
// A refused write (e.g. NFCC in standby) is sent again.
TEST(PN71xxTransceive, RefusedWriteIsRetried) {
FakePN71xx nfcc;
+4
View File
@@ -3,6 +3,10 @@
time:
- platform: sntp # Required for datetime
id: sntp_time
- platform: template
id: template_offset_time
lambda: |-
return id(sntp_time).timestamp_now() + 3600;
wifi: # Required for sntp time
ap:
+1
View File
@@ -1,4 +1,5 @@
tfluna:
- i2c_id: i2c_bus
sensor:
- platform: tfluna
+55 -71
View File
@@ -16,7 +16,6 @@ import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
@@ -35,7 +34,7 @@ from esphome.helpers import (
write_file,
write_file_if_changed,
)
from esphome.platformio.toolchain import get_idedata
from esphome.host.toolchain import get_elf_path
from .const import (
API_CONNECTION_TIMEOUT,
@@ -82,16 +81,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
def _get_build_env() -> dict[str, str]:
"""Environment for an ``esphome compile`` subprocess."""
env = os.environ.copy()
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
# libdeps is keyed only by env name (the device name), and fixtures share
# names; two xdist workers first-compiling the same name race pio pkg
# install in the same directory. Keep libdeps per worker.
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
# Prevent cache cleaning during integration tests
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
# Cap each compile's -j so several xdist workers do not each spawn a
@@ -110,53 +102,41 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
return env
# Registry libraries (noise-c, lvgl, ...) download into <data_dir>/pio_components,
# and every integration test gets its own data dir. Share one download cache
# per xdist worker instead: the converter has no cross-process lock, so
# workers must never write the same dir, while a worker's later tests (and
# later runs) reuse what it fetched.
LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components"
@pytest.fixture(scope="session")
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT
cache_dir = test_cache_dir / "platformio"
def shared_library_cache() -> Path:
"""This worker's shared registry-library download cache."""
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
# Keyed by checkout too: two sessions on one machine share worker ids
cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
# Use a lock file in the home directory to ensure only one process initializes the cache
# This is needed when running with pytest-xdist
# The lock file must be in a directory that already exists to avoid race conditions
lock_file = Path.home() / ".esphome-integration-tests-init.lock"
# Always acquire the lock to ensure cache is ready before proceeding
with lock_file.open("w") as lock_fd:
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
# Check if the native platform is installed (the actual indicator of a populated cache)
native_platform = cache_dir / "platforms" / "native"
if not native_platform.exists():
# Create the test cache directory if it doesn't exist
test_cache_dir.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory() as tmpdir:
# Use the cache_init fixture for initialization
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
# tests try to populate the same cache directory simultaneously
env = _get_platformio_env(cache_dir)
subprocess.run(
[sys.executable, "-m", "esphome", "compile", str(config_path)],
check=True,
cwd=init_dir,
env=env,
close_fds=False,
)
# Lock is held until here, ensuring cache is fully populated before any test proceeds
yield cache_dir
def _link_library_cache(config_dir: Path, cache_dir: Path) -> None:
"""Point a config dir's library download cache at the shared one
(blocking, run in executor)."""
data_dir = config_dir / ".esphome"
data_dir.mkdir(exist_ok=True)
link = data_dir / "pio_components"
if link.is_symlink():
if link.resolve() == cache_dir.resolve():
return
# A shared build dir was linked by another worker; writing through
# it would put two workers in one cache
link.unlink()
elif link.exists():
# A real dir from a run predating the shared cache; nothing in it is
# worth more than a re-download
rmtree(link)
link.symlink_to(cache_dir, target_is_directory=True)
@pytest.fixture(scope="module", autouse=True)
@@ -238,18 +218,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
content = content.replace("api:", f"api:\n port: {unused_tcp_port}")
# Add debug build flags for integration tests to enable assertions
if "esphome:" in content and "platformio_options:" not in content:
# Add platformio_options with debug flags after esphome:
if "esphome:" in content and "build_flags:" not in content:
# Add build_flags with debug flags after esphome:
content = content.replace(
"esphome:",
"esphome:\n"
" # Enable assertions for integration tests\n"
" platformio_options:\n"
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
)
# Replace external component path placeholder if present
@@ -283,8 +262,8 @@ async def write_yaml_config(
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
# Deliberately not CI-cached; stale dirs for a fixture are pruned when its
# content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
@@ -470,8 +449,7 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
binary_path = get_elf_path()
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@@ -480,19 +458,22 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
shared_library_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
async def _compile(config_path: Path) -> Path:
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
env = _get_build_env()
loop = asyncio.get_running_loop()
name = _shared_yaml_name(request)
if name is None:
# Share the library download cache so a test never re-fetches
# what an earlier one already pulled from the registry
await loop.run_in_executor(
None, _link_library_cache, integration_test_dir, shared_library_cache
)
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
@@ -568,6 +549,9 @@ async def compile_esphome(
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await loop.run_in_executor(
None, _link_library_cache, shared_dir, shared_library_cache
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
@@ -1,9 +1,8 @@
esphome:
name: api-action-timeout-test
# Use a short timeout for testing (500ms instead of 30s)
platformio_options:
build_flags:
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
build_flags:
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
host:
@@ -1,10 +0,0 @@
esphome:
name: cache-init
host:
api:
encryption:
key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w="
logger:
@@ -1,8 +1,7 @@
esphome:
name: fnv1-hash-object-id-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::fnv1_hash_object_id;
+2 -3
View File
@@ -1,8 +1,7 @@
esphome:
name: fnv1a-hash-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::fnv1a_hash;
@@ -0,0 +1,47 @@
esphome:
name: light-resume-effect
host:
api:
logger:
level: DEBUG
output:
- platform: template
id: light_output
type: float
write_action:
- logger.log:
format: "LIGHT_OUTPUT:%.2f"
args: [state]
light:
- platform: monochromatic
name: "Test Light Resume"
id: test_light_resume
output: light_output
resume_effect: true
effects:
- pulse:
name: "Pulse Effect"
transition_length: 20ms
update_interval: 50ms
- strobe:
name: "Strobe Effect"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
- platform: monochromatic
name: "Test Light No Resume"
id: test_light_no_resume
output: light_output
resume_effect: false
effects:
- pulse:
name: "Pulse Effect"
transition_length: 20ms
update_interval: 50ms
@@ -1,8 +1,7 @@
esphome:
name: micros-to-millis-test
platformio_options:
build_flags:
- "-DDEBUG"
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::micros_to_millis;
@@ -0,0 +1,47 @@
esphome:
name: template-time-test
host:
api:
logger:
time:
- platform: template
id: fixed_time
lambda: |-
return 1700000000;
# Derived from another time source, as when offsetting a real clock.
- platform: template
id: offset_time
lambda: |-
return id(fixed_time).timestamp_now() + 3600;
# A lambda that gives no value reports an invalid time.
- platform: template
id: empty_time
lambda: |-
return {};
text_sensor:
# Timestamps are exposed as text (rather than a 32-bit float sensor state,
# which cannot represent a UNIX epoch exactly) so the exact value can be checked.
- platform: template
name: "Fixed Timestamp"
id: fixed_timestamp
update_interval: 100ms
lambda: |-
return std::to_string(id(fixed_time).now().timestamp);
- platform: template
name: "Offset Timestamp"
id: offset_timestamp
update_interval: 100ms
lambda: |-
return std::to_string(id(offset_time).now().timestamp);
- platform: template
name: "Empty Time Valid"
id: empty_time_valid
update_interval: 100ms
lambda: |-
return std::string(id(empty_time).now().is_valid() ? "valid" : "invalid");
+162 -154
View File
@@ -1,156 +1,164 @@
{
"tests/integration/test_action_concurrent_reentry.py": 33.88,
"tests/integration/test_addressable_light_transition.py": 25.37,
"tests/integration/test_alarm_control_panel_state_transitions.py": 29.76,
"tests/integration/test_api_action_metadata.py": 25.54,
"tests/integration/test_api_action_responses.py": 21.15,
"tests/integration/test_api_action_timeout.py": 37.89,
"tests/integration/test_api_conditional_memory.py": 13.21,
"tests/integration/test_api_custom_services.py": 18.72,
"tests/integration/test_api_get_time_response_timezone.py": 19.55,
"tests/integration/test_api_homeassistant.py": 32.09,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04,
"tests/integration/test_api_list_entities_backpressure.py": 14.96,
"tests/integration/test_api_message_size_batching.py": 22.77,
"tests/integration/test_api_reboot_timeout.py": 20.76,
"tests/integration/test_api_string_lambda.py": 35.07,
"tests/integration/test_api_vv_logging.py": 17.17,
"tests/integration/test_api_zero_psk_provisioning.py": 27.91,
"tests/integration/test_areas_and_devices.py": 23.45,
"tests/integration/test_automation_wait_actions.py": 21.21,
"tests/integration/test_automations.py": 30.73,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97,
"tests/integration/test_binary_sensor_invalidate_state.py": 17.7,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43,
"tests/integration/test_build_info.py": 23.12,
"tests/integration/test_camera_mock.py": 21.23,
"tests/integration/test_climate_control_action.py": 26.55,
"tests/integration/test_climate_custom_modes.py": 24.85,
"tests/integration/test_continuation_actions.py": 14.94,
"tests/integration/test_cover_control_action.py": 25.35,
"tests/integration/test_crc8_helper.py": 19.94,
"tests/integration/test_device_id_in_state.py": 57.04,
"tests/integration/test_duplicate_entities.py": 17.91,
"tests/integration/test_entity_icon.py": 23.81,
"tests/integration/test_fan_turn_on_action.py": 14.88,
"tests/integration/test_fnv1_hash_object_id.py": 21.14,
"tests/integration/test_fnv1a_hash.py": 16.96,
"tests/integration/test_gpio_expander_cache.py": 16.1,
"tests/integration/test_host_logger_thread_safety.py": 17.96,
"tests/integration/test_host_mode_basic.py": 8.17,
"tests/integration/test_host_mode_batch_delay.py": 18.78,
"tests/integration/test_host_mode_climate_basic_state.py": 28.88,
"tests/integration/test_host_mode_climate_control.py": 20.11,
"tests/integration/test_host_mode_empty_string_options.py": 21.1,
"tests/integration/test_host_mode_entity_fields.py": 22.3,
"tests/integration/test_host_mode_fan_preset.py": 18.12,
"tests/integration/test_host_mode_many_entities.py": 47.88,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94,
"tests/integration/test_host_mode_noise_encryption.py": 30.68,
"tests/integration/test_host_mode_reconnect.py": 4.8,
"tests/integration/test_host_mode_sensor.py": 17.36,
"tests/integration/test_host_ota.py": 94.88,
"tests/integration/test_host_preferences.py": 20.49,
"tests/integration/test_host_preferences_suspend_resume.py": 24.17,
"tests/integration/test_improv_serial_uart.py": 22.53,
"tests/integration/test_large_message_batching.py": 17.54,
"tests/integration/test_legacy_area.py": 23.03,
"tests/integration/test_legacy_climate_compat.py": 19.3,
"tests/integration/test_legacy_fan_compat.py": 14.69,
"tests/integration/test_light_automations.py": 21.44,
"tests/integration/test_light_binary_effect_off_phase.py": 29.84,
"tests/integration/test_light_calls.py": 26.72,
"tests/integration/test_light_constant_brightness.py": 29.08,
"tests/integration/test_light_control_action.py": 22.44,
"tests/integration/test_light_dim_relative_action.py": 16.57,
"tests/integration/test_light_effect_zero_brightness.py": 32.2,
"tests/integration/test_light_initial_state.py": 18.42,
"tests/integration/test_light_toggle_action.py": 16.23,
"tests/integration/test_lock_automations.py": 28.17,
"tests/integration/test_logger_buffered_recursion_guard.py": 23.52,
"tests/integration/test_loop_disable_enable.py": 19.4,
"tests/integration/test_loop_interval_decoupling.py": 27.32,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47,
"tests/integration/test_lvgl_headless_render.py": 58.56,
"tests/integration/test_micros_to_millis.py": 21.91,
"tests/integration/test_multi_click_trigger.py": 25.74,
"tests/integration/test_multi_device_preferences.py": 24.1,
"tests/integration/test_noise_encryption_key_protection.py": 22.54,
"tests/integration/test_object_id_api_verification.py": 14.59,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88,
"tests/integration/test_object_id_no_friendly_name.py": 40.23,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62,
"tests/integration/test_online_image_bmp.py": 8.46,
"tests/integration/test_oversized_payloads.py": 41.29,
"tests/integration/test_preference_key_stability.py": 33.97,
"tests/integration/test_runtime_stats.py": 23.77,
"tests/integration/test_safe_mode_loop_runs.py": 7.96,
"tests/integration/test_scheduler_blocking_warning.py": 45.2,
"tests/integration/test_scheduler_bulk_cleanup.py": 16.2,
"tests/integration/test_scheduler_defer_cancel.py": 19.87,
"tests/integration/test_scheduler_defer_cancel_regular.py": 15.93,
"tests/integration/test_scheduler_defer_fifo_simple.py": 14.12,
"tests/integration/test_scheduler_defer_stress.py": 24.55,
"tests/integration/test_scheduler_heap_stress.py": 19.84,
"tests/integration/test_scheduler_internal_id_no_collision.py": 18.42,
"tests/integration/test_scheduler_interval_reschedule.py": 22.37,
"tests/integration/test_scheduler_interval_zero_coerced.py": 3.69,
"tests/integration/test_scheduler_null_name.py": 22.08,
"tests/integration/test_scheduler_numeric_id_test.py": 18.45,
"tests/integration/test_scheduler_pool.py": 16.04,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.32,
"tests/integration/test_scheduler_recursive_timeout.py": 24.06,
"tests/integration/test_scheduler_removed_item_race.py": 24.2,
"tests/integration/test_scheduler_self_keyed.py": 19.48,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9,
"tests/integration/test_scheduler_string_test.py": 18.13,
"tests/integration/test_script_array_params.py": 8.06,
"tests/integration/test_script_delay_params.py": 13.13,
"tests/integration/test_script_queued.py": 17.67,
"tests/integration/test_script_queued_idle_loop.py": 16.33,
"tests/integration/test_script_wait_on_boot.py": 14.01,
"tests/integration/test_sdl_headless_screenshot.py": 12.91,
"tests/integration/test_select_stringref_trigger.py": 17.66,
"tests/integration/test_sensor_filters_delta.py": 25.94,
"tests/integration/test_sensor_filters_ring_buffer.py": 17.25,
"tests/integration/test_sensor_filters_sliding_window.py": 44.9,
"tests/integration/test_sensor_filters_value_list.py": 16.0,
"tests/integration/test_sensor_raw_state.py": 42.47,
"tests/integration/test_sensor_timeout_filter.py": 23.58,
"tests/integration/test_set_internal_at_boot.py": 21.55,
"tests/integration/test_snapshot_display.py": 7.58,
"tests/integration/test_socket_wake_gate_tcp.py": 15.76,
"tests/integration/test_status_flags.py": 37.76,
"tests/integration/test_strftime_to.py": 19.69,
"tests/integration/test_syslog.py": 21.44,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98,
"tests/integration/test_template_climate_basic.py": 20.51,
"tests/integration/test_template_climate_custom_modes.py": 16.94,
"tests/integration/test_template_climate_nonoptimistic.py": 19.28,
"tests/integration/test_template_climate_on_control_ordering.py": 15.47,
"tests/integration/test_template_climate_publish_all_fields.py": 18.9,
"tests/integration/test_template_climate_sensor_push.py": 14.96,
"tests/integration/test_template_climate_set_actions.py": 16.04,
"tests/integration/test_template_climate_two_point_temperature.py": 23.72,
"tests/integration/test_template_text_save.py": 15.49,
"tests/integration/test_text_command.py": 14.21,
"tests/integration/test_text_sensor_raw_state.py": 16.32,
"tests/integration/test_uart_mock_ld2410.py": 44.46,
"tests/integration/test_uart_mock_ld2412.py": 85.51,
"tests/integration/test_uart_mock_ld2420.py": 33.99,
"tests/integration/test_uart_mock_ld2450.py": 25.3,
"tests/integration/test_uart_mock_modbus.py": 522.13,
"tests/integration/test_udp.py": 13.7,
"tests/integration/test_use_address_runtime.py": 33.8,
"tests/integration/test_valve_control_action.py": 13.0,
"tests/integration/test_varint_five_byte_device_id.py": 25.11,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.73,
"tests/integration/test_wait_until_on_boot.py": 10.45,
"tests/integration/test_wait_until_ordering.py": 3.64,
"tests/integration/test_wait_until_reentrant_restart.py": 18.73,
"tests/integration/test_wake_loop_forces_phase_b.py": 22.85,
"tests/integration/test_water_heater_template.py": 31.06
"tests/integration/test_action_concurrent_reentry.py": 31.66,
"tests/integration/test_addressable_light_transition.py": 40.49,
"tests/integration/test_alarm_control_panel_state_transitions.py": 28.84,
"tests/integration/test_api_action_metadata.py": 20.54,
"tests/integration/test_api_action_responses.py": 35.05,
"tests/integration/test_api_action_timeout.py": 25.85,
"tests/integration/test_api_conditional_memory.py": 33.44,
"tests/integration/test_api_custom_services.py": 21.85,
"tests/integration/test_api_decode_wire_types.py": 38.03,
"tests/integration/test_api_empty_message_roundtrip.py": 20.53,
"tests/integration/test_api_encode_boundaries.py": 28.74,
"tests/integration/test_api_get_time_response_timezone.py": 26.53,
"tests/integration/test_api_homeassistant.py": 38.17,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 21.45,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 20.86,
"tests/integration/test_api_list_entities_backpressure.py": 24.14,
"tests/integration/test_api_message_size_batching.py": 32.91,
"tests/integration/test_api_reboot_timeout.py": 21.49,
"tests/integration/test_api_string_lambda.py": 22.48,
"tests/integration/test_api_vv_logging.py": 25.08,
"tests/integration/test_api_zero_psk_provisioning.py": 40.67,
"tests/integration/test_areas_and_devices.py": 26.02,
"tests/integration/test_automation_wait_actions.py": 23.49,
"tests/integration/test_automations.py": 49.82,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 27.22,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 23.85,
"tests/integration/test_binary_sensor_invalidate_state.py": 23.49,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.73,
"tests/integration/test_build_info.py": 23.7,
"tests/integration/test_camera_mock.py": 16.57,
"tests/integration/test_climate_control_action.py": 25.09,
"tests/integration/test_climate_custom_modes.py": 26.78,
"tests/integration/test_continuation_actions.py": 20.51,
"tests/integration/test_cover_control_action.py": 24.95,
"tests/integration/test_crc8_helper.py": 17.19,
"tests/integration/test_device_id_in_state.py": 58.05,
"tests/integration/test_duplicate_entities.py": 30.23,
"tests/integration/test_entity_icon.py": 30.68,
"tests/integration/test_entity_missing_state.py": 27.81,
"tests/integration/test_fan_turn_on_action.py": 23.22,
"tests/integration/test_fnv1_hash_object_id.py": 25.75,
"tests/integration/test_fnv1a_hash.py": 3.64,
"tests/integration/test_gpio_expander_cache.py": 22.24,
"tests/integration/test_host_logger_thread_safety.py": 20.82,
"tests/integration/test_host_mode_basic.py": 4.96,
"tests/integration/test_host_mode_batch_delay.py": 24.14,
"tests/integration/test_host_mode_climate_basic_state.py": 27.28,
"tests/integration/test_host_mode_climate_control.py": 28.31,
"tests/integration/test_host_mode_empty_string_options.py": 27.5,
"tests/integration/test_host_mode_entity_fields.py": 30.67,
"tests/integration/test_host_mode_fan_preset.py": 24.14,
"tests/integration/test_host_mode_many_entities.py": 51.6,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 31.89,
"tests/integration/test_host_mode_noise_encryption.py": 40.77,
"tests/integration/test_host_mode_reconnect.py": 12.62,
"tests/integration/test_host_mode_sensor.py": 22.41,
"tests/integration/test_host_ota.py": 110.14,
"tests/integration/test_host_preferences.py": 24.78,
"tests/integration/test_host_preferences_suspend_resume.py": 22.95,
"tests/integration/test_improv_serial_uart.py": 18.2,
"tests/integration/test_large_message_batching.py": 39.36,
"tests/integration/test_legacy_area.py": 21.25,
"tests/integration/test_legacy_climate_compat.py": 25.65,
"tests/integration/test_legacy_fan_compat.py": 23.25,
"tests/integration/test_light_automations.py": 27.09,
"tests/integration/test_light_binary_effect_off_phase.py": 26.9,
"tests/integration/test_light_calls.py": 31.49,
"tests/integration/test_light_constant_brightness.py": 31.64,
"tests/integration/test_light_control_action.py": 24.78,
"tests/integration/test_light_dim_relative_action.py": 23.69,
"tests/integration/test_light_effect_zero_brightness.py": 25.32,
"tests/integration/test_light_initial_state.py": 24.22,
"tests/integration/test_light_restore_state.py": 23.08,
"tests/integration/test_light_resume_effect.py": 30.38,
"tests/integration/test_light_toggle_action.py": 24.42,
"tests/integration/test_light_transition_state_publish_interval.py": 71.09,
"tests/integration/test_lock_automations.py": 21.54,
"tests/integration/test_logger_buffered_recursion_guard.py": 12.66,
"tests/integration/test_loop_disable_enable.py": 22.87,
"tests/integration/test_loop_interval_decoupling.py": 36.3,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 25.88,
"tests/integration/test_lvgl_headless_render.py": 95.64,
"tests/integration/test_micros_to_millis.py": 27.1,
"tests/integration/test_multi_click_trigger.py": 23.09,
"tests/integration/test_multi_device_preferences.py": 28.62,
"tests/integration/test_noise_encryption_key_protection.py": 32.04,
"tests/integration/test_object_id_api_verification.py": 36.37,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 22.4,
"tests/integration/test_object_id_no_friendly_name.py": 42.47,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 50.34,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 5.45,
"tests/integration/test_online_image_bmp.py": 43.63,
"tests/integration/test_oversized_payloads.py": 52.45,
"tests/integration/test_preference_key_stability.py": 26.26,
"tests/integration/test_runtime_stats.py": 28.11,
"tests/integration/test_safe_mode_loop_runs.py": 10.73,
"tests/integration/test_scheduler_blocking_warning.py": 53.72,
"tests/integration/test_scheduler_bulk_cleanup.py": 21.74,
"tests/integration/test_scheduler_defer_cancel.py": 24.06,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.48,
"tests/integration/test_scheduler_defer_fifo_simple.py": 23.68,
"tests/integration/test_scheduler_defer_stress.py": 23.57,
"tests/integration/test_scheduler_heap_stress.py": 23.12,
"tests/integration/test_scheduler_internal_id_no_collision.py": 22.64,
"tests/integration/test_scheduler_interval_reschedule.py": 20.29,
"tests/integration/test_scheduler_interval_zero_coerced.py": 20.9,
"tests/integration/test_scheduler_null_name.py": 20.45,
"tests/integration/test_scheduler_numeric_id_test.py": 23.77,
"tests/integration/test_scheduler_pool.py": 24.07,
"tests/integration/test_scheduler_rapid_cancellation.py": 25.39,
"tests/integration/test_scheduler_recursive_timeout.py": 5.31,
"tests/integration/test_scheduler_removed_item_race.py": 21.95,
"tests/integration/test_scheduler_self_keyed.py": 5.81,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 21.44,
"tests/integration/test_scheduler_string_test.py": 24.38,
"tests/integration/test_script_array_params.py": 16.92,
"tests/integration/test_script_delay_params.py": 14.32,
"tests/integration/test_script_queued.py": 25.32,
"tests/integration/test_script_queued_idle_loop.py": 19.93,
"tests/integration/test_script_wait_on_boot.py": 23.34,
"tests/integration/test_sdl_headless_screenshot.py": 26.0,
"tests/integration/test_select_stringref_trigger.py": 22.38,
"tests/integration/test_sensor_filters_delta.py": 25.72,
"tests/integration/test_sensor_filters_ring_buffer.py": 24.23,
"tests/integration/test_sensor_filters_sliding_window.py": 70.81,
"tests/integration/test_sensor_filters_value_list.py": 22.96,
"tests/integration/test_sensor_raw_state.py": 43.36,
"tests/integration/test_sensor_timeout_filter.py": 22.93,
"tests/integration/test_set_internal_at_boot.py": 23.86,
"tests/integration/test_snapshot_display.py": 26.76,
"tests/integration/test_socket_wake_gate_tcp.py": 21.23,
"tests/integration/test_status_flags.py": 35.38,
"tests/integration/test_strftime_to.py": 22.15,
"tests/integration/test_syslog.py": 28.45,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.28,
"tests/integration/test_template_climate_basic.py": 20.56,
"tests/integration/test_template_climate_custom_modes.py": 25.65,
"tests/integration/test_template_climate_nonoptimistic.py": 19.89,
"tests/integration/test_template_climate_on_control_ordering.py": 21.98,
"tests/integration/test_template_climate_publish_all_fields.py": 25.12,
"tests/integration/test_template_climate_sensor_push.py": 25.02,
"tests/integration/test_template_climate_set_actions.py": 21.45,
"tests/integration/test_template_climate_two_point_temperature.py": 24.62,
"tests/integration/test_template_text_save.py": 24.66,
"tests/integration/test_template_time.py": 18.48,
"tests/integration/test_text_command.py": 23.93,
"tests/integration/test_text_sensor_raw_state.py": 22.68,
"tests/integration/test_uart_mock_ld2410.py": 60.58,
"tests/integration/test_uart_mock_ld2412.py": 79.12,
"tests/integration/test_uart_mock_ld2420.py": 40.95,
"tests/integration/test_uart_mock_ld2450.py": 30.8,
"tests/integration/test_uart_mock_modbus.py": 554.44,
"tests/integration/test_udp.py": 9.61,
"tests/integration/test_use_address_runtime.py": 29.41,
"tests/integration/test_valve_control_action.py": 21.9,
"tests/integration/test_varint_five_byte_device_id.py": 23.6,
"tests/integration/test_wait_until_mid_loop_timing.py": 21.89,
"tests/integration/test_wait_until_on_boot.py": 22.57,
"tests/integration/test_wait_until_ordering.py": 21.27,
"tests/integration/test_wait_until_reentrant_restart.py": 18.84,
"tests/integration/test_wake_loop_forces_phase_b.py": 22.68,
"tests/integration/test_water_heater_template.py": 41.77
}
@@ -0,0 +1,147 @@
"""Integration test verifying light effect restore on/off cycles.
Tests that when resume_effect is enabled, the previously active effect
is restored when the light is turned back on without explicit effect/color
parameters.
"""
from __future__ import annotations
import asyncio
from typing import Any
from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_resume_effect(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""When resume_effect is enabled, effect persists after off/on cycle."""
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light_resume = next(e for e in entities if e.object_id == "test_light_resume")
light_no_resume = next(
e for e in entities if e.object_id == "test_light_no_resume"
)
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# Drain initial state burst
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(
light: LightInfo, timeout: float = 5.0, **kwargs: Any
) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = asyncio.get_running_loop().create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# Test 1: Start with Pulse effect
state = await send_and_wait(light_resume, state=True, effect="Pulse Effect")
assert state.state is True
assert state.effect == "Pulse Effect"
# Test 2: Turn off
state = await send_and_wait(light_resume, state=False)
assert state.state is False
# Test 3: Turn on without specifying effect — should restore Pulse Effect
state = await send_and_wait(light_resume, state=True)
assert state.state is True
assert state.effect == "Pulse Effect", (
"Effect should be restored when turning on without explicit effect parameter"
)
# Test 4: Switch to Strobe effect
state = await send_and_wait(light_resume, effect="Strobe Effect")
assert state.effect == "Strobe Effect"
# Test 5: Turn off
state = await send_and_wait(light_resume, state=False)
assert state.state is False
# Test 6: Turn on without effect — should restore Strobe Effect
state = await send_and_wait(light_resume, state=True)
assert state.state is True
assert state.effect == "Strobe Effect", (
"Effect should be restored to Strobe after turning on"
)
# Test 7: Explicitly set effect to None while turning on — should not restore
state = await send_and_wait(light_resume, state=False)
assert state.state is False
state = await send_and_wait(light_resume, state=True, effect="None")
assert state.state is True
assert state.effect == "None", (
"Explicit effect=None should override restoration"
)
# Test 8: Turn off, then on with explicit effect — should use explicit effect
state = await send_and_wait(light_resume, state=False)
state = await send_and_wait(light_resume, state=True, effect="Pulse Effect")
assert state.effect == "Pulse Effect"
# Test 9: a turn-on that asks for something specific does not restore, and the
# effect it replaced must not come back on a later plain off/on
state = await send_and_wait(light_resume, state=False)
assert state.state is False
state = await send_and_wait(light_resume, state=True, brightness=0.5)
assert state.effect == "None", "A turn-on with brightness should not restore"
state = await send_and_wait(light_resume, state=False)
state = await send_and_wait(light_resume, state=True)
assert state.effect == "None", (
"An effect dropped on an earlier cycle must not return"
)
# Test 10: a plain turn-on sent to a light that is already on never starts the
# remembered effect
state = await send_and_wait(light_resume, state=True, effect="Pulse Effect")
state = await send_and_wait(light_resume, state=False)
state = await send_and_wait(light_resume, state=True, brightness=0.5)
assert state.effect == "None"
state = await send_and_wait(light_resume, state=True)
assert state.effect == "None", (
"A lit light must not pick up the remembered effect"
)
# Test 11: a turn-on that names a color mode is not plain, so it does not restore
state = await send_and_wait(light_resume, state=True, effect="Pulse Effect")
state = await send_and_wait(light_resume, state=False)
state = await send_and_wait(
light_resume, state=True, color_mode=ColorMode.BRIGHTNESS
)
assert state.effect == "None", "A turn-on with a color mode should not restore"
# Test 12: Turn on effect, then off, then on without effect — should not restore Pulse Effect
state = await send_and_wait(light_no_resume, state=True, effect="Pulse Effect")
assert state.state is True
assert state.effect == "Pulse Effect"
state = await send_and_wait(light_no_resume, state=False)
assert state.state is False
state = await send_and_wait(light_no_resume, state=True)
assert state.state is True
assert state.effect == "None", (
"Effect should NOT be restored when resume_effect=false"
)
+49
View File
@@ -0,0 +1,49 @@
"""Integration test for the template time platform."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, TextSensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
# Timestamps are exposed as text sensors (rather than 32-bit float sensor states,
# which cannot represent a UNIX epoch exactly) so the exact value can be checked.
EXPECTED_STATES = {
"fixed_timestamp": "1700000000",
"offset_timestamp": "1700003600",
"empty_time_valid": "invalid",
}
@pytest.mark.asyncio
async def test_template_time(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Verify the template time platform evaluates its lambda on demand."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES))
events = {name: asyncio.Event() for name in EXPECTED_STATES}
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
name = key_to_name.get(state.key)
if name is not None and state.state == EXPECTED_STATES[name]:
events[name].set()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
for name, event in events.items():
try:
await asyncio.wait_for(event.wait(), timeout=3.0)
except TimeoutError:
pytest.fail(f"Timeout waiting for {name} to report its expected value")
+83 -4
View File
@@ -250,6 +250,82 @@ def test_main_all_tests_should_run(
assert len(batch["components"]) > 0
assert isinstance(batch["needs_idf"], bool)
assert isinstance(batch["needs_nrf"], bool)
assert isinstance(batch["needs_arduino8266"], bool)
def test_main_batch_flags_count_variant_tests(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_esp32_platformio_components_to_test: Mock,
mock_esp8266_native_components_to_test: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The compile stage builds test-<variant>.<platform>.yaml too, so a
component tested on esp8266 only by a variant still needs the toolchain."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_esp32_platformio_components_to_test.return_value = []
mock_esp8266_native_components_to_test.return_value = []
mock_determine_cpp_unit_tests.return_value = (False, [])
mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"]
def platforms(component: str, *, base_only: bool = True) -> set[str]:
return set() if base_only else {"esp8266-ard"}
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs, "get_changed_components", return_value=["safe_mode"]
),
patch.object(
determine_jobs,
"filter_component_and_test_files",
side_effect=lambda f: f.startswith("esphome/components/"),
),
patch.object(
determine_jobs,
"get_components_with_dependencies",
return_value=["safe_mode"],
),
patch.object(determine_jobs, "_component_has_tests", return_value=True),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs,
"create_intelligent_batches",
return_value=([["safe_mode"]], {}),
),
patch.object(
determine_jobs, "get_component_test_platforms", side_effect=platforms
),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
assert output["component_test_batches"] == [
{
"components": "safe_mode",
"needs_idf": False,
"needs_nrf": False,
"needs_arduino8266": True,
}
]
def test_main_no_tests_should_run(
@@ -1530,6 +1606,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non
assert set(result["components"]) == {"wifi", "api"}
assert result["platform"] == "esp32-idf" # Common platform
assert result["use_merged_config"] == "true"
assert result["needs_arduino8266"] is False
@pytest.mark.usefixtures("mock_target_branch_dev")
@@ -1634,6 +1711,8 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
assert result["platform"] == "esp8266-ard"
assert result["components"] == ["logger"]
assert result["use_merged_config"] == "true"
# The esp8266 build is native, so the job restores that toolchain
assert result["needs_arduino8266"] is True
@pytest.mark.usefixtures("mock_target_branch_dev")
@@ -3212,13 +3291,13 @@ def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> No
@pytest.mark.parametrize(
("changed_files", "dependency_closure", "expected"),
[
# Tested component changed -- narrow to the intersection.
# A tested component alone does not schedule this job: the component
# matrix already compiles its esp8266 fixtures with this toolchain.
(
["esphome/components/mqtt/mqtt_client.cpp"],
["mqtt", "json"],
["mqtt"],
[],
),
# Components outside the test set return an empty list (job skipped).
(
["esphome/components/wifi/wifi_component.cpp"],
["wifi", "network"],
@@ -3236,7 +3315,7 @@ def test_esp8266_native_components_to_test_narrowing(
dependency_closure: list[str],
expected: list[str],
) -> None:
"""Component changes narrow the native-ESP8266 test list."""
"""Only a native-build change schedules the native-ESP8266 job."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
patch.object(
+1 -4
View File
@@ -2143,10 +2143,7 @@ def test_no_orphan_integration_fixtures() -> None:
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
fixtures = list(fixtures_dir.glob("*.yaml"))
assert fixtures, f"no fixtures found under {fixtures_dir}"
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
orphans = [
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
]
orphans = [f.stem for f in fixtures if f.stem not in mapping]
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
@@ -4,6 +4,7 @@ esphome:
esp8266:
board: d1_mini_pro
toolchain: arduino
logger:
level: VERY_VERBOSE
@@ -715,7 +715,7 @@ def test_write_project_libraries_and_variant(
)
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"):
with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"):
content = _write_ninja(
paths, libraries=[library, headers_only], ccache="/cc/ccache"
)
@@ -727,6 +727,9 @@ def test_write_project_libraries_and_variant(
assert "libHeadersOnly.a" not in content
assert "Library HeadersOnly has no source files" in caplog.text
assert " flags = -DMYLIB=1" in content
# A library's own include dirs lead its compile lines
assert " own_includes = -I" in content
assert "$own_includes $cxxflags $flags" in content
assert "-lalgobsec" in content
# Library link flags reach the firmware link line; .cc compiles as C++
assert "-Wl,--wrap=malloc" in content
+368
View File
@@ -0,0 +1,368 @@
"""Tests for esphome.build_gen.host (the host ninja generator)."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.arduino.library import ArduinoLibrary
from esphome.build_gen import host as build_gen
from esphome.build_helpers.ninja import escape as _e, quote_path as _q
from esphome.build_helpers.ninja_gen import Flag
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST
from esphome.core import CORE, EsphomeError, Library
from esphome.host.toolchain import PROGRAM_NAME, HostCompilers
COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",))
@pytest.fixture(autouse=True)
def _core(tmp_path: Path) -> None:
CORE.build_path = tmp_path
CORE.name = "dev"
CORE.cpp_standard = "gnu++20"
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
def _make_src(tmp_path: Path, *names: str) -> Path:
src = tmp_path / "src"
for name in names:
path = src / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
return src
def _render(ccache: str | None = None) -> tuple[bool, str]:
changed = build_gen.write_project(COMPILERS, ccache)
ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja"
return changed, ninja.read_text()
@pytest.mark.parametrize(
("entries", "compile_flags", "link_flags"),
[
(
["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"],
[("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)],
[],
),
(["-std=gnu++20"], [("-std=gnu++20",)], []),
(
["-lssl", "-L/opt/lib", "-Wl,--gc-sections"],
[],
[("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)],
),
# A link-only flag travels with its argument
(
["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"],
[],
[
("-Xlinker", "--wrap=malloc"),
("-z", "noexecstack"),
("-framework", "Cocoa"),
],
),
# Both lines, as SCons routes unclassified flags
(
["-g -O2", "-fsanitize=address", "-pthread", "--coverage"],
[
("-g",),
("-O2",),
("-fsanitize=address",),
("-pthread",),
("--coverage",),
],
[
("-g",),
("-O2",),
("-fsanitize=address",),
("-pthread",),
("--coverage",),
],
),
# A flag with a path argument travels with it, glued on or not
(
["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"],
[("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)],
[],
),
],
)
def test_split_flags(
entries: list[str], compile_flags: list[Flag], link_flags: list[Flag]
) -> None:
flags = build_gen.parse_flags(entries, "esphome")
assert build_gen.split_flags(flags) == (compile_flags, link_flags)
@pytest.mark.parametrize(
"entries",
[
# The flag is the last token of its entry
["-g -include"],
# Entries are a set: the next entry is not this flag's argument
["-include", "pre.h"],
["-framework", "-pthread"],
# A path never starts with "-"
["-include -pthread"],
],
)
def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None:
with pytest.raises(EsphomeError, match="with no argument; write the flag"):
build_gen.parse_flags(entries, "esphome")
def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None:
assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [
("-Xlinker", "--wrap=malloc")
]
def test_flag_lists_route_the_standard() -> None:
"""cpp_standard wins for C++; C never sees a C++ standard."""
CORE.build_flags = {
"-DUSE_HOST",
"-Iinc",
"-Wl,-x",
"-g",
"-lssl",
"-std=c++17",
"-std=gnu17",
}
CORE.cxx_build_flags = {"-Wno-volatile"}
cflags, cxxflags, link_flags = build_gen._flag_lists()
# The relative include is anchored at the build path
inc = f"-I{CORE.build_path / 'inc'}"
assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"]
assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"]
assert link_flags == ["-Wl,-x", "-g", "-lssl"]
def test_flag_lists_without_cpp_standard_keeps_user_std() -> None:
CORE.cpp_standard = None
CORE.build_flags = {"-std=c++17"}
cflags, cxxflags, _link = build_gen._flag_lists()
assert cflags == []
assert cxxflags == ["-std=c++17"]
def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None:
CORE.build_flags = {"-g", "-DUSE_HOST"}
CORE.build_unflags = {"-g", "-Onope"}
with caplog.at_level(logging.WARNING):
cflags, cxxflags, link_flags = build_gen._flag_lists()
assert "-g" not in cflags + cxxflags + link_flags
assert "-DUSE_HOST" in cflags
assert "build_unflags entries matched no build flag: -Onope" in caplog.text
def test_resolve_host_libraries_without_libraries() -> None:
with patch("esphome.arduino.library.resolve_libraries") as resolve:
assert build_gen._resolve_host_libraries() == []
resolve.assert_not_called()
def test_resolve_host_libraries_is_framework_less() -> None:
CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0"))
lib = ArduinoLibrary(name="lvgl")
with patch(
"esphome.arduino.library.resolve_libraries", return_value=[lib]
) as resolve:
assert build_gen._resolve_host_libraries() == [lib]
resolve.assert_called_once_with(
None,
pio_platform="native",
board_mcu="host",
cache_key="host",
framework=None,
manifest_optional=True,
)
def test_write_project_requires_generated_sources(tmp_path: Path) -> None:
with pytest.raises(EsphomeError, match="Generated source directory .* is missing"):
build_gen.write_project(COMPILERS, None)
_make_src(tmp_path, "esphome.h")
with pytest.raises(EsphomeError, match="No source files found"):
build_gen.write_project(COMPILERS, None)
def test_write_project_emits_every_source_kind(tmp_path: Path) -> None:
src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h")
CORE.build_flags = {"-DUSE_HOST", "-g"}
changed, ninja = _render(ccache="/usr/bin/ccache")
assert changed is True
# A compiler override's arguments follow the program
assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja
assert f"cxx = {_q('/usr/bin/g++')}" in ninja
assert f"ccache = {_q('/usr/bin/ccache')}" in ninja
assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja
assert (
f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja
)
assert "build obj/src/x.S.o: aspp " in ninja
assert "build obj/src/y.s.o: asm " in ninja
assert "h.h" not in ninja
# __FILE__ stays relative to the build path, as it was under PlatformIO,
# whether the source arrives absolute or rewritten by ccache
sep = os.sep
maps = (
f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} "
f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}"
)
assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
# Assembly gets the defines and includes only
assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja
assert "linkflags = -g\n" in ninja
assert "libdirflags = \n" in ninja
assert "libflags = \n" in ninja
assert "rule ar" not in ninja
assert (
f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o "
f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n"
) in ninja
# Unchanged content reports no change so the compile DB can be reused
changed, _ = _render(ccache="/usr/bin/ccache")
assert changed is False
def test_write_project_without_ccache(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
_changed, ninja = _render()
assert "ccache = \n" in ninja
def test_write_project_routes_user_link_flags(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
lib_dir = tmp_path / "opt" / "lib"
# Forward slashes: build flags are lexed like a POSIX shell line, which
# reads a backslash as an escape
CORE.build_flags = {
f"-L{lib_dir.as_posix()}",
"-lcrypto",
"-Wl,-framework,Security",
}
_changed, ninja = _render()
assert "linkflags = -Wl,-framework,Security\n" in ninja
assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja
assert "libflags = -lcrypto\n" in ninja
def _libraries(tmp_path: Path) -> list[ArduinoLibrary]:
lib_dir = tmp_path / "libs"
for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"):
path = lib_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
archived = ArduinoLibrary(
name="foo",
sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"],
include_dirs=[lib_dir / "foo/src"],
flags=["-DFOO=1"],
link_dirs=[lib_dir / "foo/lib"],
link_libs=["bar"],
link_flags=["-Wl,--gc-sections"],
)
direct = ArduinoLibrary(
name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False
)
header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"])
return [archived, direct, header_only]
def test_write_project_with_libraries(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
src = _make_src(tmp_path, "main.cpp")
libs = _libraries(tmp_path)
with (
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool,
patch.object(build_gen.sys, "platform", "linux"),
caplog.at_level(logging.DEBUG),
):
_changed, ninja = _render()
find_tool.assert_called_once_with("AR", ("ar",))
assert (
f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp"
in ninja
)
lib_dir = tmp_path / "libs"
# Every library's include dir joins the global include path
assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja
assert "linkflags = -Wl,--gc-sections\n" in ninja
assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja
assert "libflags = -lbar\n" in ninja
# Library sources compile with the library's own flags, rooted at their
# common parent; its own include dirs lead the line so another library's
# header of the same name cannot shadow them
own = f"-I{_q(lib_dir / 'foo/src')}"
assert "$own_includes $cxxflags $flags" in ninja
assert (
f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n"
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
)
assert (
f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n"
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
)
assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja
# libArchive: false objects link directly; the archive is an order-only
# input wrapped in a group for GNU ld
assert (
f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o "
"| libfoo.a\n"
" archives = -Wl,--start-group libfoo.a -Wl,--end-group\n"
) in ninja
assert "Library hdr has no source files" in caplog.text
def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
libs = _libraries(tmp_path)
with (
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"),
patch.object(build_gen.sys, "platform", "darwin"),
):
_changed, ninja = _render()
assert " archives = libfoo.a\n" in ninja
assert "--start-group" not in ninja
def test_build_unflags_remove_a_flag_with_its_argument(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Only the named pair goes; the same path under another flag stays."""
CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"}
CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"}
with caplog.at_level(logging.WARNING):
cflags, _cxxflags, link_flags = build_gen._flag_lists()
assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")]
assert link_flags == ["-g"]
assert "matched no build flag" not in caplog.text
def test_build_unflags_warn_when_nothing_matches(
caplog: pytest.LogCaptureFixture,
) -> None:
CORE.build_flags = {"-g"}
CORE.build_unflags = {"-isystem inc", "-DNOPE"}
with caplog.at_level(logging.WARNING):
build_gen._flag_lists()
assert "matched no build flag: -DNOPE, -isystem " in caplog.text
def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None:
CORE.build_flags = {"-Iinc", "-g"}
CORE.build_unflags = {"-Iinc"}
with caplog.at_level(logging.WARNING):
cflags, _cxxflags, _link = build_gen._flag_lists()
assert cflags == ["-g"]
assert "matched no build flag" not in caplog.text
@@ -10,6 +10,7 @@ from unittest.mock import patch
import pytest
from esphome.build_helpers import ccache
from esphome.build_helpers.pch import mark_pch_emitted
from esphome.core import CORE
@@ -135,3 +136,78 @@ def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None:
for bad in ("", "/", "a/b"):
with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True):
assert ccache.effective_ccache_basedir() == str(tmp_path.resolve())
def test_resolve_absolute_ccache_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"):
resolved = ccache.resolve_absolute_ccache_path()
assert Path(resolved) == tmp_path / "bin" / "ccache"
with patch.object(ccache, "resolve_ccache_path", return_value=None):
assert ccache.resolve_absolute_ccache_path() is None
def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from esphome.core import CORE
CORE.build_path = tmp_path / "build"
spec = ("ESPHOME_TEST_PREFIX", "test")
monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache"))
monkeypatch.setenv("CCACHE_NOHASHDIR", "false")
for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"):
monkeypatch.delenv(key, raising=False)
# None means resolved and disabled
assert ccache.ccache_env(None, spec) == {}
env = ccache.ccache_env("/usr/bin/ccache", spec)
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
def test_ccache_defaults_env_escapes_a_dollar(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A literal $ is doubled, since ccache expands $VAR in its settings."""
from esphome.core import CORE
CORE.build_path = tmp_path / "a$b"
for key in ("CCACHE_DIR", "CCACHE_BASEDIR"):
monkeypatch.delenv(key, raising=False)
env = ccache.ccache_defaults_env(tmp_path / "c$d")
assert env["CCACHE_DIR"].endswith("c$$d")
assert env["CCACHE_BASEDIR"].endswith("a$$b")
def _pch_ccache_env(tmp_path: Path, environ: dict[str, str]) -> dict[str, str]:
CORE.build_path = tmp_path / "build"
spec = ("ESPHOME_TEST_PREFIX", "test")
environ = {"ESPHOME_TEST_PREFIX": str(tmp_path / "cache"), **environ}
with patch.dict(os.environ, environ, clear=True):
return ccache.ccache_env("/usr/bin/ccache", spec)
def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None:
"""A native build exports the ccache settings the pch needs."""
mark_pch_emitted()
env = _pch_ccache_env(tmp_path, {})
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_env_pch_disabled(tmp_path: Path) -> None:
env = _pch_ccache_env(tmp_path, {"ESPHOME_PCH_ENABLE": "0"})
assert "CCACHE_SLOPPINESS" not in env
assert "CCACHE_PCH_EXTSUM" not in env
def test_ccache_env_respects_user_sloppiness(tmp_path: Path) -> None:
mark_pch_emitted()
env = _pch_ccache_env(tmp_path, {"CCACHE_SLOPPINESS": "locale"})
# The user's tokens survive; the ones the pch needs are unioned on
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
@@ -757,3 +757,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
)
mock_build.assert_not_called()
assert data["cached"] is True
def test_warn_if_idedata_missing_is_silent_on_success(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"})
assert "idedata" not in caplog.text
def test_warn_if_idedata_missing_warns_on_none(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
idedata.warn_if_idedata_missing(lambda: None)
assert "No idedata was generated for this build" in caplog.text
@pytest.mark.parametrize(
"error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")]
)
def test_warn_if_idedata_missing_downgrades_failures(
caplog: pytest.LogCaptureFixture, error: Exception
) -> None:
"""The firmware already built; a broken idedata must not fail the build."""
def broken() -> dict | None:
raise error
with caplog.at_level(logging.DEBUG):
idedata.warn_if_idedata_missing(broken)
assert f"Could not generate idedata: {error}" in caplog.text
assert "Idedata failure detail" in caplog.text
@@ -0,0 +1,34 @@
"""Tests for esphome.build_helpers.native."""
from __future__ import annotations
import logging
import pytest
from esphome.build_helpers import native
from esphome.const import Toolchain
from esphome.core import CORE
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
"""Options a native build drops are warned by name; consumed ones stay quiet."""
CORE.toolchain = Toolchain.ARDUINO
CORE.platformio_options = {
"lib_ignore": ["x"],
"board_build.filesystem": "littlefs",
}
with caplog.at_level(logging.WARNING):
native.warn_ignored_platformio_options({"lib_ignore"})
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "lib_ignore" not in caplog.text
def test_warn_ignored_platformio_options_without_options(
caplog: pytest.LogCaptureFixture,
) -> None:
CORE.platformio_options = None
with caplog.at_level(logging.WARNING):
native.warn_ignored_platformio_options(())
assert caplog.text == ""
@@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None:
with patch.object(os, "name", "nt"):
assert ninja_helper.shell_token("a b") == '"a b"'
assert ninja_helper.shell_token("", force=True) == '""'
@pytest.mark.parametrize("char", ["|", "\n", "\r"])
def test_escape_rejects_what_ninja_cannot_express(char: str) -> None:
with pytest.raises(EsphomeError, match="a ninja build file cannot express"):
ninja_helper.escape(f"src/a{char}b.cpp")
def test_escape_keeps_a_hash() -> None:
"""A # is only a comment at the start of a ninja line."""
assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp"
def test_absolute_tool_anchors_a_relative_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja"
def test_find_ninja_returns_an_absolute_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with (
patch("shutil.which", return_value="bin/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja"
@pytest.fixture
def compdb_dir(tmp_path: Path) -> Path:
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "build.ninja").write_text("rule x\n")
return build_dir
def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None:
ninja = Path("ninja")
compdb = compdb_dir / "compile_commands.json"
stamp = compdb_dir / ".compile_commands.stamp"
with patch.object(ninja_helper, "write_compile_commands") as write:
# A rewritten manifest always regenerates
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True)
assert write.call_count == 1
assert stamp.is_file()
# No compile DB yet
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
compdb.write_text("[]")
# Fresh stamp: nothing to do
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
# A manifest newer than the stamp (interrupted previous run)
os.utime(stamp, (1, 1))
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 3
# A missing stamp regenerates too
stamp.unlink()
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 4
def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
return MagicMock(returncode=rc, stdout=stdout, stderr=stderr)
def test_write_compile_commands_success(compdb_dir: Path) -> None:
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
with patch("subprocess.run", return_value=_completed(stdout=entries)) as run:
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"})
assert (compdb_dir / "compile_commands.json").read_text() == entries
# Every compile rule, so preprocessed assembly reaches the database too
assert run.call_args.args[0] == [
"ninja",
"-C",
str(compdb_dir),
"-t",
"compdb",
"c",
"cxx",
"aspp",
"asm",
]
assert run.call_args.kwargs["env"] == {"A": "1"}
@pytest.mark.parametrize(
("result", "message"),
[
(_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"),
# A parse failure names its cause, not the rule-name story
(_completed(stdout="not json"), "unparsable compile database.*not json"),
(_completed(stdout="[]"), "empty compile database"),
],
)
def test_write_compile_commands_failures_drop_stale_db(
compdb_dir: Path, result: MagicMock, message: str
) -> None:
compdb = compdb_dir / "compile_commands.json"
compdb.write_text("[stale]")
with (
patch("subprocess.run", return_value=result),
pytest.raises(EsphomeError, match=message),
):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert not compdb.exists()
def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db(
compdb_dir: Path,
) -> None:
"""The idedata cache is keyed on the DB's mtime."""
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
compdb = compdb_dir / "compile_commands.json"
compdb.write_text(entries)
os.utime(compdb, (1000, 1000))
with patch("subprocess.run", return_value=_completed(stdout=entries)):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert compdb.stat().st_mtime == 1000
@@ -0,0 +1,57 @@
"""Tests for esphome.build_helpers.ninja_gen."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from esphome.build_helpers import ninja_gen
from esphome.build_helpers.ninja_gen import Flag
@pytest.mark.parametrize(
("flag", "anchored"),
[
(("-Iinc",), ("-I{base}/inc",)),
(("-Llib",), ("-L{base}/lib",)),
(("-include", "pre.h"), ("-include", "{base}/pre.h")),
(("-isystem", "sys"), ("-isystem", "{base}/sys")),
# The glued spelling of a flag that takes a path
(("-isystemsys",), ("-isystem{base}/sys",)),
(("-includepre.h",), ("-include{base}/pre.h",)),
# An absolute operand is never changed
(("-I{base}/abs",), ("-I{base}/abs",)),
(("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")),
# Not a path
(("-DUSE_HOST",), ("-DUSE_HOST",)),
(("-lssl",), ("-lssl",)),
(("-framework", "Cocoa"), ("-framework", "Cocoa")),
(("-I",), ("-I",)),
],
)
def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None:
"""Relative operands resolve from the build path, as under PlatformIO."""
def fill(tokens: Flag) -> Flag:
return tuple(
str(Path(tok.replace("{base}", str(tmp_path))))
if "{base}" in tok and not tok.startswith("-")
else tok.replace("{base}/", f"{tmp_path}{os.sep}")
for tok in tokens
)
assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored)
def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None:
for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"):
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [
tmp_path / "a.c",
tmp_path / "b.cpp",
tmp_path / "sub" / "c.S",
]
@@ -0,0 +1,86 @@
"""Tests for the host platform's native-toolchain wiring."""
from __future__ import annotations
import asyncio
from unittest.mock import patch
import pytest
from esphome.components import host
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_HOST,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _core_data() -> None:
CORE.data[KEY_CORE] = {}
def test_schema_resolves_the_host_toolchain() -> None:
"""The host serves exactly one toolchain; it is picked without config."""
config = host.CONFIG_SCHEMA({})
assert CORE.toolchain is Toolchain.HOST
assert CORE.using_native_toolchain
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host"
assert "mac_address" in config
def test_schema_rejects_the_platformio_toolchain() -> None:
"""There is no PlatformIO backend left to honor --toolchain platformio."""
CORE.toolchain = Toolchain.PLATFORMIO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"):
host.CONFIG_SCHEMA({})
def test_to_code_configures_the_native_build() -> None:
config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"})
asyncio.run(host.to_code(config))
assert "-DUSE_HOST" in CORE.build_flags
# The standard travels through cpp_standard, not a -std= build flag,
# so the generator can keep it off the C compile line
assert CORE.cpp_standard == "gnu++20"
assert not any(flag.startswith("-std=") for flag in CORE.build_flags)
assert not CORE.platformio_options
define_names = {define.name for define in CORE.defines}
assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= (
define_names
)
def test_run_compile_hook_claims_the_build() -> None:
config = {"esphome": {}}
with (
patch.object(host.sys, "platform", "linux"),
patch("esphome.host.toolchain.run_compile", return_value=0) as run,
):
assert host.run_compile(object(), config) is True
run.assert_called_once_with(config, CORE.verbose)
def test_run_compile_hook_raises_on_failure() -> None:
with (
patch.object(host.sys, "platform", "linux"),
patch("esphome.host.toolchain.run_compile", return_value=2),
pytest.raises(EsphomeError, match="Host build failed"),
):
host.run_compile(object(), {"esphome": {}})
def test_run_compile_hook_refuses_windows() -> None:
"""The host sources use POSIX interfaces; fail before building, by name."""
with (
patch.object(host.sys, "platform", "win32"),
patch("esphome.host.toolchain.run_compile") as run,
pytest.raises(EsphomeError, match="does not build on Windows"),
):
host.run_compile(object(), {"esphome": {}})
run.assert_not_called()
@@ -1236,3 +1236,61 @@ async def test_finalize_comment_sanitization(
# Newline must be replaced to prevent breaking out of comment
assert "\n" not in comment_line
assert "INJECTED_CODE" in comment_line # still visible but safe in comment
@pytest.mark.asyncio
async def test_sub_entities_creates_and_sets(
monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str]
) -> None:
"""A configured key creates the entity, sets the parent before the setter and returns it."""
calls: list[tuple[Any, ...]] = []
created = MockObj("child")
async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj:
calls.append((conf, args, kwargs))
return created
async def fake_register_parented(var: MockObj, parent: MockObj) -> None:
setup_test_environment.append(f"parent {var} {parent}")
monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented)
hub = MockObj("hub", "->")
sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub)
assert await sub("child", hub.set_child, 1, step=2) is created
assert calls == [({}, (1,), {"step": 2})]
assert setup_test_environment == ["parent child hub", "hub->set_child(child)"]
@pytest.mark.asyncio
async def test_sub_entities_missing_key(setup_test_environment: list[str]) -> None:
"""A missing key creates nothing and emits no code."""
async def new_entity(conf: dict[str, Any]) -> MockObj:
raise AssertionError("must not be called")
hub = MockObj("hub", "->")
sub = entity_helpers.SubEntities(new_entity, {})
assert await sub("child", hub.set_child) is None
assert setup_test_environment == []
@pytest.mark.asyncio
async def test_sub_entities_without_parent(
monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str]
) -> None:
"""Without a bound parent, register_parented is not called."""
async def fail_register_parented(var: MockObj, parent: MockObj) -> None:
raise AssertionError("must not be called")
async def new_entity(conf: dict[str, Any]) -> MockObj:
return MockObj("child")
monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented)
hub = MockObj("hub", "->")
sub = entity_helpers.SubEntities(new_entity, {"child": {CONF_ID: "child"}})
assert await sub("child", hub.set_child) is not None
assert setup_test_environment == ["hub->set_child(child)"]
+11 -20
View File
@@ -112,17 +112,6 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
assert env["CCACHE_DIR"] == "x"
def test_ccache_env(tmp_path: Path) -> None:
assert framework.ccache_env(None) == {}
with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
# User-set values are respected; the rest get defaults
assert "CCACHE_NOHASHDIR" not in env
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
assert env["CCACHE_DIR"].endswith("ccache")
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
"""Calling the installer below the floor fails before any download."""
with pytest.raises(EsphomeError, match=">= 3.1.1"):
@@ -148,18 +137,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None:
assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"]
def test_ccache_env_accepts_a_preresolved_path() -> None:
"""The caller resolves ccache once and threads it through; None means
resolved-and-disabled."""
with patch.dict(os.environ, {}, clear=True):
assert framework.ccache_env(None) == {}
env = framework.ccache_env("/usr/bin/ccache")
assert env["CCACHE_DIR"].endswith("ccache")
def test_toolchain_tool_layout(tmp_path: Path) -> None:
"""One owner for the bin/xtensa-lx106-elf-<name> layout."""
tool = framework.toolchain_tool(tmp_path, "addr2line")
assert tool.parent == tmp_path / "bin"
assert tool.name.startswith("xtensa-lx106-elf-addr2line")
assert (tool.suffix == ".exe") is (os.name == "nt")
def test_get_build_env_uses_the_arduino8266_ccache_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache"))
monkeypatch.delenv("CCACHE_DIR", raising=False)
env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache")
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
# None means resolved and disabled
assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None)
+54 -176
View File
@@ -10,13 +10,13 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.arduino8266 import framework, toolchain
from esphome.build_helpers.pch import mark_pch_emitted
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@@ -81,7 +81,7 @@ def test_run_compile_build_failure(tmp_path: Path) -> None:
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
cmd = mock_run.call_args[0][0]
@@ -101,7 +101,7 @@ def test_run_compile_success(tmp_path: Path) -> None:
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
) as mock_run,
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata") as mock_idedata,
):
@@ -117,39 +117,12 @@ def test_run_compile_success(tmp_path: Path) -> None:
# cwd, not -C, so ninja prints no "Entering directory" banner
assert "-C" not in cmd
assert call[1]["cwd"] is not None
mock_compdb.assert_called_once()
# An unchanged manifest is passed on, so the shared refresh can skip
assert mock_compdb.call_args.args[3] is False
mock_size.assert_called_once()
mock_idedata.assert_called_once()
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
"""An interrupted run can leave build.ninja newer than the compile DB;
mere existence must not skip regeneration."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("")
compdb = build_dir / "compile_commands.json"
compdb.write_text("[]")
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(
returncode=0, stdout="ninja: no work to do.\n", stderr=""
),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_compdb.assert_called_once()
def test_run_compile_missing_artifact_fails(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -165,7 +138,7 @@ def test_run_compile_missing_artifact_fails(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary") as mock_size,
patch.object(toolchain, "get_idedata"),
):
@@ -189,7 +162,7 @@ def test_run_compile_warns_when_idedata_fails(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata", return_value=None),
):
@@ -197,63 +170,6 @@ def test_run_compile_warns_when_idedata_fails(
assert "No idedata was generated for this build" in caplog.text
def test_write_compile_commands(tmp_path: Path) -> None:
build_dir = tmp_path / "build"
build_dir.mkdir()
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
with patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=entries),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert (build_dir / "compile_commands.json").read_text() == entries
@pytest.mark.parametrize(
("stdout", "match"),
[
("[]\n", "empty compile database"),
# A parse failure names its cause, not the rule-name story
("not json", "unparsable compile database.*not json"),
],
)
def test_write_compile_commands_bad_db_raises(
tmp_path: Path, stdout: str, match: str
) -> None:
"""An empty or unparsable compile database fails the build with its
actual cause and drops any stale database."""
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "compile_commands.json").write_text("[stale]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout=stdout),
),
pytest.raises(EsphomeError, match=match),
):
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
assert not (build_dir / "compile_commands.json").exists()
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
"""A failed compdb run must not leave a stale database behind."""
stale = tmp_path / "compile_commands.json"
stale.write_text("[]")
with (
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=1, stderr="boom"),
),
pytest.raises(EsphomeError, match="compile_commands"),
):
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
assert not stale.exists()
def test_parse_app_size(tmp_path: Path) -> None:
ld = tmp_path / "eagle.flash.4m.ld"
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
@@ -343,7 +259,9 @@ def test_get_idedata_delegates(tmp_path: Path) -> None:
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"cc_path": "x"},
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
patch.object(
toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache"
),
):
assert toolchain.get_idedata() == {"cc_path": "x"}
compile_commands, elf, cache = mock_load.call_args[0]
@@ -360,43 +278,12 @@ def test_get_idedata_no_ccache(tmp_path: Path) -> None:
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
) as mock_load,
patch.object(toolchain, "resolve_ccache_path", return_value=None),
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
):
toolchain.get_idedata()
assert mock_load.call_args.kwargs["launcher"] is None
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
"""An unchanged build.ninja means the compile DB is already current."""
build_dir = toolchain.get_build_dir()
build_dir.mkdir(parents=True, exist_ok=True)
# write_project (stubbed below) always leaves a build.ninja behind
(build_dir / "build.ninja").write_text("# manifest")
def run(regenerate_expected: bool) -> None:
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert mock_compdb.called == regenerate_expected
# Missing compile DB: regenerated even though build.ninja is unchanged
run(regenerate_expected=True)
# Present compile DB + unchanged build.ninja: skipped
(build_dir / "compile_commands.json").write_text("[]")
run(regenerate_expected=False)
def test_print_size_summary_unparsable_section(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
@@ -448,32 +335,6 @@ def test_print_size_summary_missing_section_skips_summary(
assert "missing section(s) .bss" in caplog.text
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
"""Component-added options the native build drops are warned by name;
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
flash_mode) stay quiet."""
CORE.platformio_options = {
"board_build.ldscript": "eagle.flash.4m2m.ld",
"board_build.f_cpu": "160000000L",
"board_build.filesystem": "littlefs",
"board_build.flash_mode": "dio",
"build_src_flags": "-include throw_stubs.h",
"lib_ignore": ["Updater"],
"upload_speed": "460800",
}
toolchain._warn_ignored_platformio_options()
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_build.f_cpu is ignored" not in caplog.text
assert "lib_ignore" not in caplog.text
assert "build_src_flags" not in caplog.text
assert "flash_mode" not in caplog.text
# Component-added upload_speed never gets read under the native
# toolchain, so it must warn
assert "upload_speed" in caplog.text
def test_run_compile_idedata_error_does_not_fail_build(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -487,7 +348,7 @@ def test_run_compile_idedata_error_does_not_fail_build(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary"),
patch.object(
toolchain,
@@ -513,7 +374,7 @@ def test_run_compile_skipped_size_summary_names_consequence(
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "_write_compile_commands"),
patch.object(toolchain, "refresh_compile_commands"),
patch.object(toolchain, "_print_size_summary", return_value=False),
patch.object(toolchain, "get_idedata", return_value=None),
):
@@ -542,33 +403,50 @@ def test_get_idedata_accepts_preresolved_ccache() -> None:
"esphome.build_helpers.idedata.load_or_build_idedata",
return_value={"ok": True},
) as mock_build,
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve,
):
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
mock_resolve.assert_not_called()
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
def test_ccache_env_includes_pch_settings() -> None:
"""The native build exports the ccache settings the pch needs."""
mark_pch_emitted()
with patch.dict(os.environ, {}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_ccache_env_pch_disabled() -> None:
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
assert "CCACHE_SLOPPINESS" not in env
assert "CCACHE_PCH_EXTSUM" not in env
def test_ccache_env_respects_user_sloppiness() -> None:
mark_pch_emitted()
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
env = framework.ccache_env("/usr/bin/ccache")
# The user's tokens survive; the ones the pch needs are unioned on
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
def test_run_compile_warns_about_dropped_platformio_options(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Component-added options the native build drops are warned by name;
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
flash_mode) stay quiet."""
CORE.toolchain = Toolchain.ARDUINO
CORE.platformio_options = {
"board_build.ldscript": "eagle.flash.4m2m.ld",
"board_build.f_cpu": "160000000L",
"board_build.filesystem": "littlefs",
"board_build.flash_mode": "dio",
"build_src_flags": "-include throw_stubs.h",
"lib_ignore": ["Updater"],
"upload_speed": "460800",
}
with (
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
patch.object(framework, "get_build_env", return_value={}),
patch("esphome.build_gen.arduino8266.write_project", return_value=True),
patch.object(
toolchain.subprocess,
"run",
return_value=MagicMock(returncode=0, stdout="", stderr=""),
),
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
patch.object(toolchain, "_print_size_summary"),
patch.object(toolchain, "get_idedata"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
assert "platformio_options->upload_speed is ignored" in caplog.text
assert "native 'arduino' toolchain" in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_build.f_cpu is ignored" not in caplog.text
assert "lib_ignore" not in caplog.text
assert "build_src_flags" not in caplog.text
assert "flash_mode" not in caplog.text
# A rewritten manifest is passed on, so the compile DB is regenerated
assert mock_compdb.call_args.args[3] is True
+26
View File
@@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet(
libs = _resolve(framework)
assert "Wire" in [lib.name for lib in libs]
assert "has no version to resolve" not in caplog.text
def test_resolve_libraries_without_framework_is_all_external() -> None:
"""The host build has no core tree: nothing is bundled, the framework
check is skipped, and every name reaches the converter."""
_add_library("Wire", None)
_add_library("lvgl/lvgl", "9.5.0")
def fake_convert(libraries: list, backend: LibraryBackend) -> list:
assert backend.platform == "native"
assert backend.framework is None
assert backend.cache_key == "host"
assert backend.provides("Wire") is False
return []
with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock:
libs = component.resolve_libraries(
None,
pio_platform="native",
board_mcu="host",
cache_key="host",
framework=None,
)
assert libs == []
(libraries, _backend), _ = mock.call_args
assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"]
+47 -25
View File
@@ -509,6 +509,8 @@ class MockCodegen(NamedTuple):
get_variable: AsyncMock
new_pvariable: MagicMock
register_parented: AsyncMock
add_global: MagicMock
calls: MagicMock # new_pvariable and add_global attached, to check their order
@pytest.fixture
@@ -520,10 +522,16 @@ def mock_cg() -> Generator[MockCodegen]:
patch(
"esphome.codegen.register_parented", new_callable=AsyncMock
) as register_parented,
patch("esphome.cpp_generator.add_global") as add_global,
):
get_variable.return_value = PARENT_OBJ
new_pvariable.return_value = NEW_OBJ
yield MockCodegen(get_variable, new_pvariable, register_parented)
calls = MagicMock()
calls.attach_mock(new_pvariable, "new_pvariable")
calls.attach_mock(add_global, "add_global")
yield MockCodegen(
get_variable, new_pvariable, register_parented, add_global, calls
)
@pytest.fixture
@@ -649,8 +657,8 @@ async def _run_apply_condition(
return await _run_entry(conditions["my.check"], config, args, platform, id_key)
def _apply_lambda(mock_cg: MockCodegen) -> str:
return str(mock_cg.new_pvariable.call_args.args[2])
def _apply_definition(mock_cg: MockCodegen) -> str:
return str(mock_cg.add_global.call_args.args[0])
@pytest.mark.asyncio
@@ -661,9 +669,14 @@ async def test_register_apply_action_entry(
assert entry.type_id is ApplyAction
assert entry.synchronous is True
mock_cg.get_variable.assert_awaited_once_with(PARENT_ID)
action_id, template_arg, _ = mock_cg.new_pvariable.call_args.args
action_id, template_arg = mock_cg.new_pvariable.call_args.args
assert action_id == ID("obj_1")
assert str(template_arg) == "<int32_t>"
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
# The definition must precede the storage line that names the function.
assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"]
assert _apply_definition(mock_cg).startswith(
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
)
@pytest.mark.asyncio
@@ -692,7 +705,7 @@ async def test_apply_constants(
)
config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5}
await _run_apply_action(registries, fields, config)
text = _apply_lambda(mock_cg)
text = _apply_definition(mock_cg)
lines = [
f"::{PARENT_OBJ}->set_kp(0.0f);",
f"::{PARENT_OBJ}->set_on(false);",
@@ -715,7 +728,9 @@ async def test_apply_id_constant_is_the_named_object(
fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),)
await _run_apply_action(registries, fields, {"target": ID("speaker_b")})
mock_cg.get_variable.assert_any_await(ID("speaker_b"))
assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in _apply_lambda(mock_cg)
assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in str(
_apply_definition(mock_cg)
)
@pytest.mark.asyncio
@@ -728,7 +743,9 @@ async def test_apply_condition_id_constant_is_the_named_object(
check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),))
await _run_apply_condition(registries, check, {"target": ID("speaker_b")})
mock_cg.get_variable.assert_any_await(ID("speaker_b"))
assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in _apply_lambda(mock_cg)
assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in str(
_apply_definition(mock_cg)
)
@pytest.mark.asyncio
@@ -745,8 +762,10 @@ async def test_apply_lambdas(
"ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"),
}
await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")])
text = _apply_lambda(mock_cg)
assert text.startswith("[](const std::remove_cvref_t<int32_t> & x) -> void {")
text = _apply_definition(mock_cg)
assert text.startswith(
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
)
# The parent is global-scope qualified, so an arg named like the id cannot shadow it.
assert f"::{PARENT_OBJ}->set_kp(" in text
assert f"::{PARENT_OBJ}->set_kp(static_cast<float>(x * 2));" in text
@@ -768,11 +787,11 @@ async def test_apply_call_keys(
ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))),
)
await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0})
assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_lambda(mock_cg)
assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_definition(mock_cg)
mock_cg.new_pvariable.reset_mock()
await _run_apply_action(registries, fields, {})
assert "set_range" not in _apply_lambda(mock_cg)
assert "set_range" not in _apply_definition(mock_cg)
with pytest.raises(EsphomeError, match="needs all of"):
await _run_apply_action(registries, fields, {"low": 1.0})
@@ -784,7 +803,7 @@ async def test_apply_action_call_shape(
) -> None:
fields = (ApplyField("brightness", "set_brightness", cg.float_),)
await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call")
text = _apply_lambda(mock_cg)
text = _apply_definition(mock_cg)
lines = [
f"auto apply_call = ::{PARENT_OBJ}->make_call();",
"apply_call.set_brightness(0.5f);",
@@ -814,7 +833,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string(
"value": Lambda("return 42;"),
}
await _run_apply_action(registries, fields, config)
text = _apply_lambda(mock_cg)
text = _apply_definition(mock_cg)
assert f"::{PARENT_OBJ}->set_direction(3);" in text
assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text
assert (
@@ -824,7 +843,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string(
mock_cg.new_pvariable.reset_mock()
await _run_apply_action(registries, fields[:1], {})
assert "set_direction" not in _apply_lambda(mock_cg)
assert "set_direction" not in _apply_definition(mock_cg)
def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None:
@@ -883,8 +902,9 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266(
) -> None:
fields = (ApplyField("song", "play", cg.std_string),)
await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266")
assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda(
mock_cg
assert (
f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));'
in _apply_definition(mock_cg)
)
@@ -900,7 +920,7 @@ async def test_apply_literal_with_length_is_plain_on_every_platform(
await _run_apply_action(
registries, fields, {"option": "h\u00e9llo"}, platform=platform
)
text = _apply_lambda(mock_cg)
text = _apply_definition(mock_cg)
assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text
assert "progmem_string" not in text
@@ -913,11 +933,13 @@ async def test_register_apply_condition_predicate(
registries, "is_playing()", {}, args=[(cg.int32, "x")]
)
assert entry.type_id is ApplyCondition
condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args
condition_id, template_arg = mock_cg.new_pvariable.call_args.args
assert condition_id == ID("obj_1")
assert str(template_arg) == "<int32_t>"
text = str(check)
assert text.startswith("[](const std::remove_cvref_t<int32_t> & x) -> bool {")
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
text = _apply_definition(mock_cg)
assert text.startswith(
"static bool esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
)
assert f"return ::{PARENT_OBJ}->is_playing();" in text
@@ -927,7 +949,7 @@ async def test_apply_condition_compares_config_value(
) -> None:
check = ApplyCall("state == {}", (("state", cg.bool_),))
await _run_apply_condition(registries, check, {"state": True})
assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg)
assert f"return ::{PARENT_OBJ}->state == true;" in _apply_definition(mock_cg)
with pytest.raises(EsphomeError, match="needs all of"):
await _run_apply_condition(registries, check, {})
@@ -940,7 +962,7 @@ async def test_apply_condition_string_constant_is_a_plain_literal(
) -> None:
check = ApplyCall("state == {}", (("state", cg.std_string),))
await _run_apply_condition(registries, check, {"state": "two"}, platform=platform)
assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg)
assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg)
@pytest.mark.asyncio
@@ -964,6 +986,6 @@ async def test_apply_condition_string_lambda_paths(
await _run_apply_condition(
registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")]
)
text = _apply_lambda(mock_cg)
text = _apply_definition(mock_cg)
assert expected in text
assert ("-> std::string {" in text) is called
+8
View File
@@ -976,6 +976,14 @@ class TestEsphomeCore:
target.toolchain = const.Toolchain.SDK_NRF
assert target.using_native_toolchain is False
def test_using_toolchain_host(self, target):
"""The host's only toolchain: the system compiler driven by ninja."""
target.toolchain = const.Toolchain.HOST
assert target.using_toolchain_host is True
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_toolchain_host is False
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
+34
View File
@@ -50,3 +50,37 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path)
assert all(
{"title", "description", "file", "download"} <= entry.keys() for entry in types
)
def _nrf52_files(tmp_path: Path, *built: str) -> list[str]:
"""The files nrf52 offers for a build directory holding *built*."""
(tmp_path / "zephyr").mkdir()
for name in built:
(tmp_path / name).touch()
storage = _wizard_storage()
storage.firmware_bin_path = tmp_path / "firmware.bin"
return [entry["file"] for entry in _download_types("nrf52", storage)]
@pytest.mark.parametrize(
("built", "expected"),
[
# Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it.
(
["zephyr/zephyr.uf2", "zephyr/app_update.bin"],
["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"],
),
(["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]),
(
["zephyr/merged.hex", "zephyr/app_update.bin"],
["zephyr/merged.hex", "zephyr/app_update.bin"],
),
(["zephyr/merged.hex"], ["zephyr/merged.hex"]),
([], ["zephyr/zephyr.hex"]),
],
)
def test_nrf52_offers_the_mcumgr_image_whenever_it_was_built(
tmp_path: Path, built: list[str], expected: list[str]
) -> None:
"""The mcumgr update image is offered beside a UF2 as well as beside a HEX."""
assert _nrf52_files(tmp_path, *built) == expected
+329
View File
@@ -0,0 +1,329 @@
"""Tests for esphome.host.toolchain (the native host build driver)."""
from __future__ import annotations
from collections.abc import Generator
import logging
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_HOST,
Toolchain,
)
from esphome.core import CORE, EsphomeError
from esphome.host import toolchain
from esphome.host.toolchain import PROGRAM_NAME
@pytest.fixture(autouse=True)
def _core(tmp_path: Path) -> None:
CORE.config_path = tmp_path / "dev.yaml"
CORE.build_path = tmp_path
CORE.name = "dev"
CORE.toolchain = Toolchain.HOST
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
def _abs(path: str) -> str:
"""What the toolchain makes of a tool path (a drive is added on Windows)."""
return str(Path(path).absolute())
def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CXX", "/opt/clang++")
with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get):
assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),)
def test_find_command_keeps_the_override_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``CC="gcc -m32"`` is a command, as make and CMake read it."""
monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'")
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
"-m32",
"--sysroot=/opt/my sdk",
)
@pytest.mark.parametrize(
("override", "expected_args"),
[("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))],
)
def test_find_command_drops_a_ccache_prefix(
monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...]
) -> None:
"""The build adds ccache itself; the compiler is the word after it."""
monkeypatch.setenv("CC", override)
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
assert toolchain.find_command("CC", ("cc",)) == (
_abs("/usr/bin/gcc"),
*expected_args,
)
def test_find_command_accepts_a_compiler_named_ccache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Alone, the word is the program: ccache's compiler links work that way."""
monkeypatch.setenv("CC", "ccache")
with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get):
assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),)
def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""A broken override fails by name instead of silently using another compiler."""
monkeypatch.setenv("CXX", "nope++")
with (
patch("shutil.which", return_value=None),
pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"),
):
toolchain.find_command("CXX", ("g++",))
def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CC", " ")
with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (
_abs("/usr/bin/clang"),
)
def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),)
def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CC", raising=False)
with (
patch("shutil.which", return_value=None),
pytest.raises(
EsphomeError,
match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC",
),
):
toolchain.find_command("CC", ("gcc", "clang"))
def test_find_command_makes_a_relative_hit_absolute(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CC", "./toolchain/gcc")
with patch("shutil.which", return_value="./toolchain/gcc"):
(found,) = toolchain.find_command("CC", ("gcc",))
assert Path(found) == Path("toolchain/gcc").absolute()
def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AR", "ar --plugin x")
with (
patch("shutil.which", return_value="/usr/bin/ar"),
pytest.raises(EsphomeError, match="AR must name a program without arguments"),
):
toolchain.find_tool("AR", ("ar",))
def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("CC", "CXX"):
monkeypatch.delenv(var, raising=False)
table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.find_compilers() == toolchain.HostCompilers(
cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),)
)
def test_build_paths(tmp_path: Path) -> None:
"""The PlatformIO layout is kept: CORE.firmware_bin resolves the same file."""
assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev"
assert toolchain.get_elf_path() == CORE.firmware_bin
def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("OBJDUMP", "READELF"):
monkeypatch.delenv(var, raising=False)
table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"}
with patch("shutil.which", side_effect=table.get):
assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump"))
assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf"))
def test_get_build_env_merges_without_leaking(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache"))
monkeypatch.delenv("CCACHE_DIR", raising=False)
monkeypatch.setenv("KEEP_ME", "1")
env = toolchain.get_build_env("/usr/bin/ccache")
assert env["KEEP_ME"] == "1"
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
assert "CCACHE_DIR" not in os.environ
def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CCACHE_DIR", raising=False)
assert "CCACHE_DIR" not in toolchain.get_build_env(None)
@pytest.fixture
def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]:
"""Stub everything run_compile resolves; the build dir holds a manifest."""
build_dir = tmp_path / ".pioenvs" / "dev"
build_dir.mkdir(parents=True)
(build_dir / "build.ninja").write_text("rule x\n")
compilers = toolchain.HostCompilers(("gcc",), ("g++",))
with (
patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")),
patch.object(toolchain, "find_compilers", return_value=compilers),
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
patch("esphome.build_gen.host.write_project", return_value=True) as project,
patch.object(toolchain, "refresh_compile_commands") as refresh,
patch.object(
toolchain, "_load_idedata", return_value={"cc_path": "gcc"}
) as ide,
patch("subprocess.run") as run,
):
yield SimpleNamespace(
build_dir=build_dir,
compilers=compilers,
write_project=project,
refresh=refresh,
idedata=ide,
run=run,
)
def _completed(rc: int = 0) -> SimpleNamespace:
return SimpleNamespace(returncode=rc, stdout="", stderr="")
def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None:
elf = compile_env.build_dir / PROGRAM_NAME
def build(cmd: list[str], **kwargs: object) -> SimpleNamespace:
elf.write_text("")
return _completed()
compile_env.run.side_effect = build
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
assert toolchain.run_compile(config, verbose=True) == 0
compile_env.write_project.assert_called_once_with(compile_env.compilers, None)
compile_env.refresh.assert_called_once()
assert compile_env.refresh.call_args.args[3] is True
compile_env.run.assert_called_once()
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
"-v",
"-j",
"4",
PROGRAM_NAME,
]
assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir
compile_env.idedata.assert_called_once_with(None)
def test_run_compile_defaults(compile_env: SimpleNamespace) -> None:
"""No verbosity and no process limit: just the ninja target."""
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert compile_env.run.call_args.args[0] == [
str(Path("/usr/bin/ninja")),
PROGRAM_NAME,
]
# The build inherits stdio and never captures: progress must stream
kwargs = compile_env.run.call_args.kwargs
assert kwargs["check"] is False
assert kwargs["close_fds"] is False
assert "capture_output" not in kwargs
def test_run_compile_warns_about_dropped_platformio_options(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"}
with caplog.at_level(logging.WARNING):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert "platformio_options->board_build.f_cpu is ignored" in caplog.text
assert "native 'host' toolchain" in caplog.text
assert "lib_ignore" not in caplog.text
def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None:
(compile_env.build_dir / PROGRAM_NAME).write_text("")
compile_env.run.return_value = _completed()
with patch.object(
toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache"
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
compile_env.write_project.assert_called_once_with(
compile_env.compilers, "/usr/bin/ccache"
)
compile_env.idedata.assert_called_once_with("/usr/bin/ccache")
def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None:
compile_env.run.return_value = _completed(rc=3)
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3
compile_env.idedata.assert_not_called()
def test_run_compile_missing_program_fails(
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
) -> None:
"""A green ninja run that produced no program fails by name."""
compile_env.run.return_value = _completed()
with caplog.at_level(logging.ERROR):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1
assert "Build produced no" in caplog.text
compile_env.idedata.assert_not_called()
@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None])
def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None:
with (
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache),
patch(
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1}
) as load,
):
assert toolchain.get_idedata() == {"x": 1}
load.assert_called_once_with(
tmp_path / ".pioenvs" / "dev" / "compile_commands.json",
tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME,
CORE.relative_internal_path("idedata", "dev.json"),
launcher=ccache,
)
@pytest.mark.parametrize("platform", ["darwin", "win32"])
def test_check_analysis_supported_refuses_non_elf(platform: str) -> None:
with (
patch.object(toolchain.sys, "platform", platform),
pytest.raises(EsphomeError, match=f"the host build on {platform}"),
):
toolchain.check_analysis_supported()
def test_check_analysis_supported_accepts_linux() -> None:
with patch.object(toolchain.sys, "platform", "linux"):
toolchain.check_analysis_supported()
+191 -30
View File
@@ -7489,6 +7489,8 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
[
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
# No native build backend, but its binutils and ELF are known
(PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"),
],
)
def test_command_analyze_memory_native_toolchains(
@@ -7708,7 +7710,12 @@ def test_command_analyze_memory_unsupported_toolchain(
mock_write_cpp.return_value = 0
mock_compile_program.return_value = 0
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
# Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for
# one that does not
with patch.dict(
"esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True
):
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
assert result == 1
assert "analyze-memory is not supported" in caplog.text
@@ -7798,52 +7805,206 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
assert second.index("a: 2") < second.index("z: 1")
def test_host_program_path_platformio_toolchain() -> None:
"""Host + PlatformIO toolchain reads the memoized idedata path."""
setup_core(platform=PLATFORM_HOST)
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
with patch(
"esphome.platformio.toolchain.get_idedata", return_value=idedata
) as mock_get:
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
mock_get.assert_called_once_with({})
def test_host_program_path_esp_idf_toolchain() -> None:
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.ESP_IDF
with patch(
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
):
assert main._host_program_path({}) == str(Path("/b/app.elf"))
def test_command_compile_host_logs_program_path(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""command_compile on host logs the compiled program path."""
setup_core(platform=PLATFORM_HOST)
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
caplog.at_level(logging.INFO),
):
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
assert "Successfully compiled program to path '/b/program'" in caplog.text
assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
def test_command_run_host_executes_program(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""command_run on host logs and executes the compiled program directly."""
setup_core(platform=PLATFORM_HOST)
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
with (
patch.object(main, "write_cpp", return_value=0),
patch.object(main, "compile_program", return_value=0),
patch.object(main, "_host_program_path", return_value="/b/program"),
patch.object(main, "run_external_process", return_value=0) as mock_run,
caplog.at_level(logging.INFO),
):
assert main.command_run(SimpleNamespace(), {}) == 0
mock_run.assert_called_with("/b/program")
assert "Running program from path '/b/program'" in caplog.text
program = str(CORE.firmware_bin)
mock_run.assert_called_with(program)
assert f"Running program from path '{program}'" in caplog.text
def test_write_cpp_file_project_generation_follows_host_toolchain() -> None:
"""Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project
here, and the other native builds generate theirs at compile time."""
setup_core(platform=PLATFORM_HOST)
with (
patch("esphome.writer.write_cpp"),
patch("esphome.build_gen.platformio.write_project") as mock_pio,
patch("esphome.build_gen.espidf.write_project") as mock_idf,
):
CORE.toolchain = Toolchain.HOST
assert main.write_cpp_file() == 0
mock_pio.assert_not_called()
mock_idf.assert_not_called()
CORE.toolchain = Toolchain.PLATFORMIO
assert main.write_cpp_file() == 0
mock_pio.assert_called_once()
CORE.toolchain = Toolchain.ESP_IDF
assert main.write_cpp_file() == 0
mock_idf.assert_called_once()
def test_compile_program_host_uses_the_platform_hook() -> None:
"""The host component's run_compile claims the build."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.components.host.run_compile", return_value=True) as hook,
patch("esphome.__main__._check_and_emit_build_info") as build_info,
):
assert compile_program(MagicMock(), {}) == 0
hook.assert_called_once()
build_info.assert_called_once()
def test_compile_program_native_toolchain_needs_a_backend() -> None:
"""A native toolchain no hook claims must not fall through to PlatformIO."""
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.platformio.toolchain.run_compile") as mock_pio,
# A platform package without a run_compile hook
patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}),
pytest.raises(EsphomeError, match="no platform backend claimed the build"),
):
compile_program(MagicMock(), {})
mock_pio.assert_not_called()
def test_native_backend_resolves_host() -> None:
from esphome.build_helpers.native import native_backend
from esphome.host import toolchain as host_toolchain
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.PLATFORMIO
assert native_backend() is None
CORE.toolchain = Toolchain.HOST
assert native_backend() is host_toolchain
# A native toolchain without a backend for the platform must not degrade
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(
EsphomeError, match="has no native build backend module for platform host"
):
native_backend()
def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None:
setup_core(platform=PLATFORM_HOST)
CORE.toolchain = Toolchain.HOST
data = {"cxx_path": "g++", "prog_path": "/build/program"}
with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get:
assert command_idedata(MagicMock(), CORE.config) == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]:
objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program"))
for tool in (objdump, readelf, elf):
tool.write_text("")
return objdump, readelf, elf
def test_command_analyze_memory_native_toolchain(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
mock_ram_strings_analyzer: Mock,
) -> None:
"""A native backend supplies its own binutils and ELF, with no idedata."""
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
config = {CONF_ESPHOME: {CONF_NAME: "dev"}}
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
):
assert command_analyze_memory(MockArgs(), config) == 0
mock_memory_analyzer_cli.assert_called_once_with(
str(elf), str(objdump), str(readelf), set(), idedata=None
)
mock_ram_strings_analyzer.assert_called_once_with(
str(elf), objdump_path=str(objdump), platform="host"
)
assert "Mock Memory Report" in capfd.readouterr().out
def test_command_analyze_memory_native_missing_tool_fails(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
readelf.unlink()
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
caplog.at_level(logging.ERROR),
):
assert command_analyze_memory(MockArgs(), {}) == 1
assert f"{readelf} is missing; the toolchain install may be incomplete" in (
caplog.text
)
def test_command_analyze_memory_host_missing_elf_fails(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
objdump, readelf, elf = _native_host_tools(tmp_path)
elf.unlink()
with (
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
patch("esphome.host.toolchain.check_analysis_supported"),
caplog.at_level(logging.ERROR),
):
assert command_analyze_memory(MockArgs(), {}) == 1
assert f"{elf} is missing; compile the configuration first" in caplog.text
def test_command_analyze_memory_host_refuses_before_compiling(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
"""A machine whose host program is not ELF fails before the compile."""
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
CORE.toolchain = Toolchain.HOST
with (
patch("esphome.host.toolchain.sys.platform", "darwin"),
pytest.raises(EsphomeError, match="analyze-memory reads ELF files"),
):
command_analyze_memory(MockArgs(), {})
mock_write_cpp.assert_not_called()
mock_compile_program.assert_not_called()
+23
View File
@@ -0,0 +1,23 @@
"""Tests for the nRF52 native west build command."""
from pathlib import Path
from esphome.components.nrf52 import _west_build_command
def test_west_build_sets_the_cmake_build_type() -> None:
"""The picolibc module used to force MinSizeRel, and with it the -DNDEBUG
that keeps libc assert() out of the image; the build sets it itself now."""
python = Path("/penv/python")
cmd = _west_build_command(
python,
"adafruit_feather_nrf52840",
Path("/build/pio"),
Path("/build/zephyr"),
)
# str(), not a literal: the separator differs on Windows
assert cmd[:4] == [str(python), "-m", "west", "build"]
assert "-b" in cmd and "adafruit_feather_nrf52840" in cmd
# CMake arguments come after west's own
assert cmd[cmd.index("--") + 1 :] == ["-DCMAKE_BUILD_TYPE=MinSizeRel"]
+516 -17
View File
@@ -1,11 +1,12 @@
"""Tests for esphome.components.nrf52.framework helpers."""
import errno
import hashlib
import os
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import ANY, call, patch
import platformdirs
import pytest
@@ -14,16 +15,20 @@ from esphome.components.nrf52 import _resolve_toolchain
from esphome.components.nrf52.framework import (
_PLATFORMIO_PENV_REQUIREMENTS,
_REQUIREMENTS,
DEFAULT_WEST_PROJECTS,
TOOLCHAIN_VERSION,
_get_penv_site_packages,
_get_platformio_penv_path,
_get_toolchain_platform_info,
_needs_venv_rebuild,
_wanted_west_projects,
check_and_install,
get_build_env,
get_sdk_nrf_tools_path,
include_west_project,
setup_platformio_python_env,
)
from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR
import esphome.config_validation as cv
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
@@ -76,6 +81,8 @@ def test_get_toolchain_platform_info(
# ---------------------------------------------------------------------------
_TEST_SDK_VERSION = "2.9.0"
# The filter that keeps only DEFAULT_WEST_PROJECTS
_DEFAULTS_FILTER = "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr"
@pytest.fixture
@@ -129,6 +136,12 @@ def mock_nrf52_ops():
# ---------------------------------------------------------------------------
def _mark_west_initialized(framework: Path) -> None:
"""What a finished ``west init`` leaves behind."""
(framework / ".west").mkdir()
(framework / ".west" / "config").touch()
def _touch_penv_python(penv: Path) -> None:
"""Create the interpreter file so the rebuild gate sees a live venv."""
python = get_python_env_executable_path(penv, "python")
@@ -136,6 +149,33 @@ def _touch_penv_python(penv: Path) -> None:
python.touch()
def _subcommand(cmd: list[str]) -> str:
tool = "west" if "west" in cmd else "pip"
return cmd[cmd.index(tool) + 1]
def _subcommands(run_command_ok) -> list[str]:
"""The west or pip subcommand of each command run, in order."""
return [_subcommand(c.args[0]) for c in run_command_ok.call_args_list]
def _project_filter(run_command_ok) -> str:
"""The manifest.project-filter value of the last ``west config`` run."""
for west_call in reversed(run_command_ok.call_args_list):
cmd = west_call.args[0]
if "manifest.project-filter" in cmd:
return cmd[-1]
raise AssertionError("no west config command ran")
def _mark_installed(dirs: SimpleNamespace) -> None:
"""Every install step finished: venv, zephyr requirements, SDK, toolchain."""
_mark_venv_ready(dirs.python_env)
(dirs.python_env / ".zephyr_reqs_ready").touch()
(dirs.framework / ".ready").touch()
(dirs.toolchain / ".ready").touch()
def _mark_venv_ready(python_env: Path) -> None:
"""Write the venv sentinel with the current requirements hash and a
present interpreter so the rebuild gate passes."""
@@ -151,10 +191,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""All three sentinels present → nothing downloaded or compiled."""
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.toolchain / ".ready").touch()
_mark_installed(nrf52_dirs)
check_and_install()
@@ -189,8 +226,14 @@ class TestCheckAndInstall:
check_and_install()
mock_nrf52_ops.create_venv.assert_called_once()
# pip install requirements, west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 4
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"install", # requirements
"init",
"config",
"update",
"list",
"install", # zephyr requirements
]
# minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
@@ -210,8 +253,13 @@ class TestCheckAndInstall:
check_and_install()
mock_nrf52_ops.create_venv.assert_not_called()
# west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 3
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"init",
"config",
"update",
"list",
"install",
]
# minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
@@ -243,13 +291,104 @@ class TestCheckAndInstall:
check_and_install()
init_cmd, update_cmd = (
call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2]
init, _, update = mock_nrf52_ops.run_command_ok.call_args_list[:3]
assert "-o=--depth=1" in init.args[0]
assert "--fetch-opt=--depth=1" in update.args[0]
# Streamed, so the long clone's progress reaches the log
assert init.kwargs["stream_output"] is True
assert update.kwargs["stream_output"] is True
def test_interrupted_download_resumes(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A workspace left by a cut-short download is updated in place, not
wiped and cloned again."""
_mark_venv_ready(nrf52_dirs.python_env)
_mark_west_initialized(nrf52_dirs.framework)
# A marker left by an earlier failed resume
(nrf52_dirs.framework / ".resume_failed").touch()
check_and_install()
assert (
call(nrf52_dirs.framework, msg=ANY)
not in mock_nrf52_ops.rmdir.call_args_list
)
assert "init" in init_cmd
assert "-o=--depth=1" in init_cmd
assert "update" in update_cmd
assert "--fetch-opt=--depth=1" in update_cmd
assert not (nrf52_dirs.framework / ".resume_failed").exists()
# west update in the workspace (no init), then pip install zephyr reqs
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"config",
"update",
"list",
"install",
]
update = mock_nrf52_ops.run_command_ok.call_args_list[1]
assert update.kwargs["cwd"] == nrf52_dirs.framework
assert (nrf52_dirs.framework / ".ready").exists()
def test_failed_resume_keeps_the_download_once(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A first failed resume keeps what was fetched (the network likely
dropped again) and is retried on the next build."""
_mark_venv_ready(nrf52_dirs.python_env)
_mark_west_initialized(nrf52_dirs.framework)
# config succeeds, the resumed update fails
mock_nrf52_ops.run_command_ok.side_effect = [True, False]
with pytest.raises(EsphomeError, match="Can't resume"):
check_and_install()
assert (
call(nrf52_dirs.framework, msg=ANY)
not in mock_nrf52_ops.rmdir.call_args_list
)
assert (nrf52_dirs.framework / ".resume_failed").exists()
def test_cut_short_init_starts_over(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A ``.west`` without its config (init cut short) clones clean."""
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".west").mkdir()
check_and_install()
mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY)
first = mock_nrf52_ops.run_command_ok.call_args_list[0]
assert "init" in first.args[0]
def test_second_failed_resume_starts_over(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A resume failing twice in a row wipes the workspace and clones clean."""
_mark_venv_ready(nrf52_dirs.python_env)
_mark_west_initialized(nrf52_dirs.framework)
(nrf52_dirs.framework / ".resume_failed").touch()
# resumed update fails; the clean clone and zephyr reqs succeed
mock_nrf52_ops.run_command_ok.side_effect = [True, False, *[True] * 5]
check_and_install()
mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY)
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"config",
"update",
"init",
"config",
"update",
"list",
"install",
]
def test_requirements_install_failure_raises(
self,
@@ -281,12 +420,372 @@ class TestCheckAndInstall:
) -> None:
"""Failing west update raises EsphomeError."""
_mark_venv_ready(nrf52_dirs.python_env)
# init succeeds, update fails
mock_nrf52_ops.run_command_ok.side_effect = [True, False]
# init and config succeed, update fails
mock_nrf52_ops.run_command_ok.side_effect = [True, True, False]
with pytest.raises(EsphomeError, match="Can't update"):
check_and_install()
def test_fresh_install_fetches_only_default_projects(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A fresh clone leaves every west project out except the defaults."""
_mark_venv_ready(nrf52_dirs.python_env)
check_and_install()
assert _project_filter(mock_nrf52_ops.run_command_ok) == (
"-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr"
)
stamp = nrf52_dirs.framework / ".west_projects"
assert stamp.read_text(encoding="utf-8").split() == sorted(
DEFAULT_WEST_PROJECTS
)
def test_included_project_joins_the_filter(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A project a component includes is fetched with the defaults."""
_mark_venv_ready(nrf52_dirs.python_env)
include_west_project("oberon-psa-crypto")
check_and_install()
assert "+oberon-psa-crypto" in _project_filter(
mock_nrf52_ops.run_command_ok
).split(",")
def test_installed_sdk_fetches_a_newly_needed_project(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A finished install gains a project another config left out, keeping
what it already has."""
_mark_installed(nrf52_dirs)
(nrf52_dirs.framework / ".west_projects").write_text(
"cmsis\nhal_nordic\nnrfxlib\ntinycrypt\nzephyr", encoding="utf-8"
)
include_west_project("openthread")
check_and_install()
# The names are checked before anything is fetched
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"list",
"config",
"update",
]
wanted = "-.*,+cmsis,+hal_nordic,+nrfxlib,+openthread,+tinycrypt,+zephyr"
assert _project_filter(mock_nrf52_ops.run_command_ok) == wanted
mock_nrf52_ops.rmdir.assert_not_called()
@pytest.mark.parametrize(
"stamp",
[
pytest.param(None, id="install_from_before_the_filter"),
pytest.param(
"cmsis\nhal_nordic\nnrfxlib\nopenthread\nzephyr",
id="project_already_fetched",
),
],
)
def test_installed_sdk_with_the_project_fetches_nothing(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
stamp: str | None,
) -> None:
"""No fetch when the install already has every wanted project; an
install without the stamp (west config, no filter) has them all. The
names are still checked, which only reads the manifest."""
_mark_installed(nrf52_dirs)
_mark_west_initialized(nrf52_dirs.framework)
if stamp is not None:
(nrf52_dirs.framework / ".west_projects").write_text(
stamp, encoding="utf-8"
)
include_west_project("openthread")
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"]
def test_sysbuild_fetches_mcuboot(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Sysbuild always builds the MCUboot image, so it needs the project."""
_mark_venv_ready(nrf52_dirs.python_env)
CORE.data[KEY_ZEPHYR] = {KEY_SYSBUILD: True}
check_and_install()
assert "+mcuboot" in _project_filter(mock_nrf52_ops.run_command_ok).split(",")
@pytest.mark.parametrize(
("sdk_version", "has_cmsis_6"),
[("2.9.2", False), ("3.1.0", True), ("3.2.0", True)],
)
def test_sdk_3_1_and_later_want_cmsis_6(
self, setup_core: Path, sdk_version: str, has_cmsis_6: bool
) -> None:
"""Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)}
assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6
def test_default_projects_never_read_the_stamp(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A build wanting only the defaults has them on any install; an
unreadable stamp shows the check never looked."""
_mark_installed(nrf52_dirs)
(nrf52_dirs.framework / ".west_projects").mkdir()
check_and_install()
mock_nrf52_ops.run_command_ok.assert_not_called()
def test_failed_project_fetch_raises(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A failed fetch of a newly needed project raises, keeps the stamp and
puts the workspace filter back to the stamp's projects and the defaults."""
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".ready").touch()
stamp = nrf52_dirs.framework / ".west_projects"
stamp.write_text("zephyr", encoding="utf-8")
include_west_project("openthread")
# list and config succeed, update fails, the restoring config succeeds
mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True]
with pytest.raises(EsphomeError, match="Can't update"):
check_and_install()
assert stamp.read_text(encoding="utf-8") == "zephyr"
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"list",
"config",
"update",
"config",
]
# The defaults always stay in the restored filter
assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER
def test_failed_fetch_with_a_lost_stamp_keeps_the_defaults_active(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""An unknown installed set must not leave a filter with every module off."""
_mark_installed(nrf52_dirs)
_mark_west_initialized(nrf52_dirs.framework)
(nrf52_dirs.framework / ".west" / "config").write_text(
"[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8"
)
include_west_project("openthread")
# list and config succeed, update fails, the restoring config succeeds
mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True]
with pytest.raises(EsphomeError, match="Can't update"):
check_and_install()
assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER
def test_failed_filter_restore_is_logged(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
caplog: pytest.LogCaptureFixture,
) -> None:
"""When the filter can't be put back after a failed fetch, the user is told."""
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8")
include_west_project("openthread")
# list and config succeed, update fails, the restoring config fails too
mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, False]
with pytest.raises(EsphomeError, match="Can't update"):
check_and_install()
assert "Couldn't put the nRF Connect SDK" in caplog.text
def test_lost_stamp_on_a_filtered_install_fetches_again(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A workspace with a project filter but no stamp is a filtered install
whose record was lost, so the wanted projects are fetched, not assumed."""
_mark_installed(nrf52_dirs)
_mark_west_initialized(nrf52_dirs.framework)
(nrf52_dirs.framework / ".west" / "config").write_text(
"[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8"
)
include_west_project("openthread")
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"list",
"config",
"update",
]
def test_install_waits_for_another_build_holding_the_lock(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A held lock is waited on, never mistaken for a filesystem that
cannot lock (filelock's Timeout is an OSError as well)."""
from filelock import Timeout
_mark_installed(nrf52_dirs)
with (
caplog.at_level("INFO"),
patch("filelock.FileLock") as file_lock,
):
file_lock.return_value.acquire.side_effect = [Timeout("install.lock"), None]
check_and_install()
assert file_lock.return_value.acquire.call_count == 2
assert "Waiting for another build" in caplog.text
assert "continuing without a lock" not in caplog.text
def test_install_from_before_the_filter_still_checks_the_names(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""An install with every project fetches nothing, but an unknown name
is still rejected so a mistake shows on every install alike."""
_mark_installed(nrf52_dirs)
_mark_west_initialized(nrf52_dirs.framework)
include_west_project("openthread")
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"]
mock_nrf52_ops.run_command_ok.reset_mock()
mock_nrf52_ops.run_command_ok.return_value = False
include_west_project("no_such_project")
with pytest.raises(EsphomeError, match="west list failed"):
check_and_install()
def test_install_lock_is_per_sdk_version(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Installs of different SDK versions do not wait on each other; the
toolchain they share is only locked while it is missing."""
_mark_installed(nrf52_dirs)
with patch("filelock.FileLock") as file_lock:
check_and_install()
lock_files = [Path(c.args[0]).name for c in file_lock.call_args_list]
assert lock_files == [f"sdk-v{_TEST_SDK_VERSION}.lock"]
def test_install_runs_unlocked_where_the_filesystem_cannot_lock(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No soft-lock fallback: its marker outlives a killed build and would
hang every later one, so the install goes ahead without a lock."""
_mark_installed(nrf52_dirs)
with patch("filelock.FileLock") as file_lock:
file_lock.return_value.acquire.side_effect = OSError(
errno.ENOSYS, "Function not implemented"
)
check_and_install()
assert file_lock.call_args.kwargs == {"fallback_to_soft": False}
assert "continuing without a lock" in caplog.text
def test_unknown_project_raises_before_anything_is_written(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A filter naming a project the manifest lacks fetches nothing, so
the names are checked once the update resolved the manifest, and an
unknown one is never recorded as installed."""
_mark_venv_ready(nrf52_dirs.python_env)
include_west_project("no_such_project")
# init, config and update succeed, list fails
mock_nrf52_ops.run_command_ok.side_effect = [True, True, True, False]
with pytest.raises(EsphomeError, match="west list failed .*no_such_project"):
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"init",
"config",
"update",
"list",
]
assert "no_such_project" in mock_nrf52_ops.run_command_ok.call_args.args[0]
assert not (nrf52_dirs.framework / ".west_projects").exists()
def test_unknown_project_on_an_installed_sdk_fetches_nothing(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""On a finished install the names are checked first, so an unknown one
costs no fetch and leaves the workspace as it was."""
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8")
include_west_project("no_such_project")
# list fails before anything is fetched or changed
mock_nrf52_ops.run_command_ok.side_effect = [False]
with pytest.raises(EsphomeError, match="west list failed"):
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"]
assert (nrf52_dirs.framework / ".west_projects").read_text(
encoding="utf-8"
) == "zephyr"
def test_missing_stamp_and_west_config_fetches_again(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""With neither the stamp nor .west/config left, nothing says what the
install has, so the wanted projects are fetched rather than assumed."""
_mark_installed(nrf52_dirs)
include_west_project("openthread")
check_and_install()
assert _subcommands(mock_nrf52_ops.run_command_ok) == [
"list",
"config",
"update",
]
def test_toolchain_download_passes_platform_substitutions(
self,
nrf52_dirs: SimpleNamespace,
+54
View File
@@ -0,0 +1,54 @@
"""Tests for the nRF52 sdk-nrf memory analysis hooks."""
import os
from pathlib import Path
import pytest
from esphome.components.nrf52 import toolchain
from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE
@pytest.fixture
def nrf52_build(tmp_path: Path) -> Path:
CORE.name = "test_device"
CORE.build_path = tmp_path / "build" / "test_device"
return CORE.build_path / ".pioenvs" / "test_device" / "zephyr"
def _set_sdk_version(version: cv.Version) -> None:
CORE.data.setdefault(KEY_CORE, {})[KEY_FRAMEWORK_VERSION] = version
def test_binutils_come_from_the_pinned_zephyr_sdk() -> None:
bin_path = (
get_sdk_nrf_tools_path()
/ "toolchains"
/ TOOLCHAIN_VERSION
/ "arm-zephyr-eabi"
/ "bin"
)
# Windows hosts get the .exe binaries (CI covers both)
suffix = ".exe" if os.name == "nt" else ""
assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}"
assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}"
def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None:
_set_sdk_version(cv.Version(2, 9, 2))
# A flat image left by an older SDK must not be picked over the current
# layout, so the choice follows the version rather than what exists
(nrf52_build / "zephyr.elf").parent.mkdir(parents=True)
(nrf52_build / "zephyr.elf").write_text("")
assert toolchain.get_elf_path() == nrf52_build / "zephyr" / "zephyr.elf"
def test_elf_uses_the_flat_layout_before_sdk_2_9_2(nrf52_build: Path) -> None:
_set_sdk_version(cv.Version(2, 6, 1))
nested = nrf52_build / "zephyr" / "zephyr.elf"
nested.parent.mkdir(parents=True)
nested.write_text("")
assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf"
@@ -407,6 +407,28 @@ def test_convert_libraries_redownloads_when_manifest_missing(
assert top[0].data["name"] == "A"
def test_convert_libraries_manifest_optional_uses_default_layout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A backend accepting manifest-less libraries (the host build, like
PlatformIO's native platform) gets a default manifest instead of a
re-download that would repeat every build."""
calls = _patch_download_without_manifest(
monkeypatch, tmp_path, manifest_on_force=True
)
emitted: list[ConvertedLibrary] = []
backend = _backend(emit=emitted.append)
backend.manifest_optional = True
with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"):
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
assert calls == [False]
assert top[0].data == {"name": "esphome/A"}
assert emitted == top
assert "has no manifest; using PlatformIO's default layout" in caplog.text
def test_convert_libraries_raises_when_manifest_missing_after_retry(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -1251,3 +1273,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
_backend(),
)
assert "has no version to resolve" not in caplog.text
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
"""symlink:// is PlatformIO's other spelling for a local library folder."""
src = setup_core / "lib_dev"
(src / "src").mkdir(parents=True)
(src / "library.json").write_text(json.dumps({"name": "benchmark"}))
url = src.as_uri().replace("file://", "symlink://", 1)
# Both the name=URL form and an explicit repository take the scheme
for library in (
Library(f"benchmark={url}", None, None),
Library("benchmark", None, url),
):
top = convert_libraries([library], _backend())
assert isinstance(top[0].source, LocalSource)
assert top[0].source_path == src
def test_convert_libraries_incompatible_names_the_platform_without_a_framework(
setup_core: Path,
) -> None:
"""The host has no framework; the error must not read 'compatible with None'."""
src = setup_core / "lib_dev"
src.mkdir()
(src / "library.json").write_text(
json.dumps({"name": "Only32", "platforms": "espressif32"})
)
backend = _backend()
backend.platform = "native"
backend.framework = None
with pytest.raises(RuntimeError, match="Only32 is not compatible with native"):
convert_libraries([Library("Only32", None, src.as_uri())], backend)
@pytest.mark.parametrize(
("url", "message"),
[
("symlink://host/lib", "Unsupported host in symlink:// library URL"),
("symlink:lib_dev", "symlink:// library URL .* must be an absolute"),
],
)
def test_convert_libraries_symlink_url_errors_name_the_scheme(
setup_core: Path, url: str, message: str
) -> None:
with pytest.raises(RuntimeError, match=message):
convert_libraries([Library("benchmark", None, url)], _backend())