Merge branch 'dev' into sendspin-artwork

This commit is contained in:
Kevin Ahrendt
2026-05-04 06:37:51 -04:00
committed by GitHub
330 changed files with 11733 additions and 2571 deletions
+11 -3
View File
@@ -11,11 +11,19 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
await original_to_code(config)
# Enable BLE proto message types for benchmarks. The real
# bluetooth_proxy component is ESP32-only; a lightweight stub
# header in tests/benchmarks/stubs/ satisfies the include.
# Enable proxy proto message types for benchmarks. The real
# components have hardware dependencies (BLE/UART/RMT); lightweight
# stub headers in tests/benchmarks/stubs/ satisfy the includes.
cg.add_define("USE_BLUETOOTH_PROXY")
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3)
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("USE_ZWAVE_PROXY")
cg.add_define("USE_INFRARED")
cg.add_define("USE_IR_RF")
cg.add_define("USE_RADIO_FREQUENCY")
cg.add_define("USE_SERIAL_PROXY")
cg.add_define("SERIAL_PROXY_COUNT", 0)
cg.add_define("ESPHOME_ENTITY_INFRARED_COUNT", 0)
cg.add_define("ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT", 0)
manifest.to_code = to_code
@@ -0,0 +1,280 @@
// Encode/decode microbenchmarks for proxy message families that carry
// high-volume traffic (Z-Wave, IR/RF, serial). Mirrors the existing
// BluetoothLERawAdvertisementsResponse benchmarks in bench_proto_encode.cpp.
#include <benchmark/benchmark.h>
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// Encodes `src` into `out`. Caller owns `out` and must keep it alive across
// the decode loop (decoded messages may store pointers back into its bytes).
template<typename T> static void encode_into(APIBuffer &out, const T &src) {
out.resize(src.calculate_size());
ProtoWriteBuffer writer(&out, 0);
src.encode(writer);
}
// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) ---
#ifdef USE_ZWAVE_PROXY
static const uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00,
0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00};
static void Encode_ZWaveProxyFrame(benchmark::State &state) {
ZWaveProxyFrame msg;
msg.data = kZWaveFrameData;
msg.data_len = sizeof(kZWaveFrameData);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_ZWaveProxyFrame);
static void Decode_ZWaveProxyFrame(benchmark::State &state) {
ZWaveProxyFrame source;
source.data = kZWaveFrameData;
source.data_len = sizeof(kZWaveFrameData);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ZWaveProxyFrame msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_ZWaveProxyFrame);
static const uint8_t kZWaveRequestData[] = {0xDE, 0xAD, 0xBE, 0xEF};
static void Decode_ZWaveProxyRequest(benchmark::State &state) {
ZWaveProxyRequest source;
source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE;
source.data = kZWaveRequestData;
source.data_len = sizeof(kZWaveRequestData);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ZWaveProxyRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_ZWaveProxyRequest);
#endif // USE_ZWAVE_PROXY
// --- SerialProxyDataReceived encode + SerialProxyWriteRequest decode ---
//
// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT) but has the same
// wire layout as SerialProxyDataReceived, so we encode via the latter and
// decode as the former.
#ifdef USE_SERIAL_PROXY
static constexpr size_t kSerialPayloadSize = 64;
static const uint8_t kSerialPayload[kSerialPayloadSize] = {
0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB,
0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF};
static void Encode_SerialProxyDataReceived(benchmark::State &state) {
SerialProxyDataReceived msg;
msg.instance = 0;
msg.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_SerialProxyDataReceived);
static void Decode_SerialProxyWriteRequest(benchmark::State &state) {
SerialProxyDataReceived source;
source.instance = 0;
source.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
SerialProxyWriteRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_SerialProxyWriteRequest);
#endif // USE_SERIAL_PROXY
// --- InfraredRFReceiveEvent encode (100 sint32 timings) +
// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) ---
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings).
static std::vector<int32_t> make_ir_timings_100() {
std::vector<int32_t> v;
v.reserve(100);
for (int i = 0; i < 100; i++) {
v.push_back((i % 2 == 0) ? 560 : -560);
}
return v;
}
static const std::vector<int32_t> &get_ir_timings_100() {
static const std::vector<int32_t> timings = make_ir_timings_100();
return timings;
}
static void Encode_InfraredRFReceiveEvent(benchmark::State &state) {
InfraredRFReceiveEvent msg;
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
APIBuffer buffer;
buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_InfraredRFReceiveEvent);
static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) {
InfraredRFReceiveEvent msg;
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_InfraredRFReceiveEvent);
// Hand-built wire bytes for InfraredRFTransmitRawTimingsRequest (decode-only,
// no sister message with identical wire layout).
// field 2 (key, fixed32): tag=0x15, 4 LE bytes
// field 3 (carrier_frequency): tag=0x18, varint
// field 4 (repeat_count): tag=0x20, varint
// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload
// field 6 (modulation): tag=0x30, varint
static APIBuffer build_infrared_rf_transmit_wire() {
uint8_t bytes[256];
size_t len = 0;
auto put_byte = [&](uint8_t b) { bytes[len++] = b; };
auto put_varint = [&](uint32_t v) {
while (v >= 0x80) {
bytes[len++] = static_cast<uint8_t>((v & 0x7F) | 0x80);
v >>= 7;
}
bytes[len++] = static_cast<uint8_t>(v);
};
auto encode_zigzag = [](int32_t v) -> uint32_t {
return (static_cast<uint32_t>(v) << 1) ^ static_cast<uint32_t>(v >> 31);
};
put_byte(0x15);
put_byte(0xEF);
put_byte(0xBE);
put_byte(0xAD);
put_byte(0xDE);
put_byte(0x18);
put_varint(38000);
put_byte(0x20);
put_varint(2);
uint8_t packed[200];
size_t packed_len = 0;
for (int i = 0; i < 100; i++) {
int32_t value = (i % 2 == 0) ? 560 : -560;
uint32_t zz = encode_zigzag(value);
while (zz >= 0x80) {
packed[packed_len++] = static_cast<uint8_t>((zz & 0x7F) | 0x80);
zz >>= 7;
}
packed[packed_len++] = static_cast<uint8_t>(zz);
}
put_byte(0x2A);
put_varint(static_cast<uint32_t>(packed_len));
std::memcpy(bytes + len, packed, packed_len);
len += packed_len;
// field 6: modulation = 1 (non-zero so it's actually emitted and exercises
// decode_varint for this field, matching the documented layout above).
put_byte(0x30);
put_varint(1);
APIBuffer buf;
buf.resize(len);
std::memcpy(buf.data(), bytes, len);
return buf;
}
static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) {
auto encoded = build_infrared_rf_transmit_wire();
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
InfraredRFTransmitRawTimingsRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest);
#endif // USE_IR_RF || USE_RADIO_FREQUENCY
} // namespace esphome::api::benchmarks
@@ -0,0 +1,45 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_INFRARED is defined,
// without pulling in the real remote_base/RMT dependencies.
#pragma once
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
namespace esphome::infrared {
class Infrared;
class InfraredCall {
public:
explicit InfraredCall(Infrared *parent) : parent_(parent) {}
InfraredCall &set_carrier_frequency(uint32_t /*frequency*/) { return *this; }
InfraredCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; }
void perform() {}
protected:
Infrared *parent_;
};
class InfraredTraits {
public:
uint32_t get_receiver_frequency_hz() const { return 0; }
};
class Infrared : public Component, public EntityBase {
public:
Infrared() = default;
InfraredTraits &get_traits() { return this->traits_; }
const InfraredTraits &get_traits() const { return this->traits_; }
InfraredCall make_call() { return InfraredCall(this); }
uint32_t get_capability_flags() const { return 0; }
protected:
InfraredTraits traits_;
};
} // namespace esphome::infrared
@@ -0,0 +1,51 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_RADIO_FREQUENCY is defined.
#pragma once
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
namespace esphome::radio_frequency {
enum RadioFrequencyModulation : uint32_t {
RADIO_FREQUENCY_MODULATION_OOK = 0,
};
class RadioFrequency;
class RadioFrequencyCall {
public:
explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {}
RadioFrequencyCall &set_frequency(uint32_t /*frequency*/) { return *this; }
RadioFrequencyCall &set_modulation(RadioFrequencyModulation /*mod*/) { return *this; }
RadioFrequencyCall &set_repeat_count(uint32_t /*count*/) { return *this; }
RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
void perform() {}
protected:
RadioFrequency *parent_;
};
class RadioFrequencyTraits {
public:
uint32_t get_frequency_min_hz() const { return 0; }
uint32_t get_frequency_max_hz() const { return 0; }
uint32_t get_supported_modulations() const { return 0; }
};
class RadioFrequency : public Component, public EntityBase {
public:
RadioFrequency() = default;
RadioFrequencyTraits &get_traits() { return this->traits_; }
const RadioFrequencyTraits &get_traits() const { return this->traits_; }
RadioFrequencyCall make_call() { return RadioFrequencyCall(this); }
uint32_t get_capability_flags() const { return 0; }
protected:
RadioFrequencyTraits traits_;
};
} // namespace esphome::radio_frequency
@@ -0,0 +1,46 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_SERIAL_PROXY is defined,
// without pulling in the real UART implementation.
#pragma once
#include <cstdint>
#include <cstddef>
#include "esphome/components/api/api_pb2.h"
namespace esphome {
namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
uint32_t get_instance_index() const { return this->instance_index_; }
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {}
void write_from_client(const uint8_t *data, size_t len) {}
void set_modem_pins(uint32_t line_states) {}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
protected:
uint32_t instance_index_{0};
};
} // namespace serial_proxy
} // namespace esphome
@@ -0,0 +1,29 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp needs when USE_ZWAVE_PROXY is defined,
// without pulling in the real UART-based ZWaveProxy implementation.
#pragma once
#include "esphome/components/api/api_pb2.h"
namespace esphome {
namespace api {
class APIConnection;
} // namespace api
namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
void send_frame(const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
uint32_t get_home_id() { return 0; }
};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern ZWaveProxy *global_zwave_proxy;
} // namespace zwave_proxy
} // namespace esphome
@@ -0,0 +1,8 @@
esphome:
name: test
esp32:
board: esp32dev
variant: esp32
framework:
type: arduino
+28
View File
@@ -16,6 +16,7 @@ from esphome.const import (
CONF_ESPHOME,
CONF_IGNORE_PIN_VALIDATION_ERROR,
CONF_NUMBER,
KEY_NATIVE_IDF,
PlatformFramework,
)
from esphome.core import CORE
@@ -243,3 +244,30 @@ def test_platformio_idf_enables_reproducible_build(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
def test_platformio_arduino_enables_reproducible_build(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test PlatformIO Arduino builds enable reproducible app metadata."""
generate_main(component_config_path("reproducible_build_arduino.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
def test_native_idf_enables_reproducible_build(
component_config_path: Callable[[str], Path],
) -> None:
"""Test native ESP-IDF builds enable reproducible app metadata."""
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
CORE.config_path = component_config_path("reproducible_build.yaml")
CORE.config = read_config({})
CORE.data[KEY_NATIVE_IDF] = True
generate_cpp_contents(CORE.config)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
+69
View File
@@ -7,10 +7,12 @@ from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from PIL import Image as PILImage
import pytest
from esphome import config_validation as cv
from esphome.components.image import (
CONF_ALPHA_CHANNEL,
CONF_INVERT_ALPHA,
CONF_OPAQUE,
CONF_TRANSPARENCY,
@@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds(
assert 30 < height < 50, (
f"Height should be around 39 pixels for 10mm at 100dpi, got {height}"
)
@pytest.mark.asyncio
async def test_rgb565_alpha_animation_layout_per_frame(
tmp_path: Path,
mock_progmem_array: MagicMock,
) -> None:
"""RGB565+alpha animations must store each frame as a self-contained
[RGB plane | alpha plane] block. Animation::update_data_start_ steps frames
with a single per-frame stride, so any cross-frame layout (all RGB then all
alpha) makes the C++ alpha read land in the next frame's RGB bytes — that
was the regression behind issue #15999.
"""
# Build a 2-frame APNG where each frame is a solid color with a known
# alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so
# round-tripping mid-range alpha values does not work). Frame 0 is fully
# opaque red, frame 1 is fully transparent blue.
width = 4
height = 3
frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF))
frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00))
apng_path = tmp_path / "anim.png"
frame0.save(
apng_path,
format="PNG",
save_all=True,
append_images=[frame1],
duration=100,
loop=0,
)
config = {
CONF_FILE: str(apng_path),
CONF_TYPE: "RGB565",
CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL,
CONF_DITHER: "NONE",
CONF_INVERT_ALPHA: False,
CONF_RAW_DATA_ID: "test_raw_data_id",
}
_, _, _, _, _, frame_count = await write_image(config, all_frames=True)
assert frame_count == 2
# Recover the bytes handed to progmem_array. Signature is (id_, rhs).
_, raw_data = mock_progmem_array.call_args.args
data = [int(x) for x in raw_data]
rgb_size = width * height * 2
alpha_size = width * height
frame_size = rgb_size + alpha_size
assert len(data) == frame_size * frame_count, (
"RGB565+alpha animation buffer must be (RGB + alpha) per frame, not "
"all RGB followed by all alpha"
)
# Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is
# 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes
# would all land at the tail of the buffer and the per-frame slices below
# would point at RGB565 noise instead.
frame0_alpha = data[rgb_size : rgb_size + alpha_size]
frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size]
assert all(a == 0xFF for a in frame0_alpha), (
f"Frame 0 alpha plane should be opaque, got {frame0_alpha}"
)
assert all(a == 0x00 for a in frame1_alpha), (
f"Frame 1 alpha plane should be transparent, got {frame1_alpha}"
)
@@ -0,0 +1,239 @@
"""Unit tests for the LVGL grid layout shorthand and rows/columns auto-sizing."""
from __future__ import annotations
import pytest
from voluptuous import Invalid
from esphome.components.lvgl.defines import (
CONF_GRID_COLUMNS,
CONF_GRID_ROWS,
CONF_LAYOUT,
CONF_WIDGETS,
TYPE_GRID,
)
from esphome.components.lvgl.layout import GridLayout, grid_dimension
from esphome.const import CONF_TYPE
FR1 = "LV_GRID_FR(1)"
def _widgets(n: int) -> list[dict]:
"""Build a list of `n` placeholder widgets for the validate() input."""
return [{"label": {}} for _ in range(n)]
# ---------------------------------------------------------------------------
# grid_dimension validator
# ---------------------------------------------------------------------------
def test_grid_dimension_int_expands_to_fr1_list() -> None:
"""A positive integer should expand to a list of LV_GRID_FR(1) entries."""
assert grid_dimension(1) == [FR1]
assert grid_dimension(3) == [FR1, FR1, FR1]
def test_grid_dimension_zero_or_negative_rejected() -> None:
"""Non-positive integers must be rejected."""
with pytest.raises(Invalid):
grid_dimension(0)
with pytest.raises(Invalid):
grid_dimension(-2)
def test_grid_dimension_list_passes_through() -> None:
"""A list should be validated through the existing grid_spec list schema."""
result = grid_dimension(["100px", "content", "fr(2)"])
# `grid_spec` normalises each entry: pixel sizes become ints, the
# CONTENT keyword is uppercased and prefixed, and FR(n) is normalised.
assert result == [100, "LV_GRID_CONTENT", "LV_GRID_FR(2)"]
def test_grid_dimension_invalid_string_rejected() -> None:
"""A string is not a valid grid dimension and should be rejected."""
with pytest.raises(Invalid):
grid_dimension("not a list")
def test_grid_dimension_empty_list_rejected() -> None:
"""An empty list of grid specs must be rejected."""
with pytest.raises(Invalid, match="at least one entry"):
grid_dimension([])
# ---------------------------------------------------------------------------
# Shorthand string layouts
# ---------------------------------------------------------------------------
def test_shorthand_full_form_unchanged() -> None:
"""`<rows>x<cols>` continues to work and yields the exact dimensions."""
config = {CONF_LAYOUT: "2x3", CONF_WIDGETS: _widgets(0)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert layout[CONF_TYPE] == TYPE_GRID
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
def test_shorthand_rows_only_calculates_columns_from_widgets() -> None:
"""`<rows>x` derives the column count from the number of widgets."""
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: _widgets(7)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 7 widgets / 3 rows -> ceil = 3 columns.
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 3
def test_shorthand_columns_only_calculates_rows_from_widgets() -> None:
"""`x<cols>` derives the row count from the number of widgets."""
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: _widgets(5)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 5 widgets / 4 cols -> ceil = 2 rows.
assert len(layout[CONF_GRID_ROWS]) == 2
assert len(layout[CONF_GRID_COLUMNS]) == 4
def test_shorthand_rows_only_no_widgets_defaults_columns_to_one() -> None:
"""With no widgets and only rows specified, the column count defaults to 1."""
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: []}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 1
def test_shorthand_columns_only_no_widgets_defaults_rows_to_one() -> None:
"""With no widgets and only columns specified, the row count defaults to 1."""
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: []}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 1
assert len(layout[CONF_GRID_COLUMNS]) == 4
def test_shorthand_with_whitespace_accepted() -> None:
"""The shorthand parser should tolerate whitespace around the components."""
config = {CONF_LAYOUT: " 3 x ", CONF_WIDGETS: _widgets(6)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 6 widgets / 3 rows -> 2 columns.
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 2
def test_shorthand_bare_x_rejected() -> None:
"""Pure `x` (no digits at all) is not a valid shorthand."""
config = {CONF_LAYOUT: "x", CONF_WIDGETS: _widgets(2)}
with pytest.raises(Invalid):
GridLayout().validate(config)
@pytest.mark.parametrize(
"layout,bad_label",
[
("0x3", "row"),
("3x0", "column"),
("0x", "row"),
("x0", "column"),
("0x0", "row"),
],
)
def test_shorthand_zero_dimension_rejected(layout: str, bad_label: str) -> None:
"""Shorthand row/column counts must be >= 1."""
config = {CONF_LAYOUT: layout, CONF_WIDGETS: _widgets(2)}
with pytest.raises(Invalid, match=f"{bad_label} count must be at least 1"):
GridLayout().validate(config)
def test_shorthand_get_layout_schemas_recognizes_partial_forms() -> None:
"""`<rows>x` and `x<cols>` should be picked up by GridLayout.get_layout_schemas."""
grid = GridLayout()
for layout in ("3x", "x4", "2x3"):
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
assert layout_schema is not None, f"{layout!r} should be recognised"
# Pure `x` and unrelated strings should not be picked up as a grid layout.
for layout in ("x", "horizontal"):
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
assert layout_schema is None, f"{layout!r} should not be recognised"
# ---------------------------------------------------------------------------
# Dict-form layouts with rows/columns auto-sizing
# ---------------------------------------------------------------------------
def test_dict_rows_only_calculates_columns_from_widgets() -> None:
"""A dict layout with only rows fills in the column count from widget count."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1],
},
CONF_WIDGETS: _widgets(5),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 5 widgets / 2 rows -> ceil = 3 columns.
assert len(layout[CONF_GRID_ROWS]) == 2
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
def test_dict_columns_only_calculates_rows_from_widgets() -> None:
"""A dict layout with only columns fills in the row count from widget count."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
},
CONF_WIDGETS: _widgets(7),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 7 widgets / 3 cols -> ceil = 3 rows.
assert layout[CONF_GRID_ROWS] == [FR1, FR1, FR1]
assert len(layout[CONF_GRID_COLUMNS]) == 3
def test_dict_rows_only_no_widgets_defaults_columns_to_one() -> None:
"""A dict layout with rows but no widgets defaults columns to 1."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1, FR1],
},
CONF_WIDGETS: [],
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 3
assert layout[CONF_GRID_COLUMNS] == [FR1]
def test_dict_neither_rows_nor_columns_rejected() -> None:
"""A grid layout dict without rows AND without columns must be rejected."""
config = {
CONF_LAYOUT: {CONF_TYPE: TYPE_GRID},
CONF_WIDGETS: _widgets(3),
}
with pytest.raises(Invalid):
GridLayout().validate(config)
def test_dict_both_rows_and_columns_unchanged() -> None:
"""When both dimensions are present they are preserved as-is."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1],
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
},
CONF_WIDGETS: _widgets(0),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
+18
View File
@@ -91,6 +91,24 @@ api:
- float_arr.size()
- string_arr[0].c_str()
- string_arr.size()
# Test array + string args used after a non-synchronous action (delay).
# The default non-owning types (StringRef, const FixedVector&) would
# dangle once rx_buf_ is reused, and FixedVector is non-copyable so
# DelayAction's lambda capture would fail to compile. The api codegen
# must fall back to owning std::string / std::vector here.
- action: array_with_delay
variables:
name: string
int_arr: int[]
string_arr: string[]
then:
- delay: 20ms
- logger.log:
format: "Delayed: %s (%u ints, %u strings)"
args:
- name.c_str()
- int_arr.size()
- string_arr.size()
# Test ContinuationAction (IfAction with then/else branches)
- action: test_if_action
variables:
@@ -0,0 +1,123 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <ios>
#include <random>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// Generic varint decoder, used to verify the encoded bytes round-trip back to
// the original 48-bit MAC value, independent of the specialized encoder under
// test.
static uint64_t decode_varint(const uint8_t *buf, size_t len, size_t *consumed) {
uint64_t value = 0;
int shift = 0;
for (size_t i = 0; i < len; i++) {
value |= static_cast<uint64_t>(buf[i] & 0x7F) << shift;
if ((buf[i] & 0x80) == 0) {
*consumed = i + 1;
return value;
}
shift += 7;
}
*consumed = 0;
return 0;
}
// Reference encoder mirroring ProtoEncode::encode_varint_raw_64.
static size_t reference_encode(uint64_t value, uint8_t *out) {
uint8_t *p = out;
if (value < 128) {
*p++ = static_cast<uint8_t>(value);
return p - out;
}
do {
*p++ = static_cast<uint8_t>(value | 0x80);
value >>= 7;
} while (value > 0x7F);
*p++ = static_cast<uint8_t>(value);
return p - out;
}
// Encode `mac` via the 48-bit fast path and verify:
// - byte-identical output to the reference loop
// - encoded byte length matches `expected_bytes`
// - calc_uint64_48bit_force agrees on the size
// - the bytes round-trip through a generic varint decoder
static void verify_mac(uint64_t mac, size_t expected_bytes) {
ASSERT_LT(mac, 1ULL << 48) << "test fixture mac exceeds 48 bits";
uint8_t ref_buf[16] = {0};
size_t ref_len = reference_encode(mac, ref_buf);
APIBuffer api_buf;
api_buf.resize(16);
uint8_t *pos = api_buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
#endif
ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac);
size_t new_len = pos - api_buf.data();
EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec;
EXPECT_EQ(ref_len, expected_bytes) << "reference disagrees on length for mac=0x" << std::hex << mac << std::dec;
for (size_t i = 0; i < new_len; i++) {
EXPECT_EQ(api_buf.data()[i], ref_buf[i])
<< "byte " << i << " differs for mac=0x" << std::hex << mac << " (got 0x" << static_cast<int>(api_buf.data()[i])
<< ", expected 0x" << static_cast<int>(ref_buf[i]) << ")" << std::dec;
}
size_t consumed = 0;
uint64_t decoded = decode_varint(api_buf.data(), new_len, &consumed);
EXPECT_EQ(consumed, new_len) << "decoder did not consume all bytes for mac=0x" << std::hex << mac << std::dec;
EXPECT_EQ(decoded, mac) << "round-trip mismatch for mac=0x" << std::hex << mac << std::dec;
// Verify the size helper agrees. field_id_size = 1 (typical 1-byte tag).
uint32_t calc_size = ProtoSize::calc_uint64_48bit_force(1, mac);
EXPECT_EQ(calc_size, 1 + expected_bytes)
<< "calc_uint64_48bit_force size mismatch for mac=0x" << std::hex << mac << std::dec;
}
// Compute the canonical varint byte length for a value < 1<<48.
static size_t expected_varint_len(uint64_t v) {
if (v < (1ULL << 7))
return 1;
if (v < (1ULL << 14))
return 2;
if (v < (1ULL << 21))
return 3;
if (v < (1ULL << 28))
return 4;
if (v < (1ULL << 35))
return 5;
if (v < (1ULL << 42))
return 6;
return 7;
}
// --- Specific MACs requested for verification ---
TEST(ProtoMacVarint, AllZeros) { verify_mac(0x000000000000ULL, 1); } // 00:00:00:00:00:00
TEST(ProtoMacVarint, FirstByteOnly) { verify_mac(0x110000000000ULL, 7); } // 11:00:00:00:00:00
TEST(ProtoMacVarint, SecondByteOnly) { verify_mac(0x00AA00000000ULL, 6); } // 00:AA:00:00:00:00
TEST(ProtoMacVarint, ThirdByteOnly) { verify_mac(0x0000BB000000ULL, 5); } // 00:00:BB:00:00:00
TEST(ProtoMacVarint, FourthByteOnly) { verify_mac(0x000000CC0000ULL, 4); } // 00:00:00:CC:00:00
TEST(ProtoMacVarint, FifthByteOnly) { verify_mac(0x00000000DD00ULL, 3); } // 00:00:00:00:DD:00
TEST(ProtoMacVarint, SixthByteOnly) { verify_mac(0x0000000000EEULL, 2); } // 00:00:00:00:00:EE
TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // FF:FF:FF:FF:FF:FF
// 100 deterministic-random 48-bit MACs to catch regressions across the space.
TEST(ProtoMacVarint, RandomSample) {
// NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) -- fixed seed for reproducibility
std::mt19937_64 rng(0xC0FFEE);
for (int i = 0; i < 100; i++) {
uint64_t mac = rng() & 0xFFFFFFFFFFFFULL;
verify_mac(mac, expected_varint_len(mac));
}
}
} // namespace esphome::api::testing
+14
View File
@@ -0,0 +1,14 @@
audio:
codecs:
flac:
buffer_memory: internal
mp3:
buffer_memory: psram
opus:
floating_point: false
state_memory: psram
pseudostack:
threadsafe: false
buffer_memory: internal
size: 80000
wav:
@@ -0,0 +1 @@
<<: !include common.yaml
+71
View File
@@ -29,3 +29,74 @@ climate:
heat_action:
- switch.turn_on: climate_heater_switch
- switch.turn_off: climate_cooler_switch
# Thermostat-based climate so climate.control: action variants get build
# coverage (bang_bang doesn't support fan modes, presets, etc.). Climate
# has no template platform, so thermostat is the right vehicle.
- platform: thermostat
id: climate_test_thermostat
name: Test Thermostat
sensor: climate_temperature_sensor
min_idle_time: 30s
min_heating_off_time: 300s
min_heating_run_time: 300s
min_cooling_off_time: 300s
min_cooling_run_time: 300s
heat_action:
- logger.log: heating
idle_action:
- logger.log: idle
cool_action:
- logger.log: cooling
auto_mode:
- logger.log: auto
heat_cool_mode:
- logger.log: heat_cool
preset:
- name: Default
default_target_temperature_low: 18°C
default_target_temperature_high: 22°C
button:
# Exercise the climate.control: action so ControlAction templates get
# build coverage. Various field combinations are tested.
- platform: template
name: "Climate Control Mode"
on_press:
- climate.control:
id: climate_test_thermostat
mode: HEAT
- platform: template
name: "Climate Control Mode And Temps"
on_press:
- climate.control:
id: climate_test_thermostat
mode: HEAT_COOL
target_temperature_low: 19.0°C
target_temperature_high: 23.0°C
- platform: template
name: "Climate Control Lambda Temp"
on_press:
- climate.control:
id: climate_test_thermostat
target_temperature_high: !lambda "return 21.5;"
- platform: template
name: "Climate Control Off"
on_press:
- climate.control:
id: climate_test_thermostat
mode: "OFF"
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
# passes float).
number:
- platform: template
id: climate_target_temp_number
optimistic: true
min_value: 16
max_value: 28
step: 0.5
on_value:
then:
- climate.control:
id: climate_test_thermostat
target_temperature_high: !lambda "return x;"
+57
View File
@@ -9,6 +9,14 @@ fan:
has_oscillating: true
has_direction: true
speed_count: 3
# Exercise fan.turn_on inside a trigger whose Ts pack is non-empty
# (StringRef from on_preset_set) so the apply-lambda + inner-lambda
# codegen runs through the cvref-normalized path.
on_preset_set:
then:
- fan.turn_on:
id: test_fan
speed: !lambda "return x.empty() ? 1 : 3;"
# Test lambdas using get_preset_mode() which returns StringRef
# These examples match the migration guide in the PR description
@@ -57,3 +65,52 @@ binary_sensor:
return true;
}
return false;
# Exercise fan.turn_on with various field combinations so the
# TurnOnAction codegen paths get build coverage.
button:
- platform: template
name: "Fan Speed Only"
on_press:
- fan.turn_on:
id: test_fan
speed: 2
- platform: template
name: "Fan Oscillating + Direction"
on_press:
- fan.turn_on:
id: test_fan
oscillating: true
direction: REVERSE
- platform: template
name: "Fan All Fields"
on_press:
- fan.turn_on:
id: test_fan
oscillating: false
speed: 3
direction: FORWARD
- platform: template
name: "Fan Lambda Speed"
on_press:
- fan.turn_on:
id: test_fan
speed: !lambda 'return 1;'
# Exercise fan.turn_on inside triggers with non-empty Ts:
# - number.on_value: Ts = float (Python value type; previously raised
# AttributeError on .operator("const"))
# - fan.on_preset_set: Ts = StringRef (already a value-type wrapper around
# a const char * + size; tests the cvref-normalized inner-lambda path)
number:
- platform: template
id: fan_speed_number
optimistic: true
min_value: 1
max_value: 3
step: 1
on_value:
then:
- fan.turn_on:
id: test_fan
speed: !lambda "return (int) x;"
+9
View File
@@ -0,0 +1,9 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# json's to_code calls cg.add_library("bblanchon/ArduinoJson", ...). C++
# unit test builds that pull json in transitively (e.g. api) need that
# library registration to happen, otherwise json_util.cpp fails to find
# ArduinoJson.h.
manifest.enable_codegen()
+19
View File
@@ -108,6 +108,10 @@ esphome:
relative_brightness: 5%
brightness_limits:
max_brightness: 90%
- light.dim_relative:
id: test_monochromatic_light
relative_brightness: -5%
transition_length: 250ms
- light.turn_on:
id: test_addressable_transition
brightness: 50%
@@ -123,6 +127,21 @@ esphome:
blue: 0%
transition_length: 1s
# Exercise light actions inside a trigger with non-empty Ts (number on_value
# passes float).
number:
- platform: template
id: test_number_brightness
optimistic: true
min_value: 0
max_value: 100
step: 1
on_value:
then:
- light.turn_on:
id: test_monochromatic_light
brightness: !lambda "return x / 100.0;"
light:
- platform: binary
id: test_binary_light
+98
View File
@@ -16,10 +16,19 @@ binary_sensor:
platform: template
- id: left_sensor
platform: template
- platform: lvgl
name: Button A pressed
widget: button_a
state: pressed
- platform: lvgl
name: Button A checked
widget: button_a
state: checked
- platform: lvgl
id: button_checker
name: LVGL button
widget: button_button
state: checked
on_state:
then:
- lvgl.checkbox.update:
@@ -29,6 +38,12 @@ binary_sensor:
auto y = x; // block inlining of one line return
return y;
- platform: lvgl
id: button_presser
name: Button pressed
widget: button_button
state: pressed
lvgl:
id: lvgl_id
rotation: 90
@@ -1098,6 +1113,8 @@ lvgl:
pad_row: 6px
pad_column: 0
multiple_widgets_per_cell: true
grid_cell_x_align: center
grid_cell_y_align: center
widgets:
- image:
grid_cell_row_pos: 0
@@ -1290,6 +1307,87 @@ lvgl:
hidden: true
mode: text_lower
# Grid shorthand "<rows>x": 3 rows specified, columns derived
# from widget count (4 widgets / 3 rows -> 2 columns)
- obj:
id: grid_rows_only_shorthand
layout: 3x
widgets:
- label:
text: "r1"
- label:
text: "r2"
- label:
text: "r3"
- label:
text: "r4"
# Grid shorthand "x<cols>": 4 columns specified, rows derived
# from widget count (5 widgets / 4 cols -> 2 rows)
- obj:
id: grid_cols_only_shorthand
layout: x4
widgets:
- label:
text: "a"
- label:
text: "b"
- label:
text: "c"
- label:
text: "d"
- label:
text: "e"
# Grid dict form with grid_rows as a plain integer; columns derived
- obj:
id: grid_rows_int
layout:
type: grid
grid_rows: 2
widgets:
- label:
text: "1"
- label:
text: "2"
- label:
text: "3"
# Grid dict form with grid_columns as a plain integer; rows derived
- obj:
id: grid_cols_int
layout:
type: grid
grid_columns: 3
widgets:
- label:
text: "x"
- label:
text: "y"
- label:
text: "z"
- label:
text: "w"
- label:
text: "v"
# Grid dict form with both grid_rows and grid_columns as plain integers
- obj:
id: grid_both_int
layout:
type: grid
grid_rows: 2
grid_columns: 2
widgets:
- label:
text: "1,1"
- label:
text: "1,2"
- label:
text: "2,1"
- label:
text: "2,2"
font:
- file: "gfonts://Roboto"
id: space16
+2
View File
@@ -21,6 +21,7 @@ mapping:
entries:
clear-night: image_1
sunny: image_2
default_value: image_1
- id: weather_map_2
from: string
to: image
@@ -35,6 +36,7 @@ mapping:
2: "two"
3: "three"
77: "seventy-seven"
default_value: unknown
- id: string_map
from: string
to: int
+1 -1
View File
@@ -4,7 +4,7 @@ packages:
display:
spi_id: spi_bus
platform: ili9xxx
platform: mipi_spi
id: main_lcd
model: ili9342
cs_pin: 12
@@ -4,7 +4,7 @@ packages:
display:
spi_id: spi_bus
platform: ili9xxx
platform: mipi_spi
id: main_lcd
model: ili9342
cs_pin: 5
@@ -4,7 +4,7 @@ packages:
display:
spi_id: spi_bus
platform: ili9xxx
platform: mipi_spi
id: main_lcd
model: ili9342
data_rate: 31.25MHz
@@ -0,0 +1,23 @@
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
mdns:
disabled: false
services:
- service: _test_service
protocol: _tcp
port: 8888
txt:
static_string: Anything
@@ -0,0 +1 @@
<<: !include common-enabled-ethernet.yaml
+1 -43
View File
@@ -1,53 +1,11 @@
modbus:
- id: mod_bus2
uart_id: uart_bus
role: server
modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
allow_duplicate_commands: false
on_online:
then:
logger.log: "Module Online"
- id: modbus_controller2
address: 0x2
modbus_id: mod_bus2
server_registers:
- address: 0x0000
value_type: S_DWORD_R
read_lambda: |-
return 42.3;
max_cmd_retries: 0
- id: modbus_controller3
address: 0x3
modbus_id: mod_bus2
server_registers:
- address: 0x0009
value_type: S_DWORD
read_lambda: |-
return 31;
write_lambda: |-
printf("address=%d, value=%d", x);
return true;
max_cmd_retries: 0
- id: modbus_controller4
modbus_id: mod_bus2
address: 0x4
server_courtesy_response:
enabled: true
register_last_address: 100
register_value: 0
server_registers:
- address: 0x0001
value_type: U_WORD
read_lambda: |-
return 0x8;
- address: 0x0005
value_type: U_WORD
read_lambda: |-
return (random_uint32() % 100);
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller1
@@ -0,0 +1,41 @@
modbus:
- id: mod_bus2
uart_id: uart_bus
role: server
modbus_server:
- id: modbus_server2
address: 0x2
modbus_id: mod_bus2
registers:
- address: 0x0
value_type: S_DWORD_R
read_lambda: |-
return 42.3;
- id: modbus_server3
address: 0x3
modbus_id: mod_bus2
registers:
- address: 0x9
value_type: S_DWORD
read_lambda: |-
return 31;
write_lambda: |-
printf("address=%d, value=%d", x);
return true;
- id: modbus_server4
modbus_id: mod_bus2
address: 0x4
courtesy_response:
enabled: true
register_last_address: 100
register_value: 0
registers:
- address: 0x1
value_type: U_WORD
read_lambda: |-
return 0x8;
- address: 0x5
value_type: U_WORD
read_lambda: |-
return (random_uint32() % 100);
@@ -0,0 +1,4 @@
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,9 @@
nrf52:
dfu:
reset_pin:
number: 14
inverted: true
mode:
output: true
reg0:
voltage: 1.8V
@@ -1,9 +1,4 @@
nrf52:
dfu:
reset_pin:
number: 14
inverted: true
mode:
output: true
dfu: true
reg0:
voltage: 1.8V
@@ -0,0 +1,5 @@
ota:
- platform: esphome
allow_partition_access: true
<<: !include common.yaml
@@ -7,3 +7,4 @@ media_source:
initial_static_delay: 5ms
static_delay_adjustable: true
fixed_delay: 480us
decode_memory: internal
@@ -293,6 +293,60 @@ cover:
cover.is_closed: template_cover_with_triggers
then:
logger.log: Cover is closed
# Exercise cover.control / cover.template.publish action variants so they
# get build coverage in CI (and so memory-impact analysis on PRs that
# touch ControlAction / CoverPublishAction sees real instances).
- platform: template
name: "Template Cover Actions"
id: template_cover_actions
has_position: true
optimistic: true
open_action:
# CONF_STATE alias for the position bit
- cover.template.publish:
id: template_cover_actions
state: OPEN
- cover.template.publish:
id: template_cover_actions
position: 1.0
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
close_action:
- cover.template.publish:
id: template_cover_actions
position: 0.0
tilt: 0.0
stop_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
tilt_action:
- lambda: |-
id(template_cover_actions).tilt = tilt;
id(template_cover_actions).publish_state();
on_idle:
# position only
- cover.control:
id: template_cover_actions
position: 50%
# tilt only
- cover.control:
id: template_cover_actions
tilt: 75%
# position + tilt
- cover.control:
id: template_cover_actions
position: 25%
tilt: 30%
# stop
- cover.control:
id: template_cover_actions
stop: true
# CONF_STATE alias for position
- cover.control:
id: template_cover_actions
state: OPEN
number:
- platform: template
@@ -302,6 +356,19 @@ number:
min_value: 0
max_value: 100
step: 1
# Exercise valve.control inside a trigger with non-empty Ts (number on_value
# passes float).
- platform: template
id: template_valve_position_number
optimistic: true
min_value: 0
max_value: 100
step: 1
on_value:
then:
- valve.control:
id: template_valve
position: !lambda "return x / 100.0f;"
select:
- platform: template
@@ -388,6 +455,20 @@ valve:
state: CLOSED
stop_action:
- logger.log: stop_action
# Exercise valve.control with various field combinations so the
# ControlAction codegen paths get build coverage.
- valve.control:
id: template_valve
stop: true
- valve.control:
id: template_valve
position: 50%
- valve.control:
id: template_valve
state: OPEN
- valve.control:
id: template_valve
position: !lambda 'return 0.25f;'
optimistic: true
text:
@@ -1,6 +1,7 @@
wifi:
min_auth_mode: WPA2
post_connect_roaming: true
phy_mode: 11G
packages:
- !include common.yaml
+8
View File
@@ -0,0 +1,8 @@
esphome:
on_boot:
- lambda: |-
ESP_LOGD("test", "millis=%u micros=%u cycles=%u",
(unsigned) millis(), (unsigned) micros(),
(unsigned) arch_get_cpu_cycle_count());
delay(1);
delayMicroseconds(1);
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1,40 @@
esphome:
name: test-autorepeat-filter
host:
api:
batch_delay: 0ms # Disable batching to receive every state transition
logger:
level: DEBUG
binary_sensor:
# The autorepeat filter is applied directly to the template sensor, so each
# write through `binary_sensor.template.publish` runs through the filter
# chain. With the source true the filter must oscillate after `delay`; once
# the source returns to false the filter must cancel both timers and emit a
# final false.
- platform: template
name: "Autorepeat Sensor"
id: autorepeat_sensor
filters:
- autorepeat:
- delay: 200ms
time_off: 100ms
time_on: 100ms
button:
- platform: template
name: "Press"
id: press_button
on_press:
- binary_sensor.template.publish:
id: autorepeat_sensor
state: true
- platform: template
name: "Release"
id: release_button
on_press:
- binary_sensor.template.publish:
id: autorepeat_sensor
state: false
@@ -0,0 +1,92 @@
esphome:
name: climate-control-action-test
host:
api:
logger:
level: DEBUG
globals:
- id: test_target_temp
type: float
initial_value: "21.5"
sensor:
- platform: template
id: temp_sensor
name: "Temp"
lambda: 'return 20.0;'
update_interval: 60s
climate:
- platform: thermostat
id: test_climate
name: "Test Climate"
sensor: temp_sensor
min_idle_time: 30s
min_heating_off_time: 300s
min_heating_run_time: 300s
min_cooling_off_time: 300s
min_cooling_run_time: 300s
heat_action:
- logger.log: heating
idle_action:
- logger.log: idle
cool_action:
- logger.log: cooling
heat_cool_mode:
- logger.log: heat_cool
preset:
- name: Default
default_target_temperature_low: 18 °C
default_target_temperature_high: 22 °C
visual:
min_temperature: 10 °C
max_temperature: 30 °C
button:
# mode only
- platform: template
id: btn_mode
name: "Set Mode Heat"
on_press:
- climate.control:
id: test_climate
mode: HEAT
# mode + target_temperature_low + target_temperature_high
- platform: template
id: btn_mode_temps
name: "Set Mode Temps"
on_press:
- climate.control:
id: test_climate
mode: HEAT_COOL
target_temperature_low: 19.0 °C
target_temperature_high: 23.0 °C
# target_temperature_low only
- platform: template
id: btn_low_only
name: "Set Low Only"
on_press:
- climate.control:
id: test_climate
target_temperature_low: 17.5 °C
# Lambda path: target_temperature_high computed at runtime
- platform: template
id: btn_lambda_high
name: "Lambda High"
on_press:
- climate.control:
id: test_climate
target_temperature_high: !lambda "return id(test_target_temp);"
# mode only — turn off via mode
- platform: template
id: btn_off
name: "Set Off"
on_press:
- climate.control:
id: test_climate
mode: "OFF"
@@ -0,0 +1,111 @@
esphome:
name: cover-control-action-test
host:
api:
logger:
level: DEBUG
globals:
- id: test_position
type: float
initial_value: "0.42"
cover:
- platform: template
name: "Test Cover"
id: test_cover
has_position: true
optimistic: true
assumed_state: true
open_action:
- cover.template.publish:
id: test_cover
position: 1.0
close_action:
- cover.template.publish:
id: test_cover
position: 0.0
stop_action:
- cover.template.publish:
id: test_cover
current_operation: IDLE
tilt_action:
- lambda: |-
// Manually set tilt and publish
id(test_cover).tilt = tilt;
id(test_cover).publish_state();
button:
# cover.control: position only
- platform: template
id: btn_position
name: "Set Position"
on_press:
- cover.control:
id: test_cover
position: 50%
# cover.control: tilt only
- platform: template
id: btn_tilt
name: "Set Tilt"
on_press:
- cover.control:
id: test_cover
tilt: 75%
# cover.control: position + tilt
- platform: template
id: btn_pos_tilt
name: "Set Pos Tilt"
on_press:
- cover.control:
id: test_cover
position: 25%
tilt: 30%
# cover.control: state alias for position
- platform: template
id: btn_open_state
name: "Open State"
on_press:
- cover.control:
id: test_cover
state: OPEN
# cover.control: lambda position (exercises lambda path)
- platform: template
id: btn_lambda_position
name: "Lambda Position"
on_press:
- cover.control:
id: test_cover
position: !lambda "return id(test_position);"
# cover.template.publish: position only
- platform: template
id: btn_publish_pos
name: "Publish Pos"
on_press:
- cover.template.publish:
id: test_cover
position: 0.6
# cover.template.publish: current_operation only
- platform: template
id: btn_publish_op
name: "Publish Op"
on_press:
- cover.template.publish:
id: test_cover
current_operation: OPENING
# cover.control: stop only — runs after Publish Op so the test can
# verify current_operation transitions OPENING -> IDLE.
- platform: template
id: btn_stop
name: "Stop Cover"
on_press:
- cover.control:
id: test_cover
stop: true
@@ -0,0 +1,59 @@
esphome:
name: fan-turn-on-action-test
host:
api:
logger:
level: DEBUG
globals:
- id: test_speed
type: int
initial_value: "2"
fan:
- platform: template
id: test_fan
name: "Test Fan"
has_oscillating: true
has_direction: true
speed_count: 5
button:
# fan.turn_on: speed only
- platform: template
id: btn_speed
name: "Set Speed"
on_press:
- fan.turn_on:
id: test_fan
speed: 3
# fan.turn_on: oscillating + direction (no speed)
- platform: template
id: btn_oscillate_direction
name: "Set Oscillate Direction"
on_press:
- fan.turn_on:
id: test_fan
oscillating: true
direction: REVERSE
# fan.turn_on: all three fields
- platform: template
id: btn_all_fields
name: "Set All Fields"
on_press:
- fan.turn_on:
id: test_fan
oscillating: false
speed: 4
direction: FORWARD
# fan.turn_on: lambda for speed (exercises lambda path)
- platform: template
id: btn_lambda_speed
name: "Lambda Speed"
on_press:
- fan.turn_on:
id: test_fan
speed: !lambda "return id(test_speed);"
@@ -1,5 +1,5 @@
esphome:
name: host-climate-test
name: host-climate-basic-state
host:
api:
logger:
@@ -10,6 +10,7 @@ climate:
name: Dual-mode Thermostat
sensor: host_thermostat_temperature_sensor
humidity_sensor: host_thermostat_humidity_sensor
on_boot_restore_from: default_preset
humidity_hysteresis: 1.0
min_cooling_off_time: 20s
min_cooling_run_time: 20s
@@ -0,0 +1,60 @@
esphome:
name: light-dim-relative-action-test
host:
api:
logger:
level: DEBUG
output:
- platform: template
id: test_out
type: float
write_action:
- lambda: ""
light:
- platform: monochromatic
name: "Test Light"
id: test_light
output: test_out
default_transition_length: 0s
button:
# Set up: turn on at 50% brightness
- platform: template
id: btn_setup
name: "Setup"
on_press:
- light.turn_on:
id: test_light
brightness: 50%
# Test 1: dim_relative without transition_length (HasTransitionLength=false)
- platform: template
id: btn_dim_up
name: "Dim Up"
on_press:
- light.dim_relative:
id: test_light
relative_brightness: 25%
# Test 2: dim_relative with transition_length (HasTransitionLength=true)
- platform: template
id: btn_dim_down
name: "Dim Down"
on_press:
- light.dim_relative:
id: test_light
relative_brightness: -10%
transition_length: 0s
# Test 3: dim_relative with brightness limits
- platform: template
id: btn_dim_clamp
name: "Dim Clamp"
on_press:
- light.dim_relative:
id: test_light
relative_brightness: 50%
brightness_limits:
max_brightness: 80%
@@ -0,0 +1,37 @@
esphome:
name: light-toggle-action-test
host:
api:
logger:
level: DEBUG
output:
- platform: template
id: test_out
type: float
write_action:
- lambda: ""
light:
- platform: monochromatic
name: "Test Light"
id: test_light
output: test_out
default_transition_length: 0s
button:
# Test 1: light.toggle without transition_length (HasTransitionLength=false)
- platform: template
id: btn_toggle
name: "Toggle"
on_press:
- light.toggle: test_light
# Test 2: light.toggle with transition_length (HasTransitionLength=true)
- platform: template
id: btn_toggle_with_trans
name: "Toggle With Trans"
on_press:
- light.toggle:
id: test_light
transition_length: 0s
@@ -0,0 +1,112 @@
esphome:
debug_scheduler: true # Enable scheduler leak detection
name: scheduler-self-keyed-test
on_boot:
priority: -100
then:
- logger.log: "Starting scheduler self-keyed tests"
host:
api:
logger:
level: VERBOSE
globals:
- id: tests_done
type: bool
initial_value: 'false'
script:
- id: test_self_keyed
then:
- logger.log: "Testing self-keyed scheduler API"
- lambda: |-
// Two distinct keys backed by addresses of static markers — they
// must not collide even though both are self-keyed and share no
// Component pointer. Static storage gives them stable, unique
// addresses for the lifetime of the program.
static int key_a_marker = 0;
static int key_b_marker = 0;
void *key_a = &key_a_marker;
void *key_b = &key_b_marker;
// ---- Test 1: Self-keyed timeout fires ----
App.scheduler.set_timeout(key_a, 50, []() {
ESP_LOGI("test", "Self timeout A fired");
});
// ---- Test 2: Self-keyed cancel cancels only that key ----
App.scheduler.set_timeout(key_b, 100, []() {
ESP_LOGE("test", "ERROR: Self timeout B should have been cancelled");
});
App.scheduler.cancel_timeout(key_b);
// ---- Test 3: Two independent self keys don't collide ----
// Using fresh static markers so neither matches key_a / key_b.
static int key_c_marker = 0;
static int key_d_marker = 0;
void *key_c = &key_c_marker;
void *key_d = &key_d_marker;
App.scheduler.set_timeout(key_c, 150, []() {
ESP_LOGI("test", "Self timeout C fired");
});
App.scheduler.set_timeout(key_d, 150, []() {
ESP_LOGI("test", "Self timeout D fired");
});
// ---- Test 4: Self-keyed and component-keyed don't collide ----
// Use a self pointer that happens to look like a Component-attached id.
// The scheduler must treat them as separate namespaces.
static int shared_marker = 0;
void *self_shared = &shared_marker;
App.scheduler.set_timeout(self_shared, 200, []() {
ESP_LOGI("test", "Self timeout shared fired");
});
App.scheduler.set_timeout(id(test_sensor), 7777U, 200, []() {
ESP_LOGI("test", "Component timeout 7777 fired");
});
// ---- Test 5: Self-keyed interval fires multiple times then cancels ----
static int interval_count = 0;
static int key_e_marker = 0;
void *key_e = &key_e_marker;
App.scheduler.set_interval(key_e, 80, [key_e]() {
interval_count++;
if (interval_count == 2) {
ESP_LOGI("test", "Self interval E fired twice");
App.scheduler.cancel_interval(key_e);
}
});
// ---- Test 6: Re-registering same self-key replaces the timer ----
// The old timer must NOT fire; only the new one does.
static int key_f_marker = 0;
void *key_f = &key_f_marker;
App.scheduler.set_timeout(key_f, 250, []() {
ESP_LOGE("test", "ERROR: Self timeout F first registration should have been replaced");
});
App.scheduler.set_timeout(key_f, 300, []() {
ESP_LOGI("test", "Self timeout F replacement fired");
});
// Log completion after all timers should have fired
App.scheduler.set_timeout(id(test_sensor), 9999U, 1500, []() {
ESP_LOGI("test", "All self-keyed tests complete");
});
sensor:
- platform: template
name: Test Sensor
id: test_sensor
lambda: return 1.0;
update_interval: never
interval:
- interval: 0.1s
then:
- if:
condition:
lambda: 'return id(tests_done) == false;'
then:
- lambda: 'id(tests_done) = true;'
- script.execute: test_self_keyed
@@ -86,9 +86,9 @@ modbus:
uart_id: virtual_uart_dev
role: server
modbus_controller:
modbus_server:
- address: 1
server_registers:
registers:
- address: 0x03
value_type: U_WORD
read_lambda: |-
@@ -33,7 +33,7 @@ uart_mock:
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
@@ -56,10 +56,11 @@ modbus_controller:
update_interval: 1s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
server_registers:
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 99;
@@ -36,7 +36,7 @@ uart_mock:
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
@@ -48,7 +48,7 @@ uart_mock:
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
@@ -81,15 +81,16 @@ modbus_controller:
update_interval: 1s
id: modbus_controller_2
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
server_registers:
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 2
modbus_id: virtual_modbus_server_2
server_registers:
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
@@ -33,7 +33,7 @@ uart_mock:
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
@@ -94,10 +94,11 @@ modbus_controller:
update_interval: 2s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
server_registers:
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return id(stored_u_word);
@@ -0,0 +1,69 @@
esphome:
name: valve-control-action-test
host:
api:
logger:
level: DEBUG
globals:
- id: test_position
type: float
initial_value: "0.42"
valve:
- platform: template
name: "Test Valve"
id: test_valve
has_position: true
optimistic: true
assumed_state: true
open_action:
- valve.template.publish:
id: test_valve
position: 1.0
close_action:
- valve.template.publish:
id: test_valve
position: 0.0
stop_action:
- valve.template.publish:
id: test_valve
current_operation: IDLE
button:
# valve.control: position only
- platform: template
id: btn_position
name: "Set Position"
on_press:
- valve.control:
id: test_valve
position: 50%
# valve.control: state alias for position 1.0
- platform: template
id: btn_open_state
name: "Open State"
on_press:
- valve.control:
id: test_valve
state: OPEN
# valve.control: lambda position (exercises lambda path)
- platform: template
id: btn_lambda_position
name: "Lambda Position"
on_press:
- valve.control:
id: test_valve
position: !lambda "return id(test_position);"
# valve.control: stop only — template valve's stop_action publishes
# current_operation: IDLE.
- platform: template
id: btn_stop
name: "Stop Valve"
on_press:
- valve.control:
id: test_valve
stop: true
+37
View File
@@ -8,6 +8,7 @@ import logging
from typing import TypeVar
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
EntityInfo,
@@ -19,6 +20,42 @@ from aioesphomeapi import (
_LOGGER = logging.getLogger(__name__)
T = TypeVar("T", bound=EntityInfo)
S = TypeVar("S", bound=EntityState)
async def wait_for_state(
client: APIClient,
predicate: Callable[[EntityState], bool],
timeout: float = 5.0,
) -> EntityState:
"""Subscribe to states and wait for one matching ``predicate``.
Resolves with the first :class:`EntityState` for which ``predicate``
returns ``True``. Useful when a component publishes multiple states
during setup (e.g. before sensor readings arrive) and the test needs
to wait for the state to converge to expected values rather than
capturing whichever state happens to arrive first.
Args:
client: Connected API client.
predicate: Callable invoked for every received state; the first
state for which it returns ``True`` is returned.
timeout: Maximum time to wait in seconds.
Returns:
The first state matching ``predicate``.
Raises:
asyncio.TimeoutError: If no matching state arrives within ``timeout``.
"""
future: asyncio.Future[EntityState] = asyncio.get_running_loop().create_future()
def on_state(state: EntityState) -> None:
if not future.done() and predicate(state):
future.set_result(state)
client.subscribe_states(on_state)
return await asyncio.wait_for(future, timeout=timeout)
def find_entity(
@@ -0,0 +1,123 @@
"""Integration test for the binary_sensor autorepeat filter.
Verifies that the autorepeat filter:
1. Passes the initial true through unchanged.
2. Begins oscillating after the configured ``delay`` while the source stays true.
3. Stops oscillating and emits a final false when the source goes false.
This exercises both scheduled timers in ``AutorepeatFilter`` (the per-step
``delay`` timer keyed off the filter ``this`` pointer and the on/off toggle
timer keyed off ``&active_timing_``).
"""
from __future__ import annotations
import asyncio
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_binary_sensor_autorepeat_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Drive the source true and verify the downstream sensor oscillates."""
collector = SensorStateCollector(
sensor_names=[],
binary_sensor_names=["autorepeat_sensor"],
)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "test-autorepeat-filter"
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
press_button = require_entity(entities, "press", description="Press button")
release_button = require_entity(
entities, "release", description="Release button"
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
autorepeat_states = collector.binary_states["autorepeat_sensor"]
# Press: source becomes true, autorepeat passes the initial true through
# and then oscillates after the configured delay.
# Configured timings: delay=200ms, time_on=100ms, time_off=100ms.
# Expected within ~700ms:
# true (0ms), false (200ms), true (300ms), false (400ms),
# true (500ms), false (600ms)
client.button_command(press_button.key)
# Wait for at least 5 transitions to verify the oscillation pattern.
oscillation_seen = collector.add_waiter(lambda: len(autorepeat_states) >= 5)
try:
await asyncio.wait_for(oscillation_seen, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Expected at least 5 autorepeat transitions, got {autorepeat_states}"
)
assert autorepeat_states[0] is True, (
f"First transition should be the pass-through true, got {autorepeat_states}"
)
# After the initial true and the configured delay, the filter must
# toggle false/true/false/... — verify the alternation pattern.
for index, value in enumerate(autorepeat_states):
expected = index % 2 == 0
assert value is expected, (
f"Expected alternating values starting with True, "
f"got {autorepeat_states} (mismatch at index {index})"
)
# Release: source becomes false, autorepeat must cancel both timers
# and settle on false. If the most recent oscillation was already
# false, the binary sensor will dedup and not emit a new state event;
# if it was true, exactly one final false transition arrives. Either
# way, the steady state must be false and no further toggles should
# arrive after a settle window longer than time_on + time_off.
was_true_before_release = autorepeat_states[-1] is True
before_count = len(autorepeat_states)
client.button_command(release_button.key)
if was_true_before_release:
settle_seen = collector.add_waiter(
lambda: len(autorepeat_states) > before_count
)
try:
await asyncio.wait_for(settle_seen, timeout=2.0)
except TimeoutError:
pytest.fail("Timeout waiting for autorepeat to settle to false")
assert autorepeat_states[-1] is False, (
f"After release, final state should be False, got {autorepeat_states}"
)
steady_count = len(autorepeat_states)
await asyncio.sleep(0.5)
assert len(autorepeat_states) == steady_count, (
f"Expected no further toggles after release, "
f"got {autorepeat_states[steady_count:]}"
)
assert autorepeat_states[-1] is False, (
f"Final autorepeat state should be False, got {autorepeat_states}"
)
@@ -0,0 +1,84 @@
"""Integration test for climate ControlAction.
Tests that climate.control automation actions work correctly with the
single stateless apply lambda/function pointer implementation. Exercises
multiple field combinations and the lambda path.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import (
ButtonInfo,
ClimateInfo,
ClimateMode,
ClimateState,
EntityState,
)
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_climate_control_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test climate ControlAction with constants and lambdas."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
climate_state_future: asyncio.Future[ClimateState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, ClimateState)
and climate_state_future is not None
and not climate_state_future.done()
):
climate_state_future.set_result(state)
async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState:
nonlocal climate_state_future
climate_state_future = loop.create_future()
try:
return await asyncio.wait_for(climate_state_future, timeout)
finally:
climate_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_climate", ClimateInfo)
async def press_and_wait(name: str) -> ClimateState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_climate_state()
# mode only — set HEAT
state = await press_and_wait("Set Mode Heat")
assert state.mode == ClimateMode.HEAT
# mode + target_temperature_low + target_temperature_high
state = await press_and_wait("Set Mode Temps")
assert state.mode == ClimateMode.HEAT_COOL
assert state.target_temperature_low == pytest.approx(19.0, abs=0.5)
assert state.target_temperature_high == pytest.approx(23.0, abs=0.5)
# target_temperature_low only
state = await press_and_wait("Set Low Only")
assert state.target_temperature_low == pytest.approx(17.5, abs=0.5)
# lambda path: target_temperature_high computed at runtime
state = await press_and_wait("Lambda High")
assert state.target_temperature_high == pytest.approx(21.5, abs=0.5)
# mode only — turn off via mode
state = await press_and_wait("Set Off")
assert state.mode == ClimateMode.OFF
@@ -0,0 +1,92 @@
"""Integration test for cover ControlAction and CoverPublishAction.
Tests that cover.control and cover.template.publish automation actions
work correctly with the single stateless apply lambda/function pointer
implementation. Exercises multiple field combinations and the lambda path.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_cover_control_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test cover ControlAction/CoverPublishAction with constants and lambdas."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
cover_state_future: asyncio.Future[CoverState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, CoverState)
and cover_state_future is not None
and not cover_state_future.done()
):
cover_state_future.set_result(state)
async def wait_for_cover_state(timeout: float = 5.0) -> CoverState:
nonlocal cover_state_future
cover_state_future = loop.create_future()
try:
return await asyncio.wait_for(cover_state_future, timeout)
finally:
cover_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_cover", CoverInfo)
async def press_and_wait(name: str) -> CoverState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_cover_state()
# cover.control: position only
state = await press_and_wait("Set Position")
assert state.position == pytest.approx(0.5, abs=0.01)
# cover.control: tilt only
state = await press_and_wait("Set Tilt")
assert state.tilt == pytest.approx(0.75, abs=0.01)
# cover.control: position + tilt
state = await press_and_wait("Set Pos Tilt")
assert state.position == pytest.approx(0.25, abs=0.01)
assert state.tilt == pytest.approx(0.30, abs=0.01)
# cover.control: state alias for position 1.0
state = await press_and_wait("Open State")
assert state.position == pytest.approx(1.0, abs=0.01)
# cover.control: lambda position (test_position global = 0.42)
state = await press_and_wait("Lambda Position")
assert state.position == pytest.approx(0.42, abs=0.01)
# cover.template.publish: position only
state = await press_and_wait("Publish Pos")
assert state.position == pytest.approx(0.6, abs=0.01)
# cover.template.publish: current_operation only
state = await press_and_wait("Publish Op")
# CoverOperation.OPENING == 1
assert state.current_operation == 1
# cover.control: stop only — template cover's stop_action publishes
# current_operation: IDLE.
state = await press_and_wait("Stop Cover")
# CoverOperation.IDLE == 0
assert state.current_operation == 0
@@ -0,0 +1,75 @@
"""Integration test for fan TurnOnAction.
Tests that fan.turn_on automation actions work correctly across multiple
field combinations and the lambda path.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, FanDirection, FanInfo, FanState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_fan_turn_on_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test fan TurnOnAction with constants and a lambda."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
fan_state_future: asyncio.Future[FanState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, FanState)
and fan_state_future is not None
and not fan_state_future.done()
):
fan_state_future.set_result(state)
async def wait_for_fan_state(timeout: float = 5.0) -> FanState:
nonlocal fan_state_future
fan_state_future = loop.create_future()
try:
return await asyncio.wait_for(fan_state_future, timeout)
finally:
fan_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_fan", FanInfo)
async def press_and_wait(name: str) -> FanState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_fan_state()
# speed only
state = await press_and_wait("Set Speed")
assert state.state is True
assert state.speed_level == 3
# oscillating + direction
state = await press_and_wait("Set Oscillate Direction")
assert state.oscillating is True
assert state.direction == FanDirection.REVERSE
# all three fields
state = await press_and_wait("Set All Fields")
assert state.oscillating is False
assert state.speed_level == 4
assert state.direction == FanDirection.FORWARD
# lambda path: speed computed at runtime (test_speed global = 2)
state = await press_and_wait("Lambda Speed")
assert state.speed_level == 2
@@ -2,11 +2,17 @@
from __future__ import annotations
import aioesphomeapi
from aioesphomeapi import ClimateAction, ClimateInfo, ClimateMode, ClimatePreset
from aioesphomeapi import (
ClimateAction,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateState,
EntityState,
)
import pytest
from .state_utils import InitialStateHelper
from .state_utils import wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -18,32 +24,30 @@ async def test_host_mode_climate_basic_state(
) -> None:
"""Test basic climate state reporting."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
entities, _ = await client.list_entities_services()
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
# Subscribe with the wrapper (no-op callback since we just want initial states)
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda _: None))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Get the climate entity and its initial state
test_climate = climate_infos[0]
climate_state = initial_state_helper.initial_states.get(test_climate.key)
assert climate_state is not None, "Climate initial state not found"
assert isinstance(climate_state, aioesphomeapi.ClimateState)
assert climate_state.mode == ClimateMode.OFF
assert climate_state.action == ClimateAction.OFF
assert climate_state.current_temperature == 22.0
assert climate_state.target_temperature_low == 18.0
assert climate_state.target_temperature_high == 24.0
assert climate_state.preset == ClimatePreset.HOME
assert climate_state.current_humidity == 42.0
assert climate_state.target_humidity == 20.0
# The thermostat publishes multiple states during setup as the
# temperature/humidity sensors come online. Wait for the state to
# converge to the expected default values rather than relying on
# whichever state happens to arrive first.
def is_default_state(state: EntityState) -> bool:
return (
isinstance(state, ClimateState)
and state.key == test_climate.key
and state.mode == ClimateMode.OFF
and state.action == ClimateAction.OFF
and state.current_temperature == 22.0
and state.target_temperature_low == 18.0
and state.target_temperature_high == 24.0
and state.preset == ClimatePreset.HOME
and state.current_humidity == 42.0
and state.target_humidity == 20.0
)
try:
await wait_for_state(client, is_default_state)
except TimeoutError:
pytest.fail("Climate did not converge to expected default state")
@@ -0,0 +1,72 @@
"""Integration test for light::DimRelativeAction.
Tests both DimRelativeAction<HasTransitionLength=false> and
DimRelativeAction<HasTransitionLength=true> instantiations.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_dim_relative_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test light.dim_relative with and without transition_length."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
light_state_future: asyncio.Future[LightState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, LightState)
and light_state_future is not None
and not light_state_future.done()
):
light_state_future.set_result(state)
async def wait_for_light_state(timeout: float = 5.0) -> LightState:
nonlocal light_state_future
light_state_future = loop.create_future()
try:
return await asyncio.wait_for(light_state_future, timeout)
finally:
light_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_light", LightInfo)
async def press_and_wait(name: str) -> LightState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_light_state()
# Setup: turn on at 50%
state = await press_and_wait("Setup")
assert state.state is True
assert state.brightness == pytest.approx(0.5, abs=0.05)
# Test 1: dim_relative without transition_length: 50% + 25% = 75%
state = await press_and_wait("Dim Up")
assert state.brightness == pytest.approx(0.75, abs=0.05)
# Test 2: dim_relative with transition_length: 75% - 10% = 65%
state = await press_and_wait("Dim Down")
assert state.brightness == pytest.approx(0.65, abs=0.05)
# Test 3: dim_relative with max_brightness limit: 65% + 50% clamped to 80%
state = await press_and_wait("Dim Clamp")
assert state.brightness == pytest.approx(0.80, abs=0.05)
@@ -0,0 +1,67 @@
"""Integration test for light::ToggleAction.
Tests both ToggleAction<HasTransitionLength=false> and
ToggleAction<HasTransitionLength=true> instantiations.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_toggle_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test light.toggle with and without transition_length."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
light_state_future: asyncio.Future[LightState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, LightState)
and light_state_future is not None
and not light_state_future.done()
):
light_state_future.set_result(state)
async def wait_for_light_state(timeout: float = 5.0) -> LightState:
nonlocal light_state_future
light_state_future = loop.create_future()
try:
return await asyncio.wait_for(light_state_future, timeout)
finally:
light_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_light", LightInfo)
async def press_and_wait(name: str) -> LightState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_light_state()
# Test 1: toggle without transition_length flips off->on
state = await press_and_wait("Toggle")
assert state.state is True
# Test 2: toggle with transition_length flips on->off
state = await press_and_wait("Toggle With Trans")
assert state.state is False
# Test 3: toggle without transition_length flips off->on again
state = await press_and_wait("Toggle")
assert state.state is True
@@ -0,0 +1,96 @@
"""Test the self-keyed scheduler API.
Verifies that `Scheduler::set_timeout(const void *, ...)` /
`set_interval(const void *, ...)` and the matching `cancel_*(const void *)`
overloads behave correctly: callbacks fire, distinct keys don't collide,
self-keyed and component-keyed namespaces are independent, and re-registering
the same key replaces the existing timer.
"""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_self_keyed(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test self-keyed scheduler API."""
self_a_fired = asyncio.Event()
self_b_error = asyncio.Event()
self_c_fired = asyncio.Event()
self_d_fired = asyncio.Event()
self_shared_fired = asyncio.Event()
component_7777_fired = asyncio.Event()
self_interval_done = asyncio.Event()
self_f_first_error = asyncio.Event()
self_f_replacement_fired = asyncio.Event()
all_tests_complete = asyncio.Event()
def on_log_line(line: str) -> None:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "Self timeout A fired" in clean_line:
self_a_fired.set()
elif "ERROR: Self timeout B" in clean_line:
self_b_error.set()
elif "Self timeout C fired" in clean_line:
self_c_fired.set()
elif "Self timeout D fired" in clean_line:
self_d_fired.set()
elif "Self timeout shared fired" in clean_line:
self_shared_fired.set()
elif "Component timeout 7777 fired" in clean_line:
component_7777_fired.set()
elif "Self interval E fired twice" in clean_line:
self_interval_done.set()
elif "ERROR: Self timeout F first registration" in clean_line:
self_f_first_error.set()
elif "Self timeout F replacement fired" in clean_line:
self_f_replacement_fired.set()
elif "All self-keyed tests complete" in clean_line:
all_tests_complete.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-self-keyed-test"
try:
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("Not all self-keyed tests completed within 5 seconds")
# Test 1: self-keyed timeout fires
assert self_a_fired.is_set(), "Self timeout A should have fired"
# Test 2: cancel_timeout(self) actually cancels
assert not self_b_error.is_set(), "Self timeout B should have been cancelled"
# Test 3: distinct self keys don't collide
assert self_c_fired.is_set(), "Self timeout C should have fired"
assert self_d_fired.is_set(), "Self timeout D should have fired"
# Test 4: self-keyed and component-keyed namespaces are independent
assert self_shared_fired.is_set(), "Self timeout shared should have fired"
assert component_7777_fired.is_set(), "Component timeout 7777 should have fired"
# Test 5: self-keyed interval fires repeatedly and cancels cleanly
assert self_interval_done.is_set(), "Self interval E should have fired twice"
# Test 6: re-registering same self-key replaces the previous timer
assert not self_f_first_error.is_set(), (
"Self timeout F first registration should have been replaced"
)
assert self_f_replacement_fired.is_set(), (
"Self timeout F replacement should have fired"
)
@@ -0,0 +1,72 @@
"""Integration test for valve ControlAction.
Tests that valve.control automation actions work correctly across multiple
field combinations and the lambda path.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, ValveInfo, ValveOperation, ValveState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_valve_control_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test valve ControlAction with constants and a lambda."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
valve_state_future: asyncio.Future[ValveState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, ValveState)
and valve_state_future is not None
and not valve_state_future.done()
):
valve_state_future.set_result(state)
async def wait_for_valve_state(timeout: float = 5.0) -> ValveState:
nonlocal valve_state_future
valve_state_future = loop.create_future()
try:
return await asyncio.wait_for(valve_state_future, timeout)
finally:
valve_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_valve", ValveInfo)
async def press_and_wait(name: str) -> ValveState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_valve_state()
# valve.control: position only
state = await press_and_wait("Set Position")
assert state.position == pytest.approx(0.5, abs=0.01)
# valve.control: state alias for position 1.0
state = await press_and_wait("Open State")
assert state.position == pytest.approx(1.0, abs=0.01)
# valve.control: lambda position (test_position global = 0.42)
state = await press_and_wait("Lambda Position")
assert state.position == pytest.approx(0.42, abs=0.01)
# valve.control: stop only — template valve's stop_action publishes
# current_operation: IDLE.
state = await press_and_wait("Stop Valve")
assert state.current_operation == ValveOperation.IDLE
+191
View File
@@ -0,0 +1,191 @@
"""Unit tests for script/check_import_time.py."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import sys
from unittest.mock import patch
import pytest
# Load the script-under-test as `check_import_time` (it's a hyphenated path
# inside `script/` that mirrors the existing `determine_jobs` pattern).
script_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "script")
)
sys.path.insert(0, script_dir)
spec = importlib.util.spec_from_file_location(
"check_import_time", os.path.join(script_dir, "check_import_time.py")
)
check_import_time = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_import_time)
def _entry(name: str, self_us: int, cumulative_us: int) -> dict:
"""Build a minimal HAR entry matching `importtime_waterfall --har`."""
return {
"request": {"url": name},
"time": cumulative_us,
"timings": {"receive": self_us, "wait": cumulative_us - self_us},
}
def _har(*entries: dict) -> dict:
return {"log": {"entries": list(entries)}}
def test_root_cumulative_us_returns_time_for_root_module() -> None:
har = _har(
_entry("dep_a", 500, 500),
_entry("dep_b", 300, 300),
_entry("esphome.__main__", 100, 1000),
)
assert check_import_time.root_cumulative_us(har, "esphome.__main__") == 1000
def test_root_cumulative_us_missing_module_raises() -> None:
har = _har(_entry("something.else", 100, 100))
with pytest.raises(RuntimeError, match="No HAR entry for 'esphome.__main__'"):
check_import_time.root_cumulative_us(har, "esphome.__main__")
def test_top_offenders_ranks_by_self_time_descending() -> None:
har = _har(
_entry("small", 100, 100),
_entry("big", 5000, 5000),
_entry("medium", 2000, 2500),
)
result = check_import_time.top_offenders(har, n=10)
assert [name for name, _, _ in result] == ["big", "medium", "small"]
assert result[0] == ("big", 5000, 5000)
def test_top_offenders_respects_n_limit() -> None:
har = _har(*[_entry(f"m{i}", i * 100, i * 100) for i in range(1, 20)])
assert len(check_import_time.top_offenders(har, n=5)) == 5
def test_top_offenders_dedupes_repeat_names_keeping_first() -> None:
har = _har(
_entry("pkg", 5000, 5000),
_entry("pkg", 100, 100), # reimport later in trace
_entry("other", 1000, 1000),
)
result = check_import_time.top_offenders(har, n=10)
assert [name for name, _, _ in result] == ["pkg", "other"]
# First occurrence wins
assert ("pkg", 5000, 5000) in result
def test_format_us_switches_to_ms_at_threshold() -> None:
assert check_import_time._format_us(500) == "500us"
assert check_import_time._format_us(999) == "999us"
assert check_import_time._format_us(1000) == "1.0ms"
assert check_import_time._format_us(12345) == "12.3ms"
def test_read_write_budget_roundtrip(tmp_path: Path) -> None:
budget_path = tmp_path / "budget.json"
with patch.object(check_import_time, "BUDGET_PATH", budget_path):
assert check_import_time.read_budget() == {}
check_import_time.write_budget(cumulative_us=12345, margin_pct=20)
loaded = check_import_time.read_budget()
assert loaded["cumulative_us"] == 12345
assert loaded["margin_pct"] == 20
assert loaded["target_module"] == check_import_time.TARGET_MODULE
def test_cmd_check_passes_when_measured_within_ceiling(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
budget_path = tmp_path / "budget.json"
budget_path.write_text(
json.dumps(
{
"target_module": check_import_time.TARGET_MODULE,
"margin_pct": 15,
"cumulative_us": 100000, # 100ms
}
)
)
# Measured 90ms: inside 100ms + 15% = 115ms ceiling
har = _har(_entry(check_import_time.TARGET_MODULE, 1000, 90000))
args = type("A", (), {"har": None})()
with (
patch.object(check_import_time, "BUDGET_PATH", budget_path),
patch.object(check_import_time, "measure", return_value=har),
):
rc = check_import_time.cmd_check(args)
assert rc == 0
out = capsys.readouterr().out
assert "measured esphome.__main__:" in out
assert "budget 100.0ms" in out
def test_cmd_check_fails_when_measured_exceeds_ceiling(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
budget_path = tmp_path / "budget.json"
budget_path.write_text(
json.dumps(
{
"target_module": check_import_time.TARGET_MODULE,
"margin_pct": 15,
"cumulative_us": 100000,
}
)
)
# Measured 120ms: over 100ms + 15% = 115ms ceiling
har = _har(
_entry("offender_a", 10000, 10000),
_entry(check_import_time.TARGET_MODULE, 1000, 120000),
)
args = type("A", (), {"har": None})()
with (
patch.object(check_import_time, "BUDGET_PATH", budget_path),
patch.object(check_import_time, "measure", return_value=har),
):
rc = check_import_time.cmd_check(args)
assert rc == 1
err = capsys.readouterr().err
assert "REGRESSION" in err
assert "120.0ms" in err
assert "offender_a" in err # top offender table
def test_cmd_check_returns_2_when_budget_missing(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
budget_path = tmp_path / "nonexistent.json"
args = type("A", (), {"har": None})()
with patch.object(check_import_time, "BUDGET_PATH", budget_path):
rc = check_import_time.cmd_check(args)
assert rc == 2
assert "missing" in capsys.readouterr().err
def test_cmd_check_writes_har_when_path_given(tmp_path: Path) -> None:
budget_path = tmp_path / "budget.json"
budget_path.write_text(
json.dumps(
{
"target_module": check_import_time.TARGET_MODULE,
"margin_pct": 15,
"cumulative_us": 100000,
}
)
)
har_path = tmp_path / "out.har"
har_text = json.dumps(_har(_entry(check_import_time.TARGET_MODULE, 1000, 80000)))
args = type("A", (), {"har": str(har_path)})()
with (
patch.object(check_import_time, "BUDGET_PATH", budget_path),
patch.object(check_import_time, "run_waterfall", return_value=har_text),
):
rc = check_import_time.cmd_check(args)
assert rc == 0
assert har_path.exists()
assert json.loads(har_path.read_text()) == json.loads(har_text)
+236 -6
View File
@@ -56,6 +56,20 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture
def mock_should_run_import_time() -> Generator[Mock, None, None]:
"""Mock should_run_import_time from determine_jobs."""
with patch.object(determine_jobs, "should_run_import_time") as mock:
yield mock
@pytest.fixture
def mock_should_run_device_builder() -> Generator[Mock, None, None]:
"""Mock should_run_device_builder from determine_jobs."""
with patch.object(determine_jobs, "should_run_device_builder") as mock:
yield mock
@pytest.fixture
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
"""Mock determine_cpp_unit_tests from helpers."""
@@ -91,6 +105,8 @@ def test_main_all_tests_should_run(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -104,6 +120,8 @@ def test_main_all_tests_should_run(
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -113,10 +131,19 @@ def test_main_all_tests_should_run(
"esphome/helpers.py",
]
# Stable, deterministic stand-in for the tests/integration/ glob so the
# bucket assertions don't drift with the real test count.
fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
# Run main function with mocked argv
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs,
"_all_integration_test_files",
return_value=fake_test_files,
),
patch.object(
determine_jobs,
"get_changed_components",
@@ -152,12 +179,30 @@ def test_main_all_tests_should_run(
output = json.loads(captured.out)
assert output["integration_tests"] is True
assert output["integration_tests_run_all"] is True
assert output["integration_test_files"] == []
# run_all=True expands to the full glob and pre-buckets into 3 parts.
# Each bucket's `tests` is a JSON list of file paths.
assert isinstance(output["integration_test_buckets"], list)
assert len(output["integration_test_buckets"]) == 3
assert [b["name"] for b in output["integration_test_buckets"]] == [
"1/3",
"2/3",
"3/3",
]
for bucket in output["integration_test_buckets"]:
assert isinstance(bucket["tests"], list)
for path in bucket["tests"]:
assert isinstance(path, str)
bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]]
assert bucket_files == fake_test_files
# Bucket sizes are balanced (max-min difference at most 1).
sizes = [len(b["tests"]) for b in output["integration_test_buckets"]]
assert max(sizes) - min(sizes) <= 1
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["changed_components"] == ["wifi", "api", "sensor"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -189,6 +234,8 @@ def test_main_no_tests_should_run(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -202,6 +249,8 @@ def test_main_no_tests_should_run(
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files
@@ -235,12 +284,13 @@ def test_main_no_tests_should_run(
output = json.loads(captured.out)
assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["integration_test_buckets"] == []
assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["import_time"] is False
assert output["device_builder"] is False
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
@@ -261,6 +311,8 @@ def test_main_with_branch_argument(
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -274,6 +326,8 @@ def test_main_with_branch_argument(
mock_should_run_clang_tidy.return_value = True
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -310,18 +364,21 @@ def test_main_with_branch_argument(
mock_should_run_clang_tidy.assert_called_once_with("main")
mock_should_run_clang_format.assert_called_once_with("main")
mock_should_run_python_linters.assert_called_once_with("main")
mock_should_run_import_time.assert_called_once_with("main")
mock_should_run_device_builder.assert_called_once_with("main")
# Check output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["integration_test_buckets"] == []
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["changed_components"] == ["mqtt"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -340,6 +397,59 @@ def test_main_with_branch_argument(
assert output["cpp_unit_tests_components"] == ["mqtt"]
def test_compute_integration_test_buckets_empty() -> None:
"""No integration tests scheduled => (False, [])."""
run, buckets = determine_jobs._compute_integration_test_buckets(False, [])
assert run is False
assert buckets == []
def test_compute_integration_test_buckets_below_threshold() -> None:
"""A small explicit list (<= threshold) => single 1/1 bucket with that list."""
files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert buckets == [{"name": "1/1", "tests": sorted(files)}]
def test_compute_integration_test_buckets_at_threshold_stays_single() -> None:
"""Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket
(the split kicks in only when count is strictly greater than threshold)."""
files = [
f"tests/integration/test_{i:02d}.py"
for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD)
]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert len(buckets) == 1
assert buckets[0]["name"] == "1/1"
assert buckets[0]["tests"] == sorted(files)
def test_compute_integration_test_buckets_just_over_threshold_splits() -> None:
"""One file over the threshold triggers the 3-bucket fan-out, balanced."""
n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1
files = [f"tests/integration/test_{i:02d}.py" for i in range(n)]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"]
union = [path for b in buckets for path in b["tests"]]
assert union == sorted(files)
sizes = [len(b["tests"]) for b in buckets]
assert max(sizes) - min(sizes) <= 1
def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> (
None
):
"""run_all=True but glob returns no files => run suppressed (otherwise
pytest would collect tests outside tests/integration/)."""
with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]):
run, buckets = determine_jobs._compute_integration_test_buckets(True, [])
assert run is False
assert buckets == []
def test_determine_integration_tests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -597,6 +707,126 @@ def test_should_run_python_linters_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
# esphome Python files trigger the check
(["esphome/__main__.py"], True),
(["esphome/components/wifi/__init__.py"], True),
(["esphome/core/config.py"], True),
(["esphome/types.pyi"], True),
# Dependency declarations and the check's own files trigger
(["requirements.txt"], True),
(["requirements_dev.txt"], True),
(["requirements_test.txt"], True),
(["pyproject.toml"], True),
(["script/check_import_time.py"], True),
(["script/import_time_budget.json"], True),
# Mixed: any triggering file is enough
(["docs/README.md", "esphome/config.py"], True),
# Python files outside esphome/ don't trigger
(["script/some_other_script.py"], False),
(["tests/script/test_determine_jobs.py"], False),
# Non-Python changes don't trigger
(["esphome/core/component.cpp"], False),
(["tests/components/wifi/test.esp32-idf.yaml"], False),
(["README.md"], False),
([], False),
],
)
def test_should_run_import_time(
changed_files: list[str], expected_result: bool
) -> None:
"""Test should_run_import_time function."""
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
result = determine_jobs.should_run_import_time()
assert result == expected_result
def test_should_run_import_time_with_branch() -> None:
"""Test should_run_import_time with branch argument."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.should_run_import_time("release")
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
# esphome Python files trigger downstream device-builder tests
(["esphome/__main__.py"], True),
(["esphome/components/wifi/__init__.py"], True),
(["esphome/core/config.py"], True),
(["esphome/types.pyi"], True),
# Runtime dependency changes trigger
(["requirements.txt"], True),
(["pyproject.toml"], True),
# Non-C++ files packaged with esphome trigger -- device-builder
# picks them up because esphome's pyproject sets
# include-package-data = true.
(["esphome/idf_component.yml"], True),
(["esphome/dashboard/templates/index.html"], True),
(["esphome/components/api/api_pb2_service.json"], True),
# Mixed: any triggering file is enough
(["docs/README.md", "esphome/config.py"], True),
# Dev/test-only dependency changes don't trigger device-builder
# (they don't affect the importable surface device-builder uses)
(["requirements_dev.txt"], False),
(["requirements_test.txt"], False),
# Files outside esphome/ don't trigger
(["script/some_other_script.py"], False),
(["tests/script/test_determine_jobs.py"], False),
# C++ files under esphome/ don't trigger -- they only affect
# compiled firmware, not the Python install device-builder pulls in.
(["esphome/core/component.cpp"], False),
(["esphome/core/component.h"], False),
(["esphome/components/wifi/wifi_component.cpp"], False),
# Files outside esphome/ entirely
(["tests/components/wifi/test.esp32-idf.yaml"], False),
(["README.md"], False),
([], False),
],
)
def test_should_run_device_builder(
changed_files: list[str], expected_result: bool
) -> None:
"""Test should_run_device_builder function (non-beta/release target)."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
# Mock target branch to "dev" so the beta/release skip is bypassed
# for these per-file behavior checks.
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
):
result = determine_jobs.should_run_device_builder()
assert result == expected_result
def test_should_run_device_builder_with_branch() -> None:
"""Test should_run_device_builder with branch argument."""
with (
patch.object(determine_jobs, "changed_files") as mock_changed,
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
):
mock_changed.return_value = []
determine_jobs.should_run_device_builder("release")
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"])
def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None:
"""Beta/release target branches skip device-builder (lag behind device-builder@main)."""
with (
patch.object(determine_jobs, "get_target_branch", return_value=target_branch),
patch.object(determine_jobs, "changed_files") as mock_changed,
):
# Even with a triggering file present, the target-branch guard wins.
mock_changed.return_value = ["esphome/__main__.py"]
assert determine_jobs.should_run_device_builder() is False
# changed_files shouldn't even be consulted -- the guard short-circuits.
mock_changed.assert_not_called()
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
+158
View File
@@ -258,3 +258,161 @@ def test_load_wraps_platform_component(tmp_path: Path) -> None:
assert key == "bthome.sensor"
assert isinstance(installed, ComponentManifestOverride)
assert installed.to_code is None
# ---------------------------------------------------------------------------
# populate_dependency_config
# ---------------------------------------------------------------------------
def _make_component_stub(
*,
multi_conf: bool = False,
is_platform_component: bool = False,
config_schema=None,
) -> MagicMock:
stub = MagicMock()
stub.multi_conf = multi_conf
stub.is_platform_component = is_platform_component
stub.config_schema = config_schema
return stub
def test_populate_platform_component_listed_alone_uses_list() -> None:
"""Regression: a platform component (sensor) with no `sensor.x` siblings
must land as `[]` in config. Previously it was populated as a dict via
`schema({})`, which then crashed the sibling `domain.platform` branch
when later dependencies tried `config.setdefault('sensor', []).append(...)`.
"""
sensor = _make_component_stub(is_platform_component=True)
config: dict = {}
build_helpers.populate_dependency_config(
config,
["sensor"],
get_component_fn=lambda name: sensor if name == "sensor" else None,
register_platform_fn=lambda _: None,
)
assert config["sensor"] == []
def test_populate_platform_component_then_platform_entry() -> None:
"""When `sensor` is processed before `sensor.gpio` (sorted order),
the bare-component branch must leave `config['sensor']` as a list so
the platform-entry branch can append into it.
"""
sensor = _make_component_stub(is_platform_component=True)
gpio = _make_component_stub() # the bare `gpio` component
components: dict[str, object] = {"sensor": sensor, "gpio": gpio}
config: dict = {}
build_helpers.populate_dependency_config(
config,
["gpio", "sensor", "sensor.gpio"],
get_component_fn=components.get,
register_platform_fn=lambda _: None,
)
assert config["sensor"] == [{"platform": "gpio"}]
def test_populate_multi_conf_component_uses_list() -> None:
multi = _make_component_stub(multi_conf=True)
config: dict = {}
build_helpers.populate_dependency_config(
config,
["multi"],
get_component_fn=lambda name: multi if name == "multi" else None,
register_platform_fn=lambda _: None,
)
assert config["multi"] == []
def test_populate_plain_component_uses_schema_defaults() -> None:
schema = MagicMock(return_value={"default_key": 42})
plain = _make_component_stub(config_schema=schema)
config: dict = {}
build_helpers.populate_dependency_config(
config,
["plain"],
get_component_fn=lambda name: plain if name == "plain" else None,
register_platform_fn=lambda _: None,
)
schema.assert_called_once_with({})
assert config["plain"] == {"default_key": 42}
def test_populate_plain_component_falls_back_when_schema_raises() -> None:
def picky_schema(_):
raise ValueError("required field missing")
plain = _make_component_stub(config_schema=picky_schema)
config: dict = {}
build_helpers.populate_dependency_config(
config,
["plain"],
get_component_fn=lambda name: plain if name == "plain" else None,
register_platform_fn=lambda _: None,
)
assert config["plain"] == {}
def test_populate_skips_unresolvable_pseudo_components() -> None:
"""`core` and other names that get_component returns None for are skipped
silently without inserting anything into the config.
"""
config: dict = {}
build_helpers.populate_dependency_config(
config,
["core"],
get_component_fn=lambda _: None,
register_platform_fn=lambda _: None,
)
assert config == {}
def test_populate_preserves_existing_plain_component_config() -> None:
"""If a plain component already has a config entry (e.g. from the user's
YAML), the schema-defaults branch must not overwrite it.
"""
schema = MagicMock()
plain = _make_component_stub(config_schema=schema)
config: dict = {"plain": {"user_key": "set_by_user"}}
build_helpers.populate_dependency_config(
config,
["plain"],
get_component_fn=lambda name: plain if name == "plain" else None,
register_platform_fn=lambda _: None,
)
schema.assert_not_called()
assert config["plain"] == {"user_key": "set_by_user"}
def test_populate_registers_platform_for_platform_entry() -> None:
"""Each `domain.platform` entry triggers register_platform_fn(domain) so
USE_<DOMAIN> defines get emitted later in the build pipeline.
"""
registered: list[str] = []
config: dict = {}
build_helpers.populate_dependency_config(
config,
["sensor.gpio", "binary_sensor.gpio"],
get_component_fn=lambda _: None,
register_platform_fn=registered.append,
)
assert registered == ["sensor", "binary_sensor"]
assert config["sensor"] == [{"platform": "gpio"}]
assert config["binary_sensor"] == [{"platform": "gpio"}]
@@ -0,0 +1,43 @@
"""Tests for source-file-to-component attribution in memory analyzer."""
from unittest.mock import patch
from esphome.analyze_memory import MemoryAnalyzer
def _make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer:
"""Create a MemoryAnalyzer with mocked dependencies."""
with patch.object(MemoryAnalyzer, "__init__", lambda self, *a, **kw: None):
analyzer = MemoryAnalyzer.__new__(MemoryAnalyzer)
analyzer.external_components = external_components or set()
analyzer._lib_hash_to_name = {}
return analyzer
def test_source_file_to_component_main_cpp_relative() -> None:
"""ESPHome-generated src/main.cpp.o (nm path form) attributes to core."""
analyzer = _make_analyzer()
assert analyzer._source_file_to_component("src/main.cpp.o") == "[esphome]core"
def test_source_file_to_component_main_cpp_pioenvs_path() -> None:
"""Linker map paths like .pioenvs/<env>/src/main.cpp.o attribute to core."""
analyzer = _make_analyzer()
result = analyzer._source_file_to_component(".pioenvs/drivewaygate/src/main.cpp.o")
assert result == "[esphome]core"
def test_source_file_to_component_esphome_core() -> None:
"""Sources under src/esphome/core/ attribute to core."""
analyzer = _make_analyzer()
result = analyzer._source_file_to_component("src/esphome/core/application.cpp.o")
assert result == "[esphome]core"
def test_source_file_to_component_known_component() -> None:
"""Known ESPHome components attribute to their component name."""
analyzer = _make_analyzer()
result = analyzer._source_file_to_component(
"src/esphome/components/wifi/wifi_component.cpp.o"
)
assert result == "[esphome]wifi"
@@ -0,0 +1,113 @@
"""Tests for esphome.components.api.client."""
from __future__ import annotations
from unittest.mock import patch
from esphome.components.api import client as api_client
from esphome.core import EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
on_log runs inside an asyncio protocol callback; if EsphomeError
escapes, the loop reports "Fatal error: protocol.data_received()
call failed.", tears the connection down, and ReconnectLogic loops
forever as the device replays the same crash trace on every
reconnect.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor.process_line("PC: 0x4010496e")
assert mock_process.called
assert processor.backtrace_state is False
def test_decoder_swallows_platform_handler_error() -> None:
"""The same protection must apply to the platform-specific handler."""
config = {"esphome": {"name": "test"}}
def platform_handler(_config, _line, _state):
raise EsphomeError("no idedata")
processor = api_client._LogLineProcessor(config, platform_handler)
processor.process_line("PC: 0x4010496e")
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(api_client, "process_stacktrace", side_effect=EsphomeError()):
processor.process_line("PC: 0x4010496e")
warnings = [r.message for r in caplog.records if r.levelname == "WARNING"]
assert any("build artifacts not found locally" in m for m in warnings)
assert not any("()" in m for m in warnings)
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to PlatformIO; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
processor.process_line("BT1: 0x401049aa")
assert mock_process.call_count == 1
def test_decoder_threads_backtrace_state() -> None:
"""When decoding succeeds, backtrace_state is threaded across calls."""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=[True, False]
) as mock_process:
processor.process_line(">>>stack>>>")
assert processor.backtrace_state is True
processor.process_line("<<<stack<<<")
assert processor.backtrace_state is False
assert mock_process.call_args_list[0].kwargs == {"backtrace_state": False}
assert mock_process.call_args_list[1].kwargs == {"backtrace_state": True}
def test_decoder_uses_platform_handler_when_provided() -> None:
"""The platform handler is preferred over the generic one."""
config = {"esphome": {"name": "test"}}
calls: list[tuple[object, str, bool]] = []
def platform_handler(cfg, line, state):
calls.append((cfg, line, state))
return True
processor = api_client._LogLineProcessor(config, platform_handler)
with patch.object(api_client, "process_stacktrace") as mock_generic:
processor.process_line("BT0: 0x4010496e")
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
+203
View File
@@ -0,0 +1,203 @@
"""Unit tests for ``esphome.components.dashboard_import.import_config``.
Locks the YAML shape that ``import_config`` materialises on disk for
adopted factory firmware. Both the legacy dashboard and the new
device-builder backend (esphome/device-builder) call this function
during the adoption flow and depend on the output's ``esphome.name``
/ ``packages:`` keys to route subsequent compile + flash operations.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml as pyyaml
from esphome.components.dashboard_import import import_config
def _load_plain_yaml(path: Path) -> dict:
"""Load YAML without invoking ESPHome's ``CORE``-aware loader.
``esphome.yaml_util.load_yaml`` resolves ``!include`` /
``!secret`` against ``CORE.config_path`` which isn't set in
these tests. We're only asserting on plain key/value structure,
so ``pyyaml.load`` with a custom loader subclassing
``pyyaml.SafeLoader`` (and empty fallbacks for the secret/include
tags) is enough.
"""
class _Loader(pyyaml.SafeLoader):
pass
_Loader.add_constructor("!secret", lambda loader, node: f"!secret {node.value}")
_Loader.add_constructor("!include", lambda loader, node: f"!include {node.value}")
return pyyaml.load(path.read_text(encoding="utf-8"), Loader=_Loader)
def test_basic_import_writes_expected_yaml_shape(tmp_path: Path) -> None:
"""A minimal Wi-Fi import emits the substitutions / packages / esphome triad.
These three top-level blocks are the contract: substitutions
holds the device-specific name, packages pulls in the upstream
firmware via the import URL, and esphome.name interpolates from
substitutions. Anything that depends on this output (frontend
config viewer, follow-up edits, version checks) reads those
keys directly.
"""
yaml_path = tmp_path / "kitchen.yaml"
import_config(
path=str(yaml_path),
name="kitchen",
friendly_name="Kitchen",
project_name="acme.kitchen-light",
import_url="github://acme/firmware/kitchen.yaml@main",
)
assert yaml_path.exists()
config = _load_plain_yaml(yaml_path)
assert config["substitutions"] == {
"name": "kitchen",
"friendly_name": "Kitchen",
}
assert config["packages"] == {
"acme.kitchen-light": "github://acme/firmware/kitchen.yaml@main"
}
assert config["esphome"] == {
"name": "${name}",
"name_add_mac_suffix": False,
"friendly_name": "${friendly_name}",
}
def test_import_appends_wifi_config_when_network_is_wifi(tmp_path: Path) -> None:
"""Wi-Fi devices get a ``wifi:`` block templated with secrets references.
Adopted Wi-Fi devices need a ``wifi:`` section so they can
actually connect on the user's LAN — the boilerplate references
``!secret wifi_ssid`` / ``!secret wifi_password`` so the
user's existing secrets file plugs in. Devices on other
networks (Ethernet) shouldn't get the Wi-Fi block.
"""
yaml_path = tmp_path / "kitchen.yaml"
import_config(
path=str(yaml_path),
name="kitchen",
friendly_name=None,
project_name="acme.kitchen-light",
import_url="github://acme/firmware/kitchen.yaml@main",
)
contents = yaml_path.read_text()
assert "wifi:" in contents
assert "!secret wifi_ssid" in contents
assert "!secret wifi_password" in contents
def test_import_omits_wifi_block_for_ethernet_network(tmp_path: Path) -> None:
"""Ethernet devices get no ``wifi:`` block — caller wires Ethernet separately.
The ``network`` parameter exists specifically so non-Wi-Fi
devices (PoE / Ethernet, etc.) skip the Wi-Fi templating —
otherwise their generated YAML would carry an unused ``wifi:``
section the user has to clean up by hand.
"""
yaml_path = tmp_path / "olimex-poe.yaml"
import_config(
path=str(yaml_path),
name="olimex-poe",
friendly_name=None,
project_name="acme.poe-monitor",
import_url="github://acme/firmware/poe.yaml@main",
network="ethernet",
)
contents = yaml_path.read_text()
assert "wifi:" not in contents
def test_import_with_encryption_writes_api_key(tmp_path: Path) -> None:
"""``encryption=True`` generates a fresh Noise PSK in the api block.
Used during the adoption flow when the device-builder UI
explicitly opts the new device into encrypted API. Each
invocation must produce a fresh 32-byte PSK base64-encoded into
the YAML; subsequent compiles and the dashboard's encryption
indicator both read it from there.
"""
yaml_path_1 = tmp_path / "a.yaml"
yaml_path_2 = tmp_path / "b.yaml"
import_config(
path=str(yaml_path_1),
name="a",
friendly_name=None,
project_name="acme.dev",
import_url="github://acme/firmware/dev.yaml@main",
encryption=True,
)
import_config(
path=str(yaml_path_2),
name="b",
friendly_name=None,
project_name="acme.dev",
import_url="github://acme/firmware/dev.yaml@main",
encryption=True,
)
config_1 = _load_plain_yaml(yaml_path_1)
config_2 = _load_plain_yaml(yaml_path_2)
assert "api" in config_1 and "encryption" in config_1["api"]
key_1 = config_1["api"]["encryption"]["key"]
key_2 = config_2["api"]["encryption"]["key"]
# Fresh per-call PSK, not a hardcoded value.
assert key_1 != key_2
# Base64-encoded 32 bytes → length 44 with one trailing `=`.
assert len(key_1) == 44
def test_import_without_friendly_name_omits_friendly_substitution(
tmp_path: Path,
) -> None:
"""``friendly_name=None`` skips the friendly_name substitution.
Some imported configs don't carry a friendly name. The output
shouldn't pretend they do — the substitutions block must omit
``friendly_name`` so the dashboard renders blank rather than
the literal substitution token.
"""
yaml_path = tmp_path / "noname.yaml"
import_config(
path=str(yaml_path),
name="noname",
friendly_name=None,
project_name="acme.dev",
import_url="github://acme/firmware/dev.yaml@main",
)
config = _load_plain_yaml(yaml_path)
assert config["substitutions"] == {"name": "noname"}
assert "friendly_name" not in config["esphome"]
def test_import_refuses_to_overwrite_existing_yaml(tmp_path: Path) -> None:
"""An already-present file raises rather than clobbering the user's edits.
Both the legacy dashboard and device-builder rely on the
``FileExistsError`` to surface a "config already exists" message
instead of silently destroying user data.
"""
yaml_path = tmp_path / "existing.yaml"
yaml_path.write_text("# user's hand-edited config\n", encoding="utf-8")
with pytest.raises(FileExistsError):
import_config(
path=str(yaml_path),
name="existing",
friendly_name=None,
project_name="acme.dev",
import_url="github://acme/firmware/dev.yaml@main",
)
# Original content survives unchanged.
assert yaml_path.read_text() == "# user's hand-edited config\n"
+304 -8
View File
@@ -185,6 +185,22 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
"Error: The OTA partition on the ESP couldn't be found",
),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"),
(
espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
"Error: Firmware signature verification failed",
),
(
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
"Error: The requested OTA type is not supported by the device",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY,
"Error: The partition table update could not be verified",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
"Error: An error occurred while updating the partition table",
),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
],
)
@@ -270,12 +286,13 @@ def test_perform_ota_successful_md5_auth(
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support)
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -640,12 +657,13 @@ def test_perform_ota_successful_sha256_auth(
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support)
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -699,8 +717,9 @@ def test_perform_ota_sha256_fallback_to_md5(
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -765,3 +784,280 @@ def test_perform_ota_version_differences(
# For v2.0, verify more recv calls due to chunk acknowledgments
assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_extended_protocol_app(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA extended protocol app update."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Device feature flags
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify ota type was sent
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_partition_table(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA partition table update.
The mocked server advertises both COMPRESSION and PARTITION_ACCESS to exercise
the full extended-protocol negotiation path. Real IDFOTABackend devices return
``supports_compression() == false`` and never set the COMPRESSION flag for a
partition-table OTA; the flag here is intentional protocol-coverage, not a
description of on-device behaviour.
"""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Device feature flags (compression flag is unrealistic; see docstring)
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"partitions.bin",
espota2.OTA_TYPE_UPDATE_PARTITION_TABLE,
)
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify ota type was sent
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_device_rejects_with_unsupported_ota_type(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""End-to-end: device returns 0x8E after the size byte; perform_ota must
surface the human-readable 'unsupported OTA type' error from the lookup
table in check_error()."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Feature flags
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE]), # Reject at size step
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match="The requested OTA type is not supported by the device",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify the client did send the OTA type byte before the size step
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_unsupported_type_rejected_early(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""ota_type values not in _SUPPORTED_OTA_TYPES are rejected before any I/O."""
with pytest.raises(espota2.OTAError, match="Unsupported OTA type 0xFF"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
# No bytes should have been transmitted to the device.
mock_socket.sendall.assert_not_called()
@pytest.mark.parametrize("bad_type", [-1, 256, 0x10000, "app", None, 1.5])
def test_perform_ota_rejects_out_of_range_type(
mock_socket: Mock, mock_file: io.BytesIO, bad_type: object
) -> None:
"""Out-of-range or non-int ota_type must raise OTAError, not ValueError."""
with pytest.raises(espota2.OTAError, match="Invalid ota_type"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
bad_type, # type: ignore[arg-type]
)
mock_socket.sendall.assert_not_called()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_extended_protocol(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device only supports the legacy protocol."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Legacy single-byte feature ack
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support extended OTA protocol"
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_partition_access(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device advertises extended protocol but
not the partition-access feature."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION]
), # Compression only, no partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support partition access"
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
def test_check_error_detects_errors_when_expect_is_none() -> None:
"""check_error must surface device error bytes even when expect is None.
Regression test: previously, receive_exactly(..., expect=None) calls (used
during feature negotiation and nonce reads) silently passed error bytes
through, turning clean device errors into confusing later failures.
"""
with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"):
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)
def test_check_error_detects_empty_when_expect_is_none() -> None:
"""Empty data with expect=None must still raise (connection closed)."""
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
):
espota2.check_error([], None)
def test_check_error_passes_non_error_when_expect_is_none() -> None:
"""Non-error bytes with expect=None must pass through silently."""
espota2.check_error([espota2.RESPONSE_OK], None)
espota2.check_error([espota2.RESPONSE_HEADER_OK], None)
espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None)
+556 -39
View File
@@ -1,5 +1,6 @@
"""Tests for external_files.py functions."""
import os
from pathlib import Path
import time
from unittest.mock import MagicMock, patch
@@ -8,8 +9,73 @@ import pytest
import requests
from esphome import external_files
from esphome.config_validation import Invalid
from esphome.core import CORE, TimePeriod
from esphome.config_validation import Invalid, MultipleInvalid
from esphome.core import CORE, EsphomeError, TimePeriod
def _seed_etag(cache_file: Path, etag: str) -> Path:
"""Write an ETag sidecar with its mtime synced to the cache file's mtime,
matching the invariant that `_write_etag` enforces in production.
"""
sidecar = external_files._etag_sidecar_path(cache_file)
sidecar.write_text(etag)
file_mtime = int(cache_file.stat().st_mtime)
os.utime(sidecar, (file_mtime, file_mtime))
return sidecar
@pytest.fixture
def mock_requests_head() -> MagicMock:
"""Patch `external_files.requests.head` so the conditional HEAD-request
validator can be tested without doing real HTTP.
"""
with patch("esphome.external_files.requests.head") as m:
yield m
@pytest.fixture
def mock_requests_get() -> MagicMock:
"""Patch `external_files.requests.get` so the download path can be
tested without doing real HTTP.
"""
with patch("esphome.external_files.requests.get") as m:
yield m
@pytest.fixture
def mock_has_remote_file_changed() -> MagicMock:
"""Patch `external_files.has_remote_file_changed` so download tests can
control the conditional check independently from the GET path.
"""
with patch("esphome.external_files.has_remote_file_changed") as m:
yield m
@pytest.fixture
def mock_write_file() -> MagicMock:
"""Patch `external_files.write_file` so atomic-write failures can be
injected without involving the real filesystem helper.
"""
with patch("esphome.external_files.write_file") as m:
yield m
@pytest.fixture
def mock_download_content() -> MagicMock:
"""Patch `external_files.download_content` for tests that exercise the
parallel batch helper without doing real I/O.
"""
with patch("esphome.external_files.download_content") as m:
yield m
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch `external_files.download_content_many` for tests that exercise
the URL-collection helper without dispatching to the thread pool.
"""
with patch("esphome.external_files.download_content_many") as m:
yield m
def test_compute_local_file_dir(setup_core: Path) -> None:
@@ -88,9 +154,8 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None:
assert result is False
@patch("esphome.external_files.requests.head")
def test_has_remote_file_changed_not_modified(
mock_head: MagicMock, setup_core: Path
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed returns False when file not modified."""
test_file = setup_core / "cached.txt"
@@ -98,23 +163,23 @@ def test_has_remote_file_changed_not_modified(
mock_response = MagicMock()
mock_response.status_code = 304
mock_head.return_value = mock_response
mock_response.headers = {}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, test_file)
assert result is False
mock_head.assert_called_once()
mock_requests_head.assert_called_once()
call_args = mock_head.call_args
call_args = mock_requests_head.call_args
headers = call_args[1]["headers"]
assert external_files.IF_MODIFIED_SINCE in headers
assert external_files.CACHE_CONTROL in headers
@patch("esphome.external_files.requests.head")
def test_has_remote_file_changed_modified(
mock_head: MagicMock, setup_core: Path
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed returns True when file modified."""
test_file = setup_core / "cached.txt"
@@ -122,7 +187,8 @@ def test_has_remote_file_changed_modified(
mock_response = MagicMock()
mock_response.status_code = 200
mock_head.return_value = mock_response
mock_response.headers = {}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, test_file)
@@ -140,15 +206,16 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None:
assert result is True
@patch("esphome.external_files.requests.head")
def test_has_remote_file_changed_network_error(
mock_head: MagicMock, setup_core: Path
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed returns False on network error when file is cached."""
test_file = setup_core / "cached.txt"
test_file.write_text("cached content")
mock_head.side_effect = requests.exceptions.RequestException("Network error")
mock_requests_head.side_effect = requests.exceptions.RequestException(
"Network error"
)
url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, test_file)
@@ -156,9 +223,8 @@ def test_has_remote_file_changed_network_error(
assert result is False
@patch("esphome.external_files.requests.head")
def test_has_remote_file_changed_timeout(
mock_head: MagicMock, setup_core: Path
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed respects timeout."""
test_file = setup_core / "cached.txt"
@@ -166,15 +232,176 @@ def test_has_remote_file_changed_timeout(
mock_response = MagicMock()
mock_response.status_code = 304
mock_head.return_value = mock_response
mock_response.headers = {}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
external_files.has_remote_file_changed(url, test_file)
call_args = mock_head.call_args
call_args = mock_requests_head.call_args
assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT
def test_has_remote_file_changed_uses_etag(
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed sends If-None-Match when ETag is cached."""
test_file = setup_core / "cached.txt"
test_file.write_text("cached content")
_seed_etag(test_file, '"abc123"')
mock_response = MagicMock()
mock_response.status_code = 304
mock_response.headers = {}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, test_file)
assert result is False
headers = mock_requests_head.call_args[1]["headers"]
assert headers[external_files.IF_NONE_MATCH] == '"abc123"'
def test_has_remote_file_changed_no_etag_no_if_none_match(
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed omits If-None-Match when no ETag is cached."""
test_file = setup_core / "cached.txt"
test_file.write_text("cached content")
mock_response = MagicMock()
mock_response.status_code = 304
mock_response.headers = {}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
external_files.has_remote_file_changed(url, test_file)
headers = mock_requests_head.call_args[1]["headers"]
assert external_files.IF_NONE_MATCH not in headers
def test_has_remote_file_changed_refreshes_etag_on_304(
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""Test has_remote_file_changed updates the cached ETag when the 304 sends a new one."""
test_file = setup_core / "cached.txt"
test_file.write_text("cached content")
_seed_etag(test_file, '"old"')
mock_response = MagicMock()
mock_response.status_code = 304
mock_response.headers = {external_files.ETAG: '"new"'}
mock_requests_head.return_value = mock_response
url = "https://example.com/file.txt"
external_files.has_remote_file_changed(url, test_file)
assert external_files._etag_sidecar_path(test_file).read_text() == '"new"'
def test_has_remote_file_changed_ignores_etag_when_mtime_diverges(
mock_requests_head: MagicMock, setup_core: Path
) -> None:
"""If the cache file was edited out-of-band (mtime no longer matches the
sidecar's), the cached ETag must not be used -- it no longer describes the
bytes on disk.
"""
test_file = setup_core / "cached.txt"
test_file.write_text("cached content")
sidecar = _seed_etag(test_file, '"abc123"')
# Simulate an out-of-band edit to the cache file -- mtime advances by a
# full second (so it diverges at whole-second resolution) but the sidecar
# is left untouched, so the recorded ETag is now stale.
file_stat = test_file.stat()
os.utime(test_file, (file_stat.st_atime, file_stat.st_mtime + 1))
mock_response = MagicMock()
mock_response.status_code = 304
mock_response.headers = {}
mock_requests_head.return_value = mock_response
external_files.has_remote_file_changed("https://example.com/file.txt", test_file)
headers = mock_requests_head.call_args[1]["headers"]
assert external_files.IF_NONE_MATCH not in headers
# Stale sidecar should be removed so future calls don't keep paying the
# mtime-comparison cost on a known-bad sidecar.
assert not sidecar.exists()
def test_download_content_pins_etag_mtime_to_file_mtime(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""After a successful download, the sidecar's mtime must equal the cache
file's mtime so `_read_etag` accepts it on the next call.
"""
test_file = setup_core / "fresh.txt"
mock_has_remote_file_changed.return_value = True
mock_response = MagicMock()
mock_response.content = b"fresh content"
mock_response.headers = {external_files.ETAG: '"deadbeef"'}
mock_response.raise_for_status = MagicMock()
mock_requests_get.return_value = mock_response
external_files.download_content("https://example.com/file.txt", test_file)
sidecar = external_files._etag_sidecar_path(test_file)
assert int(sidecar.stat().st_mtime) == int(test_file.stat().st_mtime)
def test_write_etag_swallows_write_file_failure(
mock_write_file: MagicMock, setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""If `write_file` raises, _write_etag must not propagate -- ETag
persistence is best-effort and a failure here must not abort the
surrounding download.
"""
cache_file = setup_core / "cached.txt"
cache_file.write_text("cached content")
mock_write_file.side_effect = EsphomeError("disk full")
with caplog.at_level("DEBUG", logger="esphome.external_files"):
external_files._write_etag(cache_file, '"abc123"')
assert "Could not save ETag" in caplog.text
# Sidecar wasn't created, since write_file was mocked to fail before
# reaching the os.utime step.
assert not external_files._etag_sidecar_path(cache_file).exists()
def test_write_etag_swallows_utime_failure(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""If `os.utime` raises while pinning the sidecar's mtime, _write_etag
must not propagate. The sidecar is still written; if its mtime later
fails to match the cache file, `_read_etag` will discard it on next
read.
"""
cache_file = setup_core / "cached.txt"
cache_file.write_text("cached content")
with (
patch(
"esphome.external_files.os.utime",
side_effect=PermissionError("nope"),
),
caplog.at_level("DEBUG", logger="esphome.external_files"),
):
external_files._write_etag(cache_file, '"abc123"')
assert "Could not sync ETag sidecar mtime" in caplog.text
# write_file succeeded, so the sidecar exists with the new value even
# though we couldn't pin its mtime.
sidecar = external_files._etag_sidecar_path(cache_file)
assert sidecar.exists()
assert sidecar.read_text() == '"abc123"'
def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None:
"""Test compute_local_file_dir creates parent directories."""
domain = "level1/level2/level3/level4"
@@ -200,10 +427,10 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None:
assert result is True
@patch("esphome.external_files.requests.get")
@patch("esphome.external_files.has_remote_file_changed")
def test_download_content_with_network_error_uses_cache(
mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Test download_content uses cached file when network fails."""
test_file = setup_core / "cached.txt"
@@ -211,8 +438,10 @@ def test_download_content_with_network_error_uses_cache(
test_file.write_bytes(cached_content)
# Simulate file has changed, so it tries to download
mock_has_changed.return_value = True
mock_get.side_effect = requests.exceptions.RequestException("Network error")
mock_has_remote_file_changed.return_value = True
mock_requests_get.side_effect = requests.exceptions.RequestException(
"Network error"
)
url = "https://example.com/file.txt"
result = external_files.download_content(url, test_file)
@@ -220,17 +449,19 @@ def test_download_content_with_network_error_uses_cache(
assert result == cached_content
@patch("esphome.external_files.requests.get")
@patch("esphome.external_files.has_remote_file_changed")
def test_download_content_with_network_error_no_cache_fails(
mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Test download_content raises error when network fails and no cache exists."""
test_file = setup_core / "nonexistent.txt"
# Simulate file has changed (doesn't exist), so it tries to download
mock_has_changed.return_value = True
mock_get.side_effect = requests.exceptions.RequestException("Network error")
mock_has_remote_file_changed.return_value = True
mock_requests_get.side_effect = requests.exceptions.RequestException(
"Network error"
)
url = "https://example.com/file.txt"
@@ -238,11 +469,72 @@ def test_download_content_with_network_error_no_cache_fails(
external_files.download_content(url, test_file)
@patch("esphome.external_files.requests.get")
@patch("esphome.external_files.has_remote_file_changed")
class _BodyReadErrorResponse:
"""Stand-in for `requests.Response` whose `.content` raises on access.
A small dedicated stub avoids mutating `MagicMock`'s class with a
`property` (which would leak across every other MagicMock-based test
in this file).
"""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.headers: dict[str, str] = {}
def raise_for_status(self) -> None:
return None
@property
def content(self) -> bytes:
raise self._exc
def test_download_content_with_body_read_error_uses_cache(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Body-read errors (chunked-decode/gzip-decode/mid-stream connection
drop) raise RequestException subclasses on `.content` access, not from
`requests.get` itself. They must follow the same fall-back-to-cache
path as a connect-time failure.
"""
test_file = setup_core / "cached.txt"
cached_content = b"cached content"
test_file.write_bytes(cached_content)
mock_has_remote_file_changed.return_value = True
mock_requests_get.return_value = _BodyReadErrorResponse(
requests.exceptions.ChunkedEncodingError("body truncated")
)
result = external_files.download_content("https://example.com/file.txt", test_file)
assert result == cached_content
def test_download_content_with_body_read_error_no_cache_fails(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""A body-read failure with no cache available must surface as a
cv.Invalid, same as a connect-time failure with no cache.
"""
test_file = setup_core / "nonexistent.txt"
mock_has_remote_file_changed.return_value = True
mock_requests_get.return_value = _BodyReadErrorResponse(
requests.exceptions.ChunkedEncodingError("body truncated")
)
with pytest.raises(Invalid, match="Could not download from.*body truncated"):
external_files.download_content("https://example.com/file.txt", test_file)
def test_download_content_skip_external_update_uses_cache(
mock_has_changed: MagicMock,
mock_get: MagicMock,
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Test download_content skips network checks when CORE.skip_external_update is set."""
@@ -255,26 +547,25 @@ def test_download_content_skip_external_update_uses_cache(
result = external_files.download_content(url, test_file)
assert result == cached_content
mock_has_changed.assert_not_called()
mock_get.assert_not_called()
mock_has_remote_file_changed.assert_not_called()
mock_requests_get.assert_not_called()
@patch("esphome.external_files.requests.get")
@patch("esphome.external_files.has_remote_file_changed")
def test_download_content_skip_external_update_downloads_when_missing(
mock_has_changed: MagicMock,
mock_get: MagicMock,
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Test download_content still downloads when file is missing, even with skip_external_update."""
test_file = setup_core / "missing.txt"
new_content = b"fresh content"
mock_has_changed.return_value = True
mock_has_remote_file_changed.return_value = True
mock_response = MagicMock()
mock_response.content = new_content
mock_response.headers = {}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
mock_requests_get.return_value = mock_response
CORE.skip_external_update = True
url = "https://example.com/file.txt"
@@ -282,3 +573,229 @@ def test_download_content_skip_external_update_downloads_when_missing(
assert result == new_content
assert test_file.read_bytes() == new_content
def test_download_content_many_empty_is_noop(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""Empty input shouldn't spin up a thread pool or call download_content."""
external_files.download_content_many([])
mock_download_content.assert_not_called()
def test_download_content_many_single_item_avoids_pool(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""A single item should be downloaded inline (no thread pool overhead)."""
item = ("https://example.com/file.txt", setup_core / "f.txt")
external_files.download_content_many([item])
mock_download_content.assert_called_once_with(
item[0], item[1], external_files.NETWORK_TIMEOUT
)
def test_download_content_many_runs_in_parallel(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""Multiple items should run concurrently — total wall time ≈ max latency."""
import threading
barrier = threading.Barrier(3)
def slow_download(url: str, path: Path, timeout: int) -> bytes:
# If calls were serial this would deadlock (third caller never arrives
# while the first is blocked at the barrier).
barrier.wait(timeout=2.0)
return b""
mock_download_content.side_effect = slow_download
items = [
("https://example.com/a", setup_core / "a"),
("https://example.com/b", setup_core / "b"),
("https://example.com/c", setup_core / "c"),
]
external_files.download_content_many(items, max_workers=4)
assert mock_download_content.call_count == 3
def test_download_content_many_propagates_single_error(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""A single failing worker should raise its `Invalid` directly, not wrap
it in a `MultipleInvalid` that the caller would have to unpack.
"""
def fake_download(url: str, path: Path, timeout: int) -> bytes:
if url.endswith("bad"):
raise Invalid(f"could not download {url}")
return b""
mock_download_content.side_effect = fake_download
items = [
("https://example.com/ok", setup_core / "ok"),
("https://example.com/bad", setup_core / "bad"),
]
with pytest.raises(Invalid, match="could not download") as exc_info:
external_files.download_content_many(items)
assert not isinstance(exc_info.value, MultipleInvalid)
def test_download_content_many_aggregates_multiple_errors(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""Every failing worker should be reported in a single MultipleInvalid so
the user sees all broken URLs in one validation pass instead of fixing
them one network round-trip at a time.
"""
def fake_download(url: str, path: Path, timeout: int) -> bytes:
if url.endswith("ok"):
return b""
raise Invalid(f"could not download {url}")
mock_download_content.side_effect = fake_download
items = [
("https://example.com/ok", setup_core / "ok"),
("https://example.com/bad1", setup_core / "bad1"),
("https://example.com/bad2", setup_core / "bad2"),
]
with pytest.raises(MultipleInvalid) as exc_info:
external_files.download_content_many(items)
messages = {str(e) for e in exc_info.value.errors}
assert messages == {
"could not download https://example.com/bad1",
"could not download https://example.com/bad2",
}
def test_download_content_many_dedupes_by_path(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""Two items pointing at the same cache path must collapse to one
download -- otherwise concurrent writes race on the same file. Which
URL wins doesn't matter (in practice duplicate paths only arise when
the URL is duplicated), so we only assert the call count and path.
"""
path = setup_core / "shared"
items = [
("https://example.com/a", path),
("https://example.com/b", path),
("https://example.com/a", path),
]
external_files.download_content_many(items)
assert mock_download_content.call_count == 1
args, _ = mock_download_content.call_args
assert args[1] == path
def test_download_content_many_clamps_invalid_max_workers(
mock_download_content: MagicMock, setup_core: Path
) -> None:
"""`max_workers <= 0` must not raise from ThreadPoolExecutor; it should
be clamped up to at least 1 worker.
"""
items = [
("https://example.com/a", setup_core / "a"),
("https://example.com/b", setup_core / "b"),
]
external_files.download_content_many(items, max_workers=0)
assert mock_download_content.call_count == 2
def test_download_web_files_in_config_filters_and_dispatches(
mock_download_content_many: MagicMock, setup_core: Path
) -> None:
"""Only `file.type == "web"` entries should be forwarded to
download_content_many, and the unmodified config should be returned so
the helper can sit in a `cv.All(...)` chain.
"""
def path_for(file_dict: dict) -> Path:
return setup_core / file_dict["url"].rsplit("/", 1)[-1]
config = [
{"file": {"type": "web", "url": "https://example.com/a"}},
{"file": {"type": "local", "path": "/tmp/b"}},
{"file": {"type": "web", "url": "https://example.com/c"}},
{}, # no `file` key at all
]
result = external_files.download_web_files_in_config(config, path_for)
assert result is config
mock_download_content_many.assert_called_once()
assert list(mock_download_content_many.call_args[0][0]) == [
("https://example.com/a", setup_core / "a"),
("https://example.com/c", setup_core / "c"),
]
def test_download_web_files_in_config_no_web_entries(
mock_download_content_many: MagicMock, setup_core: Path
) -> None:
"""A config with no web entries should still call through to
download_content_many (which is itself a no-op for empty input) so the
behavior stays consistent.
"""
config = [{"file": {"type": "local", "path": "/tmp/a"}}]
external_files.download_web_files_in_config(config, lambda _: setup_core / "x")
mock_download_content_many.assert_called_once()
assert list(mock_download_content_many.call_args[0][0]) == []
def test_download_content_saves_etag(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
setup_core: Path,
) -> None:
"""Test download_content writes the ETag sidecar after a successful download."""
test_file = setup_core / "fresh.txt"
new_content = b"fresh content"
mock_has_remote_file_changed.return_value = True
mock_response = MagicMock()
mock_response.content = new_content
mock_response.headers = {external_files.ETAG: '"deadbeef"'}
mock_response.raise_for_status = MagicMock()
mock_requests_get.return_value = mock_response
url = "https://example.com/file.txt"
external_files.download_content(url, test_file)
assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"'
def test_download_content_atomic_write_no_partial_on_failure(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_write_file: MagicMock,
setup_core: Path,
) -> None:
"""If `write_file` (the atomic-write helper) fails, the existing cache
file must remain untouched and no temp files may be left behind. Patching
`write_file` directly exercises the atomic-rename path -- a failure inside
`write_file` is the only reason the rename wouldn't have happened.
"""
from esphome.core import EsphomeError
test_file = setup_core / "cached.txt"
original_content = b"original content"
test_file.write_bytes(original_content)
mock_has_remote_file_changed.return_value = True
mock_response = MagicMock()
mock_response.content = b"new content"
mock_response.headers = {}
mock_response.raise_for_status = MagicMock()
mock_requests_get.return_value = mock_response
mock_write_file.side_effect = EsphomeError("disk full")
with pytest.raises(EsphomeError, match="disk full"):
external_files.download_content("https://example.com/file.txt", test_file)
# Original file is untouched -- write_file aborted before its rename step.
assert test_file.read_bytes() == original_content
# write_file is responsible for cleaning its own temp files; nothing leaks
# into the cache directory either way.
leftover_tmps = list(setup_core.glob("tmp*"))
assert leftover_tmps == []
+190
View File
@@ -811,3 +811,193 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup(
assert repo_dir.exists()
assert call_count["clone"] == 2
assert call_count["fetch"] == 2
def test_clone_with_ref_uses_shallow_fetch(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Clone with a ref should use --depth=1 on both clone and fetch."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
mock_run_git_command.side_effect = git_command_side_effect
git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain)
call_list = mock_run_git_command.call_args_list
clone_calls = [c for c in call_list if "clone" in c[0][0]]
assert len(clone_calls) == 1
assert "--depth=1" in clone_calls[0][0][0]
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
assert "--depth=1" in fetch_calls[0][0][0]
# Ref must still be passed so the requested commit/branch is fetched.
assert ref in fetch_calls[0][0][0]
def test_clone_with_submodules_uses_shallow_submodule_update(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Submodule init on a fresh clone should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
mock_run_git_command.side_effect = git_command_side_effect
git.clone_or_update(
url=url,
ref=None,
refresh=None,
domain=domain,
submodules=["components/foo"],
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
# The `--` terminator must precede the submodule paths so a path
# beginning with `-` cannot be parsed as an option.
assert cmd.index("--") < cmd.index("components/foo")
def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None:
"""The refresh-path fetch should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
)
fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
cmd = fetch_calls[0][0][0]
assert "--depth=1" in cmd
# Ref must still be in the refresh fetch so the right tip is updated.
assert cmd[-1] == ref
def test_refresh_submodule_update_is_shallow(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""The refresh-path submodule update should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url,
ref=None,
refresh=TimePeriodSeconds(days=1),
domain=domain,
submodules=["components/foo"],
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
assert cmd.index("--") < cmd.index("components/foo")
def test_refresh_picks_up_new_remote_commits(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Shallow fetch must still pull new commits when the remote tip moves.
Simulates a stale local repo at SHA "old" while the remote has advanced
to SHA "new". The refresh path must run fetch (with --depth=1) followed
by reset --hard FETCH_HEAD so the working tree advances to the new tip.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
_setup_old_repo(repo_dir)
# rev-parse is called once before fetch to record the pre-update SHA.
rev_parse_calls = {"count": 0}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse":
rev_parse_calls["count"] += 1
return "old_sha"
return ""
mock_run_git_command.side_effect = git_command_side_effect
_, revert = git.clone_or_update(
url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain
)
# Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset
call_list = mock_run_git_command.call_args_list
cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list]
assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"]
fetch_cmd = call_list[2][0][0]
assert "--depth=1" in fetch_cmd
assert fetch_cmd[-1] == ref
reset_cmd = call_list[3][0][0]
assert reset_cmd[-1] == "FETCH_HEAD"
# revert callback should reset back to the recorded pre-update SHA.
assert revert is not None
revert()
assert mock_run_git_command.call_args_list[-1][0][0] == [
"git",
"reset",
"--hard",
"old_sha",
]
+45
View File
@@ -90,6 +90,51 @@ def test_cpp_string_escape(string, expected):
assert actual == expected
@pytest.mark.parametrize(
"value, expected",
(
# Basic underscore→dash conversion.
("Living Room Sensor", "living-room-sensor"),
# Already-slugified input passes through with dash output.
("kitchen_light", "kitchen-light"),
# Accents are stripped (matches the underlying ``slugify``).
("Café Caché", "cafe-cache"),
# Mixed casing + multiple separators collapse correctly.
("Foo Bar__Baz", "foo-bar-baz"),
# Empty input yields empty output.
("", ""),
# Numbers survive intact.
("Sensor 42", "sensor-42"),
),
)
def test_friendly_name_slugify(value, expected):
"""Friendly-name → URL-safe dash-slug.
Stable mapping is part of the cross-tool contract
(legacy dashboard + device-builder both depend on it for
filename → device-name routing). Lock the cases here so a
refactor can't accidentally change a slug shape and break
on-disk filenames in already-deployed installs.
"""
assert helpers.friendly_name_slugify(value) == expected
def test_friendly_name_slugify_back_compat_shim():
"""``esphome.dashboard.util.text`` keeps re-exporting for back-compat.
The function moved to ``esphome.helpers`` so the new
device-builder dashboard backend can import it without depending
on the legacy dashboard package, but downstream code that still
imports from the old path keeps working until the dashboard
module is removed.
"""
from esphome.dashboard.util.text import (
friendly_name_slugify as legacy_friendly_name_slugify,
)
assert legacy_friendly_name_slugify is helpers.friendly_name_slugify
@pytest.mark.parametrize(
"host",
(
+164
View File
@@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None:
# Verify the correct number of resources
assert len(resources) == 3 # test.cpp, test.h, common.cpp
# ---------------------------------------------------------------------------
# recursive_sources — used only by the core "esphome" manifest so that files
# in esphome/core/<subdir>/*.cpp (e.g. esphome/core/wake/wake_host.cpp) are
# discovered without promoting <subdir>/ to a Python subpackage.
# ---------------------------------------------------------------------------
def _mock_file(filename: str) -> MagicMock:
m = MagicMock()
m.name = filename
m.is_file.return_value = True
m.is_dir.return_value = False
return m
def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock:
"""Mock a directory entry with an iterdir() and joinpath('__init__.py')."""
d = MagicMock()
d.name = dirname
d.is_file.return_value = False
d.is_dir.return_value = True
d.iterdir.return_value = children
init_marker = MagicMock()
init_marker.is_file.return_value = has_init
d.joinpath.return_value = init_marker
return d
def test_component_manifest_resources_non_recursive_skips_subdirs() -> None:
"""Default (recursive_sources=False) does not descend into subdirectories."""
mock_module = MagicMock()
mock_module.__package__ = "esphome.components.test_component"
# No FILTER_SOURCE_FILES.
del mock_module.FILTER_SOURCE_FILES
manifest = ComponentManifest(mock_module) # recursive_sources defaults to False
top_level = [
_mock_file("top.cpp"),
_mock_dir("subdir", [_mock_file("nested.cpp")]),
]
with patch("importlib.resources.files") as mock_files_func:
pkg = MagicMock()
pkg.iterdir.return_value = top_level
mock_files_func.return_value = pkg
names = [r.resource for r in manifest.resources]
assert names == ["top.cpp"]
def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None:
"""With recursive_sources=True, a subdir without __init__.py is walked."""
mock_module = MagicMock()
mock_module.__package__ = "esphome.core"
del mock_module.FILTER_SOURCE_FILES
manifest = ComponentManifest(mock_module, recursive_sources=True)
wake_dir = _mock_dir(
"wake",
[
_mock_file("wake_host.cpp"),
_mock_file("wake_host.h"),
_mock_file("README.md"), # wrong suffix, excluded
],
has_init=False,
)
top_level = [
_mock_file("wake.h"),
wake_dir,
]
with patch("importlib.resources.files") as mock_files_func:
pkg = MagicMock()
pkg.iterdir.return_value = top_level
mock_files_func.return_value = pkg
names = sorted(r.resource for r in manifest.resources)
assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"]
def test_component_manifest_resources_recursive_skips_subpackages() -> None:
"""Subdirectories that ARE Python subpackages (contain __init__.py) are
skipped even with recursive_sources=True — those load as their own
ComponentManifest and would otherwise be double-counted."""
mock_module = MagicMock()
mock_module.__package__ = "esphome.components.haier"
del mock_module.FILTER_SOURCE_FILES
manifest = ComponentManifest(mock_module, recursive_sources=True)
button_pkg = _mock_dir(
"button",
[_mock_file("self_cleaning.cpp")],
has_init=True, # Python subpackage — must be skipped.
)
top_level = [
_mock_file("haier.cpp"),
button_pkg,
]
with patch("importlib.resources.files") as mock_files_func:
pkg = MagicMock()
pkg.iterdir.return_value = top_level
mock_files_func.return_value = pkg
names = [r.resource for r in manifest.resources]
assert names == ["haier.cpp"]
def test_component_manifest_resources_recursive_skips_pycache() -> None:
"""__pycache__ inside a recursive walk must never be descended into."""
mock_module = MagicMock()
mock_module.__package__ = "esphome.core"
del mock_module.FILTER_SOURCE_FILES
manifest = ComponentManifest(mock_module, recursive_sources=True)
# __pycache__ is_dir=True but must be skipped without checking __init__.py
# or calling iterdir (would yield compiled artifacts).
pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")])
top_level = [
_mock_file("wake.h"),
pycache,
]
with patch("importlib.resources.files") as mock_files_func:
pkg = MagicMock()
pkg.iterdir.return_value = top_level
mock_files_func.return_value = pkg
names = [r.resource for r in manifest.resources]
assert names == ["wake.h"]
def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> (
None
):
"""FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files
inside a recursively-walked subdir."""
mock_module = MagicMock()
mock_module.__package__ = "esphome.core"
mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"]
manifest = ComponentManifest(mock_module, recursive_sources=True)
wake_dir = _mock_dir(
"wake",
[
_mock_file("wake_host.cpp"), # excluded
_mock_file("wake_freertos.cpp"), # kept
],
)
with patch("importlib.resources.files") as mock_files_func:
pkg = MagicMock()
pkg.iterdir.return_value = [wake_dir]
mock_files_func.return_value = pkg
names = [r.resource for r in manifest.resources]
assert names == ["wake/wake_freertos.cpp"]
+253 -11
View File
@@ -24,6 +24,7 @@ from esphome.__main__ import (
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_resolve_network_devices,
_validate_partition_table_binary,
choose_upload_log_host,
command_analyze_memory,
command_bundle,
@@ -83,6 +84,7 @@ from esphome.const import (
PLATFORM_RP2040,
)
from esphome.core import CORE, EsphomeError
from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE
from esphome.util import BootselResult
from esphome.zeroconf import _await_discovery, discover_mdns_devices
@@ -1111,6 +1113,7 @@ class MockArgs:
reset: bool = False
list_only: bool = False
output: str | None = None
partition_table: bool = False
def test_upload_program_serial_esp32(
@@ -1593,7 +1596,7 @@ def test_upload_program_ota_success(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, "secret", expected_firmware
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP
)
@@ -1624,10 +1627,241 @@ def test_upload_program_ota_with_file_arg(
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, Path("custom.bin")
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP
)
_PARTITION_TABLE_LEN = 0xC00
def _make_partition_table_bytes() -> bytes:
"""Build a minimal partition table image accepted by _validate_partition_table_binary."""
table = bytearray(b"\xff" * _PARTITION_TABLE_LEN)
# First entry: ESP_PARTITION_MAGIC (0x50AA) little-endian -> bytes 0xAA, 0x50.
table[0] = 0xAA
table[1] = 0x50
# MD5 checksum entry at offset 32: ESP_PARTITION_MAGIC_MD5 (0xEBEB) little-endian.
table[32] = 0xEB
table[33] = 0xEB
return bytes(table)
def test_upload_program_ota_partition_table_with_file_arg(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Test upload_program with OTA and partition table."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
partition_file = tmp_path / "partitions.bin"
partition_file.write_bytes(_make_partition_table_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(partition_file), partition_table=True)
devices = ["192.168.1.100"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"],
3232,
None,
partition_file,
OTA_TYPE_UPDATE_PARTITION_TABLE,
)
def test_upload_program_serial_partition_table(
mock_upload_using_esptool: Mock,
mock_get_port_type: Mock,
) -> None:
"""Test serial upload with partition table option (unsupported)."""
setup_core(platform=PLATFORM_ESP32)
mock_get_port_type.return_value = "SERIAL"
mock_upload_using_esptool.return_value = 0
config = {}
args = MockArgs(partition_table=True)
devices = ["/dev/ttyUSB0"]
with pytest.raises(
EsphomeError,
match="The option --partition-table can only be used for Over The Air updates",
):
upload_program(config, args, devices)
def test_upload_program_ota_partition_table_mqttip(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--partition-table is allowed for MQTTIP devices; they resolve to a real IP at OTA time."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "MQTTIP"
mock_run_ota.return_value = (0, "192.168.1.100")
partition_file = tmp_path / "partitions.bin"
partition_file.write_bytes(_make_partition_table_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(partition_file), partition_table=True)
with patch(
"esphome.__main__._resolve_network_devices", return_value=["192.168.1.100"]
):
exit_code, host = upload_program(config, args, ["MQTTIP"])
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"],
3232,
None,
partition_file,
OTA_TYPE_UPDATE_PARTITION_TABLE,
)
def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None:
f = tmp_path / "partitions.bin"
f.write_bytes(_make_partition_table_bytes())
_validate_partition_table_binary(f)
_PARTITION_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "partition_tables"
@pytest.mark.parametrize(
"fixture",
[
# Stock ESP-IDF gen_esp32part.py output for an ESPHome build.
"esphome_default.bin",
# ESP-IDF Hello-world example partition table (vendored from espressif/esp-serial-flasher).
"esp_idf_hello_world.bin",
# Partition table shipped with esphome_dashboard's prebuilt firmware.
"esphome_dashboard_firmware.bin",
],
)
def test_validate_partition_table_binary_accepts_real_binaries(fixture: str) -> None:
"""Real-world partition-table binaries from ESP-IDF / ESPHome tooling pass validation."""
_validate_partition_table_binary(_PARTITION_FIXTURE_DIR / fixture)
def test_validate_partition_table_binary_rejects_wrong_size(tmp_path: Path) -> None:
f = tmp_path / "partitions.bin"
f.write_bytes(b"\xaa\x50" + b"\xff" * 100)
with pytest.raises(EsphomeError, match="wrong size"):
_validate_partition_table_binary(f)
def test_validate_partition_table_binary_rejects_wrong_magic(tmp_path: Path) -> None:
data = bytearray(_make_partition_table_bytes())
data[0] = 0x00
data[1] = 0x00
f = tmp_path / "partitions.bin"
f.write_bytes(bytes(data))
with pytest.raises(EsphomeError, match="partition magic"):
_validate_partition_table_binary(f)
def test_validate_partition_table_binary_rejects_missing_md5(tmp_path: Path) -> None:
data = bytearray(_make_partition_table_bytes())
data[32] = 0xFF
data[33] = 0xFF
f = tmp_path / "partitions.bin"
f.write_bytes(bytes(data))
with pytest.raises(EsphomeError, match="missing the MD5 checksum entry"):
_validate_partition_table_binary(f)
def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None:
with pytest.raises(EsphomeError, match="Cannot read partition table file"):
_validate_partition_table_binary(tmp_path / "does-not-exist.bin")
def test_upload_program_ota_partition_table_invalid_file(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--partition-table must fail before calling run_ota when the file is not a partition table."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
bad_file = tmp_path / "firmware.bin"
bad_file.write_bytes(b"\x00" * 4096)
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(bad_file), partition_table=True)
devices = ["192.168.1.100"]
with pytest.raises(EsphomeError, match="wrong size"):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_partition_table_without_allow_flag(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--partition-table must fail fast when allow_partition_access is not enabled in YAML."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
]
}
args = MockArgs(file="partitions.bin", partition_table=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match="requires 'allow_partition_access: true'",
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_no_config(
mock_get_port_type: Mock,
) -> None:
@@ -1682,7 +1916,7 @@ def test_upload_program_ota_with_mqtt_resolution(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)
@@ -1730,7 +1964,7 @@ def test_upload_program_ota_with_mqtt_empty_broker(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.50"], 3232, None, expected_firmware
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)
# Verify warning was logged
assert "MQTT IP discovery failed" in caplog.text
@@ -2605,7 +2839,7 @@ def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) ->
}
with (
patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
"esphome.zeroconf.discover_mdns_devices", return_value=discovered
) as mock_discover,
patch(
"esphome.__main__.choose_prompt", return_value="mydevice-abc123.local"
@@ -2653,7 +2887,7 @@ def test_choose_upload_log_host_mac_suffix_no_devices_found(
)
with (
patch("esphome.__main__.discover_mdns_devices", return_value={}),
patch("esphome.zeroconf.discover_mdns_devices", return_value={}),
caplog.at_level(logging.WARNING, logger="esphome.__main__"),
pytest.raises(EsphomeError),
):
@@ -2686,7 +2920,7 @@ def test_choose_upload_log_host_default_ota_discovers_mac_suffix(
"mydevice-def456.local": ["10.0.0.2"],
}
with patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
"esphome.zeroconf.discover_mdns_devices", return_value=discovered
) as mock_discover:
result = choose_upload_log_host(
default="OTA",
@@ -2715,7 +2949,7 @@ def test_choose_upload_log_host_default_ota_no_suffix_discovery(
name="mydevice",
)
with patch("esphome.__main__.discover_mdns_devices") as mock_discover:
with patch("esphome.zeroconf.discover_mdns_devices") as mock_discover:
result = choose_upload_log_host(
default="OTA",
check_default=None,
@@ -3207,7 +3441,11 @@ def test_upload_program_ota_static_ip_with_mqttip(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware
["192.168.1.100", "192.168.2.50"],
3232,
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
)
@@ -3250,7 +3488,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware
["192.168.2.50", "192.168.2.51", "192.168.1.100"],
3232,
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
)
@@ -3415,7 +3657,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)
+99
View File
@@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables(
assert "arg" in args
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
# win32: drive-letter extended-length prefix is stripped
(
"win32",
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
),
# win32: UNC extended-length prefix is translated to a regular UNC path
(
"win32",
"\\\\?\\UNC\\server\\share\\python.exe",
"\\\\server\\share\\python.exe",
),
# win32: paths without the prefix are returned unchanged
(
"win32",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
),
# non-win32: prefix is left alone (no-op)
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
],
)
def test_strip_win_long_path_prefix(
platform: str, input_path: str, expected: str
) -> None:
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.platformio_api.sys.platform", platform):
assert platformio_api._strip_win_long_path_prefix(input_path) == expected
def test_run_platformio_cli_strips_win_long_path_prefix(
setup_core: Path, mock_run_external_process: Mock
) -> None:
r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess.
The NSIS-installed esphome.exe launcher starts Python with
``sys.executable`` already prefixed by the extended-length path marker.
That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and
break SCons-emitted command lines run through ``cmd.exe``.
"""
CORE.build_path = str(setup_core / "build" / "test")
prefixed_exe = (
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
)
stripped_exe = (
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
)
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "win32"),
patch("esphome.platformio_api.sys.executable", prefixed_exe),
):
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
# what run_platformio_cli set, not whatever the test runner's
# environment happened to contain.
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
# The subprocess is invoked with the stripped executable path.
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert args[0] == stripped_exe
# PYTHONEXEPATH is exported with the stripped path so PlatformIO's
# get_pythonexe_path() picks it up in the subprocess.
assert os.environ["PYTHONEXEPATH"] == stripped_exe
def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
setup_core: Path, mock_run_external_process: Mock
) -> None:
r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix.
Setting it unconditionally would clobber a user-provided value (or
interfere with non-Windows tooling that has no prefix to strip).
"""
CORE.build_path = str(setup_core / "build" / "test")
plain_exe = "/usr/bin/python3"
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "linux"),
patch("esphome.platformio_api.sys.executable", plain_exe),
):
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert args[0] == plain_exe
assert "PYTHONEXEPATH" not in os.environ
def test_run_platformio_cli_run_builds_command(
setup_core: Path, mock_run_platformio_cli: Mock
) -> None:
+237
View File
@@ -0,0 +1,237 @@
"""Unit tests for ``esphome.zeroconf`` device-discovery primitives.
Covers ``DashboardImportDiscovery`` (state transitions for adoption /
import flows) and ``DiscoveredImport`` (TXT-record parse shape). Both
are part of the cross-tool contract between the legacy dashboard and
the new device-builder backend (esphome/device-builder); changes to
the callback signature, the ``import_state`` dict shape, or the
``DiscoveredImport`` field set will break downstream consumers.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from zeroconf import ServiceStateChange
from esphome.zeroconf import (
ESPHOME_SERVICE_TYPE,
DashboardImportDiscovery,
DiscoveredImport,
)
def _make_service_info(
package_import_url: str = "github://esphome/example/example.yaml",
project_name: str = "esphome.example",
project_version: str = "1.0.0",
network: str | None = "wifi",
friendly_name: str | None = "Living Room",
version: str | None = "2025.1.0",
) -> MagicMock:
"""Build a fake ``AsyncServiceInfo`` with the TXT records we care about.
The real callback path resolves a service via zeroconf and then
reads ``info.properties`` (a ``dict[bytes, bytes | None]``). Mock
that shape so we can drive ``_process_service_info`` directly
without spinning up a real zeroconf instance.
"""
info = MagicMock()
properties: dict[bytes, bytes | None] = {
b"package_import_url": package_import_url.encode(),
b"project_name": project_name.encode(),
b"project_version": project_version.encode(),
}
if network is not None:
properties[b"network"] = network.encode()
if friendly_name is not None:
properties[b"friendly_name"] = friendly_name.encode()
if version is not None:
properties[b"version"] = version.encode()
info.properties = properties
info.load_from_cache.return_value = True
return info
def test_added_service_populates_import_state_and_fires_callback() -> None:
"""An ADD with the required TXT records lands a ``DiscoveredImport`` and notifies.
Mirrors what both the legacy dashboard and device-builder rely
on — the callback is the only signal that an importable device
has appeared on the LAN, and ``import_state`` is the snapshot
they read on demand.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert name in discovery.import_state
entry = discovery.import_state[name]
assert isinstance(entry, DiscoveredImport)
assert entry.device_name == "living-room"
assert entry.package_import_url == "github://esphome/example/example.yaml"
assert entry.project_name == "esphome.example"
assert entry.project_version == "1.0.0"
assert entry.network == "wifi"
assert entry.friendly_name == "Living Room"
on_update.assert_called_once_with(name, entry)
def test_added_service_without_required_txt_is_ignored() -> None:
"""A device that doesn't carry ``package_import_url`` etc. isn't importable.
The dashboard browser also fires for plain ``_esphomelib._tcp``
services that happen to match the type but aren't dashboard
imports. Those must not land in ``import_state`` or fire the
update callback — otherwise the dashboard would surface every
API-enabled device on the LAN as "ready to adopt".
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = MagicMock()
# Empty TXT records — no import URL, no version. ``version``-only
# services hit a separate ``update_device_mdns`` path that talks
# to ``StorageJSON``; that's covered elsewhere.
info.properties = {}
info.load_from_cache.return_value = True
discovery._process_service_info(f"plain.{ESPHOME_SERVICE_TYPE}", info)
assert discovery.import_state == {}
on_update.assert_not_called()
def test_repeated_add_does_not_re_fire_callback() -> None:
"""Re-resolving the same service doesn't spam the on_update callback.
The dashboard re-resolves periodically; without the ``is_new``
guard, every refresh would fire ``IMPORTABLE_DEVICE_ADDED`` and
the dashboard's UI would re-render endlessly.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
discovery._process_service_info(name, info)
on_update.assert_called_once()
def test_removed_service_clears_state_and_fires_none_callback() -> None:
"""A ServiceStateChange.Removed pops the entry and notifies with ``None``.
Both consumers rely on the ``(name, None)`` callback shape to
distinguish "device gone" from "device updated". Coordinate
before changing the second-arg semantics.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
on_update.reset_mock()
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=name,
state_change=ServiceStateChange.Removed,
)
assert name not in discovery.import_state
on_update.assert_called_once_with(name, None)
def test_remove_for_unknown_service_does_not_fire_callback() -> None:
"""A spurious Removed for a service we never tracked is a silent no-op.
The browser can fire Removed for any matching service type,
not just the importable ones we're tracking. Don't let those
confuse the callback consumer.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=f"never-seen.{ESPHOME_SERVICE_TYPE}",
state_change=ServiceStateChange.Removed,
)
on_update.assert_not_called()
def test_updated_service_for_unknown_name_is_ignored() -> None:
"""Updates without a prior Add don't seed ``import_state``.
The dashboard counts on Add to introduce the device and Update
to refresh it. Letting Update silently introduce new state would
let an unrelated TXT change bypass the Add-time validation.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=f"living-room.{ESPHOME_SERVICE_TYPE}",
state_change=ServiceStateChange.Updated,
)
assert discovery.import_state == {}
on_update.assert_not_called()
def test_network_defaults_to_wifi_when_txt_absent() -> None:
"""Older firmware that doesn't broadcast ``network`` defaults to ``wifi``.
The TXT record was added in a later release; pre-existing
factory firmwares advertise without it. ``DiscoveredImport``
has to default cleanly so adoption flows can still produce a
valid YAML for those devices.
"""
discovery = DashboardImportDiscovery()
info = _make_service_info(network=None)
name = f"older.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert discovery.import_state[name].network == "wifi"
def test_friendly_name_optional() -> None:
"""``friendly_name`` may be ``None`` if the device doesn't broadcast it.
Both consumers handle the ``None`` case (rendering the device
name as fallback in the UI). Locking this in keeps the
optionality explicit so a future refactor doesn't accidentally
coerce it into an empty string.
"""
discovery = DashboardImportDiscovery()
info = _make_service_info(friendly_name=None)
name = f"no-friendly.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert discovery.import_state[name].friendly_name is None
def test_callback_is_optional() -> None:
"""``on_update=None`` lets ``import_state`` track silently.
Used by callers that read the dict directly rather than
subscribing to events.
"""
discovery = DashboardImportDiscovery(on_update=None)
info = _make_service_info()
name = f"silent.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
# No callback to assert against; just verify state landed.
assert name in discovery.import_state