mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
Merge branch 'esp8266-native-pch' into esp32-idf-pch
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
tfluna:
|
||||
- i2c_id: i2c_bus
|
||||
|
||||
sensor:
|
||||
- platform: tfluna
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user