mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 09:20:22 +00:00
Merge branch 'dev' into sendspin-artwork
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
(
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user