Merge remote-tracking branch 'upstream/dev' into esp32-sram1-as-iram

# Conflicts:
#	esphome/components/esp32/__init__.py
This commit is contained in:
J. Nick Koston
2026-03-19 10:00:37 -10:00
337 changed files with 9059 additions and 4508 deletions
+2
View File
@@ -0,0 +1,2 @@
/.esphome/
/secrets.yaml
@@ -0,0 +1,93 @@
#include <benchmark/benchmark.h>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// Helper: encode a message into a buffer and return it.
// Benchmarks encode once in setup, then decode the resulting bytes in a loop.
// This keeps decode benchmarks in sync with the actual protobuf schema —
// hand-encoded byte arrays would silently break when fields change.
template<typename T> static APIBuffer encode_message(const T &msg) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
return buffer;
}
// --- HelloRequest decode (string + varint fields) ---
static void Decode_HelloRequest(benchmark::State &state) {
HelloRequest source;
source.client_info = StringRef::from_lit("aioesphomeapi");
source.api_version_major = 1;
source.api_version_minor = 10;
auto encoded = encode_message(source);
for (auto _ : state) {
HelloRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
}
benchmark::DoNotOptimize(msg.api_version_major);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_HelloRequest);
// --- SwitchCommandRequest decode (simple command) ---
static void Decode_SwitchCommandRequest(benchmark::State &state) {
SwitchCommandRequest source;
source.key = 0x12345678;
source.state = true;
auto encoded = encode_message(source);
for (auto _ : state) {
SwitchCommandRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
}
benchmark::DoNotOptimize(msg.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_SwitchCommandRequest);
// --- LightCommandRequest decode (complex command with many fields) ---
static void Decode_LightCommandRequest(benchmark::State &state) {
LightCommandRequest source;
source.key = 0x11223344;
source.has_state = true;
source.state = true;
source.has_brightness = true;
source.brightness = 0.8f;
source.has_rgb = true;
source.red = 1.0f;
source.green = 0.5f;
source.blue = 0.2f;
source.has_effect = true;
source.effect = StringRef::from_lit("rainbow");
auto encoded = encode_message(source);
for (auto _ : state) {
LightCommandRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
}
benchmark::DoNotOptimize(msg.brightness);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_LightCommandRequest);
} // namespace esphome::api::benchmarks
@@ -0,0 +1,298 @@
#include <benchmark/benchmark.h>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// --- SensorStateResponse (highest frequency message) ---
static void Encode_SensorStateResponse(benchmark::State &state) {
APIBuffer buffer;
SensorStateResponse msg;
msg.key = 0x12345678;
msg.state = 23.5f;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(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_SensorStateResponse);
static void CalculateSize_SensorStateResponse(benchmark::State &state) {
SensorStateResponse msg;
msg.key = 0x12345678;
msg.state = 23.5f;
msg.missing_state = false;
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_SensorStateResponse);
// Steady state: buffer already allocated from previous iteration
static void CalcAndEncode_SensorStateResponse(benchmark::State &state) {
APIBuffer buffer;
SensorStateResponse msg;
msg.key = 0x12345678;
msg.state = 23.5f;
msg.missing_state = false;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_SensorStateResponse);
// Cold path: fresh buffer each iteration (measures heap allocation cost).
// Inner loop still needed to amortize CodSpeed instrumentation overhead.
// Each inner iteration creates a fresh buffer, so this measures
// alloc+calc+encode per item.
static void CalcAndEncode_SensorStateResponse_Fresh(benchmark::State &state) {
SensorStateResponse msg;
msg.key = 0x12345678;
msg.state = 23.5f;
msg.missing_state = false;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_SensorStateResponse_Fresh);
// --- BinarySensorStateResponse ---
static void Encode_BinarySensorStateResponse(benchmark::State &state) {
APIBuffer buffer;
BinarySensorStateResponse msg;
msg.key = 0xAABBCCDD;
msg.state = true;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(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_BinarySensorStateResponse);
// --- HelloResponse (string fields) ---
static void Encode_HelloResponse(benchmark::State &state) {
APIBuffer buffer;
HelloResponse msg;
msg.api_version_major = 1;
msg.api_version_minor = 10;
msg.server_info = StringRef::from_lit("esphome v2026.3.0");
msg.name = StringRef::from_lit("living-room-sensor");
uint32_t size = msg.calculate_size();
buffer.resize(size);
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_HelloResponse);
// --- LightStateResponse (complex multi-field message) ---
static void Encode_LightStateResponse(benchmark::State &state) {
APIBuffer buffer;
LightStateResponse msg;
msg.key = 0x11223344;
msg.state = true;
msg.brightness = 0.8f;
msg.color_mode = enums::COLOR_MODE_RGB_WHITE;
msg.color_brightness = 1.0f;
msg.red = 1.0f;
msg.green = 0.5f;
msg.blue = 0.2f;
msg.white = 0.0f;
msg.color_temperature = 4000.0f;
msg.cold_white = 0.0f;
msg.warm_white = 0.0f;
msg.effect = StringRef::from_lit("rainbow");
uint32_t size = msg.calculate_size();
buffer.resize(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_LightStateResponse);
static void CalculateSize_LightStateResponse(benchmark::State &state) {
LightStateResponse msg;
msg.key = 0x11223344;
msg.state = true;
msg.brightness = 0.8f;
msg.color_mode = enums::COLOR_MODE_RGB_WHITE;
msg.color_brightness = 1.0f;
msg.red = 1.0f;
msg.green = 0.5f;
msg.blue = 0.2f;
msg.white = 0.0f;
msg.color_temperature = 4000.0f;
msg.cold_white = 0.0f;
msg.warm_white = 0.0f;
msg.effect = StringRef::from_lit("rainbow");
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_LightStateResponse);
// --- DeviceInfoResponse (nested submessages: 20 devices + 20 areas) ---
static DeviceInfoResponse make_device_info_response() {
DeviceInfoResponse msg;
msg.name = StringRef::from_lit("living-room-sensor");
msg.mac_address = StringRef::from_lit("AA:BB:CC:DD:EE:FF");
msg.esphome_version = StringRef::from_lit("2026.3.0");
msg.compilation_time = StringRef::from_lit("Mar 16 2026, 12:00:00");
msg.model = StringRef::from_lit("esp32-poe-iso");
msg.manufacturer = StringRef::from_lit("Olimex");
msg.friendly_name = StringRef::from_lit("Living Room Sensor");
#ifdef USE_DEVICES
for (uint32_t i = 0; i < ESPHOME_DEVICE_COUNT && i < 20; i++) {
msg.devices[i].device_id = i + 1;
msg.devices[i].name = StringRef::from_lit("device");
msg.devices[i].area_id = (i % 20) + 1;
}
#endif
#ifdef USE_AREAS
for (uint32_t i = 0; i < ESPHOME_AREA_COUNT && i < 20; i++) {
msg.areas[i].area_id = i + 1;
msg.areas[i].name = StringRef::from_lit("area");
}
#endif
return msg;
}
static void CalculateSize_DeviceInfoResponse(benchmark::State &state) {
auto msg = make_device_info_response();
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_DeviceInfoResponse);
static void Encode_DeviceInfoResponse(benchmark::State &state) {
auto msg = make_device_info_response();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
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_DeviceInfoResponse);
// Steady state: buffer already allocated from previous iteration
static void CalcAndEncode_DeviceInfoResponse(benchmark::State &state) {
auto msg = make_device_info_response();
APIBuffer buffer;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_DeviceInfoResponse);
// Cold path: fresh buffer each iteration (measures heap allocation cost).
// Inner loop still needed to amortize CodSpeed instrumentation overhead.
// Each inner iteration creates a fresh buffer, so this measures
// alloc+calc+encode per item.
static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) {
auto msg = make_device_info_response();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh);
} // namespace esphome::api::benchmarks
@@ -0,0 +1,133 @@
#include <benchmark/benchmark.h>
#include "esphome/components/api/proto.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// --- ProtoVarInt::parse() benchmarks ---
static void ProtoVarInt_Parse_SingleByte(benchmark::State &state) {
uint8_t buf[] = {0x42}; // value = 66
for (auto _ : state) {
ProtoVarIntResult result{};
for (int i = 0; i < kInnerIterations; i++) {
result = ProtoVarInt::parse(buf, sizeof(buf));
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ProtoVarInt_Parse_SingleByte);
static void ProtoVarInt_Parse_TwoByte(benchmark::State &state) {
uint8_t buf[] = {0x80, 0x01}; // value = 128
for (auto _ : state) {
ProtoVarIntResult result{};
for (int i = 0; i < kInnerIterations; i++) {
result = ProtoVarInt::parse(buf, sizeof(buf));
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ProtoVarInt_Parse_TwoByte);
static void ProtoVarInt_Parse_FiveByte(benchmark::State &state) {
uint8_t buf[] = {0xFF, 0xFF, 0xFF, 0xFF, 0x0F};
for (auto _ : state) {
ProtoVarIntResult result{};
for (int i = 0; i < kInnerIterations; i++) {
result = ProtoVarInt::parse(buf, sizeof(buf));
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ProtoVarInt_Parse_FiveByte);
// --- Varint encoding benchmarks ---
static void Encode_Varint_Small(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
writer.encode_varint_raw(42);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_Varint_Small);
static void Encode_Varint_Large(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
writer.encode_varint_raw(300);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_Varint_Large);
static void Encode_Varint_MaxUint32(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
writer.encode_varint_raw(0xFFFFFFFF);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_Varint_MaxUint32);
// --- ProtoSize::varint() benchmarks ---
static void ProtoSize_Varint_Small(benchmark::State &state) {
// Use varying input to prevent constant folding.
// Values 0-127 all take 1 byte but the compiler can't prove that.
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += ProtoSize::varint(static_cast<uint32_t>(i) & 0x7F);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ProtoSize_Varint_Small);
static void ProtoSize_Varint_Large(benchmark::State &state) {
// Use varying input to prevent constant folding.
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += ProtoSize::varint(0xFFFF0000 | static_cast<uint32_t>(i));
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ProtoSize_Varint_Large);
} // namespace esphome::api::benchmarks
@@ -0,0 +1,114 @@
# Components needed for API protobuf benchmarks.
# Merged into the base config before validation so all
# dependencies get proper defaults.
#
# esphome: sub-keys are merged into the base config.
esphome:
areas:
- id: area_1
name: "Area 1"
- id: area_2
name: "Area 2"
- id: area_3
name: "Area 3"
- id: area_4
name: "Area 4"
- id: area_5
name: "Area 5"
- id: area_6
name: "Area 6"
- id: area_7
name: "Area 7"
- id: area_8
name: "Area 8"
- id: area_9
name: "Area 9"
- id: area_10
name: "Area 10"
- id: area_11
name: "Area 11"
- id: area_12
name: "Area 12"
- id: area_13
name: "Area 13"
- id: area_14
name: "Area 14"
- id: area_15
name: "Area 15"
- id: area_16
name: "Area 16"
- id: area_17
name: "Area 17"
- id: area_18
name: "Area 18"
- id: area_19
name: "Area 19"
- id: area_20
name: "Area 20"
devices:
- id: device_1
name: "Device 1"
area_id: area_1
- id: device_2
name: "Device 2"
area_id: area_2
- id: device_3
name: "Device 3"
area_id: area_3
- id: device_4
name: "Device 4"
area_id: area_4
- id: device_5
name: "Device 5"
area_id: area_5
- id: device_6
name: "Device 6"
area_id: area_6
- id: device_7
name: "Device 7"
area_id: area_7
- id: device_8
name: "Device 8"
area_id: area_8
- id: device_9
name: "Device 9"
area_id: area_9
- id: device_10
name: "Device 10"
area_id: area_10
- id: device_11
name: "Device 11"
area_id: area_11
- id: device_12
name: "Device 12"
area_id: area_12
- id: device_13
name: "Device 13"
area_id: area_13
- id: device_14
name: "Device 14"
area_id: area_14
- id: device_15
name: "Device 15"
area_id: area_15
- id: device_16
name: "Device 16"
area_id: area_16
- id: device_17
name: "Device 17"
area_id: area_17
- id: device_18
name: "Device 18"
area_id: area_18
- id: device_19
name: "Device 19"
area_id: area_19
- id: device_20
name: "Device 20"
area_id: area_20
api:
sensor:
binary_sensor:
light:
switch:
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# core (esphome/core/config.py) must run its to_code during builds
# because it bootstraps the fundamental application infrastructure.
manifest.enable_codegen()
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# host must run its to_code during builds because it sets up
# the host platform target execution environment.
manifest.enable_codegen()
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# json must run its to_code during benchmark builds because it
# adds the ArduinoJson library dependency needed by the API component.
manifest.enable_codegen()
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# logger must run its to_code during builds because it configures
# the logging subsystem used by ESP_LOG* macros.
manifest.enable_codegen()
+42
View File
@@ -0,0 +1,42 @@
#include <benchmark/benchmark.h>
#include "esphome/components/logger/logger.h"
/*
This special main.cpp provides the entry point for Google Benchmark.
It replaces the default ESPHome main with a benchmark runner.
*/
// Auto generated code by esphome
// ========== AUTO GENERATED INCLUDE BLOCK BEGIN ===========
// ========== AUTO GENERATED INCLUDE BLOCK END ===========
void original_setup() {
// Code-generated App initialization (pre_setup, area/device registration, etc.)
// ========== AUTO GENERATED CODE BEGIN ===========
// =========== AUTO GENERATED CODE END ============
}
void setup() {
// Run auto-generated initialization (App.pre_setup, area/device registration,
// looping_components_.init, etc.) so benchmarks that use App work correctly.
original_setup();
// Log functions call global_logger->log_vprintf_() without a null check,
// so we must set up a Logger before any test that triggers logging.
static esphome::logger::Logger test_logger(0);
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
test_logger.pre_setup();
int argc = 1;
char arg0[] = "benchmark";
char *argv[] = {arg0, nullptr};
::benchmark::Initialize(&argc, argv);
::benchmark::RunSpecifiedBenchmarks();
::benchmark::Shutdown();
exit(0);
}
void loop() {}
@@ -0,0 +1,9 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
manifest.to_code = to_code
@@ -0,0 +1,22 @@
#include <benchmark/benchmark.h>
#include "esphome/core/application.h"
namespace esphome::benchmarks {
// Benchmark Application::loop() with no registered components.
// App is initialized by original_setup() in main.cpp (code-generated
// pre_setup, area/device registration, looping_components_.init).
// This measures the baseline overhead of the main loop: scheduler,
// timing, before/after loop tasks, and yield_with_select_.
static void ApplicationLoop_Empty(benchmark::State &state) {
// Set loop interval to 0 so yield_with_select_ returns immediately
// instead of sleeping. This benchmarks the loop overhead, not the sleep.
App.set_loop_interval(0);
for (auto _ : state) {
App.loop();
}
}
BENCHMARK(ApplicationLoop_Empty);
} // namespace esphome::benchmarks
+41
View File
@@ -0,0 +1,41 @@
#include <benchmark/benchmark.h>
#include "esphome/core/helpers.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// --- random_float() ---
// Ported from ol.yaml:148 "Random Float Benchmark"
static void RandomFloat(benchmark::State &state) {
for (auto _ : state) {
float result = 0.0f;
for (int i = 0; i < kInnerIterations; i++) {
result += random_float();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(RandomFloat);
// --- random_uint32() ---
static void RandomUint32(benchmark::State &state) {
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += random_uint32();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(RandomUint32);
} // namespace esphome::benchmarks
+54
View File
@@ -0,0 +1,54 @@
#include <benchmark/benchmark.h>
#include "esphome/core/log.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
static const char *const TAG = "bench";
// --- Log a message with no format specifiers (fastest path) ---
static void Logger_NoFormat(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ESP_LOGW(TAG, "Something happened");
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Logger_NoFormat);
// --- Log a message with 3 uint32_t format specifiers ---
static void Logger_3Uint32(benchmark::State &state) {
uint32_t a = 12345, b = 67890, c = 99999;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ESP_LOGW(TAG, "Values: %" PRIu32 " %" PRIu32 " %" PRIu32, a, b, c);
}
benchmark::DoNotOptimize(a);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Logger_3Uint32);
// --- Log a message with 3 floats (common for sensor values) ---
static void Logger_3Float(benchmark::State &state) {
float temp = 23.456f, humidity = 67.89f, pressure = 1013.25f;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ESP_LOGW(TAG, "Sensor: %.2f %.1f %.2f", temp, humidity, pressure);
}
benchmark::DoNotOptimize(temp);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Logger_3Float);
} // namespace esphome::benchmarks
+143
View File
@@ -0,0 +1,143 @@
#include <benchmark/benchmark.h>
#include "esphome/core/scheduler.h"
#include "esphome/core/hal.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
// Without this, the ~60ns per-iteration valgrind start/stop cost dominates
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// --- Scheduler fast path: no work to do ---
static void Scheduler_Call_NoWork(benchmark::State &state) {
Scheduler scheduler;
uint32_t now = millis();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
scheduler.call(now);
}
benchmark::DoNotOptimize(now);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_Call_NoWork);
// --- Scheduler with timers: call() when timers exist but aren't due ---
static void Scheduler_Call_TimersNotDue(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Add some timeouts far in the future
for (int i = 0; i < 10; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i), 1000000, []() {});
}
scheduler.process_to_add();
uint32_t now = millis();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
scheduler.call(now);
}
benchmark::DoNotOptimize(now);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_Call_TimersNotDue);
// --- Scheduler with 5 intervals firing every call ---
static void Scheduler_Call_5IntervalsFiring(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
int fire_count = 0;
// Benchmarks the heap-based scheduler dispatch with 5 callbacks firing.
// Uses monotonically increasing fake time so intervals reliably fire every call.
// USE_BENCHMARK ifdef in component.h disables WarnIfComponentBlockingGuard
// (fake now > real millis() would cause underflow in finish()).
// interval=0 would cause an infinite loop (reschedules at same now).
for (int i = 0; i < 5; i++) {
scheduler.set_interval(&dummy_component, static_cast<uint32_t>(i), 1, [&fire_count]() { fire_count++; });
}
scheduler.process_to_add();
uint32_t now = millis() + 100;
for (auto _ : state) {
scheduler.call(now);
now++;
benchmark::DoNotOptimize(fire_count);
}
}
BENCHMARK(Scheduler_Call_5IntervalsFiring);
// --- Scheduler: set_timeout registration ---
static void Scheduler_SetTimeout(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % 5), 1000, []() {});
}
scheduler.process_to_add();
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_SetTimeout);
// --- Scheduler: set_interval registration ---
static void Scheduler_SetInterval(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Number of distinct interval keys; controls how many unique timers exist
// simultaneously and the drain cadence for process_to_add().
static constexpr int kKeyCount = 5;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_interval(&dummy_component, static_cast<uint32_t>(i % kKeyCount), 1000, []() {});
// Drain to_add_ periodically to reflect production behavior where
// process_to_add() runs each main loop iteration. Without this,
// cancelled items accumulate in to_add_ causing O(n²) scan cost.
if ((i + 1) % kKeyCount == 0) {
scheduler.process_to_add();
}
}
// Final drain in case kInnerIterations is not a multiple of 5
scheduler.process_to_add();
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_SetInterval);
// --- Scheduler: defer registration (set_timeout with delay=0) ---
static void Scheduler_Defer(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// defer() is Component::defer which calls set_timeout(delay=0).
// Call set_timeout directly since defer() is protected.
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % 5), 0, []() {});
}
scheduler.process_to_add();
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_Defer);
} // namespace esphome::benchmarks
+56 -4
View File
@@ -14,11 +14,63 @@ include the relevant `.cpp` and `.h` test files there.
### Override component code generation for testing
When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not
need to generate configuration code for testing.
During C++ test builds, `to_code` is suppressed for every component by default — most components do not
need to generate configuration code for a unit test binary.
If you do need to generate code to for example configure compilation flags or add libraries,
add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`.
#### Manifest overrides
If your component needs to customise code generation behavior for testing — for example to re-enable
`to_code`, supply a lightweight stub, add a test-only dependency, or change any other manifest attribute —
create an `__init__.py` in your component's test directory and define `override_manifest`:
**Top-level component** (`tests/components/<component>/__init__.py`):
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Re-enable the component's own to_code (needed when the component must
# emit C++ setup code that the test binary depends on at link time).
manifest.enable_codegen()
```
Or supply a lightweight stub instead of the real `to_code`:
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
# Only emit what the C++ tests actually need
pass
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["some_test_only_dep"]
```
**Platform component** (`tests/components/<component>/<domain>/__init__.py`,
e.g. `tests/components/my_sensor/sensor/__init__.py`):
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
```
`override_manifest` receives a `ComponentManifestOverride` that wraps the real manifest.
Attribute assignments store an override; reads fall back to the real manifest when no
override is present.
Key methods:
| Method | Effect |
|---|---|
| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation |
| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original |
The function is called after `to_code` has already been set to `None`, so calling
`enable_codegen()` is a deliberate opt-in.
## Running component unit tests
+8
View File
@@ -0,0 +1,8 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# core (esphome/core/config.py) must run its to_code during C++ test builds
# because it bootstraps the fundamental application infrastructure that all
# components depend on (component registration, event loop, etc.).
manifest.enable_codegen()
@@ -1,5 +1,10 @@
esp32:
variant: esp32s3
partitions:
- name: my_data
type: data
subtype: spiffs
size: 0x1000
framework:
type: esp-idf
advanced:
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# host must run its to_code during C++ test builds because it sets up the
# host platform target, which is the execution environment for all unit tests.
manifest.enable_codegen()
@@ -1,16 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO15
i2s_lrclk_pin: GPIO4
i2s_mclk_pin: GPIO5
<<: !include common.yaml
wifi:
ssid: test
password: test1234
media_player:
- platform: i2s_audio
name: Test Media Player
dac_type: internal
mode: stereo
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# logger must run its to_code during C++ test builds because it configures
# the logging subsystem used by ESP_LOG* macros throughout component code.
manifest.enable_codegen()
+57 -18
View File
@@ -42,6 +42,11 @@ lvgl:
disp_bg_color: color_id
disp_bg_image: cat_image
disp_bg_opa: cover
bottom_layer:
widgets:
- obj:
bg_color: 0x000000
bg_opa: cover
theme:
obj:
border_width: 1
@@ -49,12 +54,14 @@ lvgl:
gradients:
- id: color_bar
direction: hor
dither: err_diff
# dither: err_diff
stops:
- color: 0xFF0000
position: 0
opa: 100%
- color: 0xFFFF00
position: 42
opa: 80%
- color: 0x00FF00
position: 84
- color: 0x00FFFF
@@ -156,6 +163,16 @@ lvgl:
offset_x: !lambda return 20;
offset_y: !lambda return 20;
antialias: !lambda return true;
- id: msgbox_with_header_buttons
title: Header Buttons Test
body:
text: Testing header buttons
header_buttons:
- src: cat_image
on_click:
logger.log: Header button clicked
buttons:
- text: OK
- id: simple_msgbox
title: Simple
@@ -199,11 +216,11 @@ lvgl:
text: Unloaded
- lvgl.label.update:
id: msgbox_label
text: "" # Empty text
text: "" # Empty text
on_all_events:
logger.log:
format: "Event %s"
args: ['lv_event_code_name_for(event->code).c_str()']
args: ['lv_event_code_name_for(event).c_str()']
skip: true
layout:
type: Flex
@@ -241,7 +258,7 @@ lvgl:
on_all_events:
- logger.log:
format: "Event %s"
args: ['lv_event_code_name_for(event->code).c_str()']
args: ['lv_event_code_name_for(event).c_str()']
- lvgl.animimg.update:
id: anim_img
src: !lambda "return {dog_image, cat_image};"
@@ -306,7 +323,6 @@ lvgl:
anim_time: 1s
bg_color: light_blue
bg_grad_color: light_blue
bg_dither_mode: ordered
bg_grad_dir: hor
bg_grad_stop: 128
bg_image_opa: transp
@@ -354,10 +370,18 @@ lvgl:
text_line_space: 4
text_opa: cover
transform_angle: 180
transform_rotation: 90
transform_height: 100
transform_pivot_x: 50%
transform_pivot_y: 50%
transform_zoom: 0.5
transform_scale: 2.0
transform_scale_x: 1.5
transform_scale_y: 0.8
transform_skew_x: 10
transform_skew_y: 20
shadow_offset_x: 3
shadow_offset_y: 3
translate_x: 10
translate_y: 10
max_height: 100
@@ -549,14 +573,16 @@ lvgl:
arc_length: 120
spin_time: 2s
align: left_mid
- spinner:
align: right_mid
send_draw_task_events: true
- image:
id: lv_image
src: cat_image
align: top_left
y: "50"
mode: real
zoom: 2.0
angle: 45
scale: 2.0
rotation: 45
- tileview:
id: tileview_id
scrollbar_mode: active
@@ -661,8 +687,11 @@ lvgl:
src: cat_image
x: 100
y: 100
angle: 90
zoom: 2.0
rotation: 90
scale_x: 2.0
scale_y: 1.5
skew_x: 10
skew_y: 5
pivot_x: 25
pivot_y: 25
- lvgl.canvas.draw_line:
@@ -710,6 +739,9 @@ lvgl:
text:
format: "A string with a number %d"
args: ['(int)(random_uint32() % 1000)']
size: 120
dark_color: navy
light_color: white
- slider:
min_value: 0
@@ -928,7 +960,6 @@ lvgl:
grid_cell_row_pos: 0
grid_cell_column_pos: 0
src: !lambda return dog_image;
mode: virtual
on_click:
then:
- lvgl.tabview.select:
@@ -1023,10 +1054,18 @@ lvgl:
text_color: 0xFFFFFF
scales:
- ticks:
width: !lambda return 1;
width: 1
count: 61
length: 20%
radial_offset: 5
color: 0xFFFFFF
major:
stride: 5
width: 2
length: 8
color: 0xC0C0C0
radial_offset: 3
label_gap: 6
range_from: 0
range_to: 60
angle_range: 360
@@ -1037,15 +1076,15 @@ lvgl:
end_value: 60
color_start: 0x0000bd
color_end: 0xbd0000
width: !lambda return 1;
width: 1
- line:
opa: 50%
id: minute_hand
color: 0xFF0000
r_mod: !lambda return -1;
width: !lambda return 3;
-
angle_range: 330
length: 99%
radial_offset: 2
width: 1
- angle_range: 330
rotation: 300
range_from: 1
range_to: 12
@@ -1069,7 +1108,7 @@ lvgl:
value: 180
width: 4
color: 0xA0A0A0
r_mod: -20
length: 80%
opa: 0%
- id: page3
layout: Horizontal
+8 -8
View File
@@ -1,18 +1,18 @@
wifi:
ssid: MySSID
password: password1
i2s_audio:
i2s_lrclk_pin: 13
i2s_bclk_pin: 14
i2s_mclk_pin: 15
media_player:
speaker:
- platform: i2s_audio
name: None
dac_type: external
id: test_speaker
i2s_dout_pin: 18
mute_pin: 19
dac_type: external
media_player:
- platform: speaker
name: None
speaker: test_speaker
on_state:
- media_player.play:
- media_player.play_media: http://localhost/media.mp3
+9 -1
View File
@@ -6,7 +6,7 @@ i2s_audio:
microphone:
- platform: i2s_audio
id: mic_id_external
i2s_din_pin: ${i2s_din_pin}
i2s_din_pin: ${i2s_din_pin1}
adc_type: external
pdm: false
mclk_multiple: 384
@@ -15,7 +15,15 @@ microphone:
- if:
condition:
- microphone.is_muted:
id: mic_id_external
then:
- microphone.unmute:
id: mic_id_external
else:
- microphone.mute:
id: mic_id_external
- platform: i2s_audio
id: mic_id_pdm
i2s_din_pin: ${i2s_din_pin2}
adc_type: external
pdm: true
@@ -2,21 +2,7 @@ substitutions:
i2s_bclk_pin: GPIO15
i2s_lrclk_pin: GPIO4
i2s_mclk_pin: GPIO5
i2s_din_pin: GPIO33
i2s_din_pin1: GPIO33
i2s_din_pin2: GPIO34
i2s_audio:
i2s_bclk_pin: ${i2s_bclk_pin}
i2s_lrclk_pin: ${i2s_lrclk_pin}
i2s_mclk_pin: ${i2s_mclk_pin}
use_legacy: true
microphone:
- platform: i2s_audio
id: mic_id_external
i2s_din_pin: ${i2s_din_pin}
adc_type: external
pdm: false
- platform: i2s_audio
id: mic_id_adc
adc_pin: 32
adc_type: internal
<<: !include common.yaml
@@ -8,6 +8,11 @@ on_beo4:
- logger.log:
format: "on_beo4: %u %u"
args: ["x.source", "x.command"]
on_brennenstuhl:
then:
- logger.log:
format: "on_brennenstuhl: %u"
args: ["x.code"]
on_aeha:
then:
- logger.log:
@@ -14,6 +14,12 @@ button:
remote_transmitter.transmit_beo4:
source: 0x01
command: 0x0C
- platform: template
name: brennenstuhl
id: button_a_on
on_press:
remote_transmitter.transmit_brennenstuhl:
code: 0xBD2E2C
- platform: template
name: Dyson fan up
id: dyson_fan_up
+9
View File
@@ -0,0 +1,9 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
manifest.to_code = to_code
+148
View File
@@ -1821,3 +1821,151 @@ def test_component_batching_beta_branch_40_per_batch(
all_components.extend(batch_str.split())
assert len(all_components) == 120
assert set(all_components) == set(component_names)
# --- should_run_benchmarks tests ---
def test_should_run_benchmarks_core_change() -> None:
"""Test benchmarks trigger on core C++ file changes."""
with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/scheduler.cpp"]
):
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_core_header_change() -> None:
"""Test benchmarks trigger on core header changes."""
with patch.object(
determine_jobs, "changed_files", return_value=["esphome/core/helpers.h"]
):
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_benchmark_infra_change() -> None:
"""Test benchmarks trigger on benchmark infrastructure changes."""
for infra_file in [
"script/cpp_benchmark.py",
"script/build_helpers.py",
"script/setup_codspeed_lib.py",
]:
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
assert determine_jobs.should_run_benchmarks() is True, (
f"Expected benchmarks to run for {infra_file}"
)
def test_should_run_benchmarks_benchmark_file_change() -> None:
"""Test benchmarks trigger on benchmark file changes."""
with patch.object(
determine_jobs,
"changed_files",
return_value=["tests/benchmarks/components/api/bench_proto_encode.cpp"],
):
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_core_benchmark_file_change() -> None:
"""Test benchmarks trigger on core benchmark file changes."""
with patch.object(
determine_jobs,
"changed_files",
return_value=["tests/benchmarks/core/bench_scheduler.cpp"],
):
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None:
"""Test benchmarks trigger when a benchmarked component changes."""
# Create a fake benchmarks directory with an 'api' component
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
benchmarks_dir.mkdir(parents=True)
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/components/api/proto.h"],
),
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(
determine_jobs,
"BENCHMARKS_COMPONENTS_PATH",
"tests/benchmarks/components",
),
):
assert determine_jobs.should_run_benchmarks() is True
def test_should_run_benchmarks_non_benchmarked_component_change(
tmp_path: Path,
) -> None:
"""Test benchmarks do NOT trigger for non-benchmarked component changes."""
# Create a fake benchmarks directory with only 'api'
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
benchmarks_dir.mkdir(parents=True)
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/components/sensor/__init__.py"],
),
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(
determine_jobs,
"BENCHMARKS_COMPONENTS_PATH",
"tests/benchmarks/components",
),
):
assert determine_jobs.should_run_benchmarks() is False
def test_should_run_benchmarks_no_dependency_expansion(tmp_path: Path) -> None:
"""Test benchmarks do NOT expand to dependent components.
Changing 'sensor' should not trigger 'api' benchmarks even if api
depends on sensor. This is intentional — benchmark runs should be
targeted to directly changed components only.
"""
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
benchmarks_dir.mkdir(parents=True)
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
with (
patch.object(
determine_jobs,
"changed_files",
# sensor is a dependency of api, but benchmarks don't expand
return_value=["esphome/components/sensor/sensor.cpp"],
),
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(
determine_jobs,
"BENCHMARKS_COMPONENTS_PATH",
"tests/benchmarks/components",
),
):
assert determine_jobs.should_run_benchmarks() is False
def test_should_run_benchmarks_unrelated_change() -> None:
"""Test benchmarks do NOT trigger for unrelated changes."""
with patch.object(determine_jobs, "changed_files", return_value=["README.md"]):
assert determine_jobs.should_run_benchmarks() is False
def test_should_run_benchmarks_no_changes() -> None:
"""Test benchmarks do NOT trigger with no changes."""
with patch.object(determine_jobs, "changed_files", return_value=[]):
assert determine_jobs.should_run_benchmarks() is False
def test_should_run_benchmarks_with_branch() -> None:
"""Test should_run_benchmarks passes branch to changed_files."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.should_run_benchmarks("release")
mock_changed.assert_called_with("release")
-22
View File
@@ -1073,28 +1073,6 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None:
assert result == {"sensor.bthome", "sensor"}
def test_get_all_dependencies_cpp_testing_flag() -> None:
"""cpp_testing=True propagates to CORE.cpp_testing during resolution."""
from esphome.core import CORE
with (
patch("esphome.loader.get_component") as mock_get_component,
patch("esphome.loader.get_platform"),
):
observed: list[bool] = []
def capturing_get_component(name: str):
observed.append(CORE.cpp_testing)
mock_get_component.side_effect = capturing_get_component
helpers.get_all_dependencies({"some_comp"}, cpp_testing=True)
assert observed and all(observed), (
"CORE.cpp_testing should be True during resolution"
)
def test_get_components_from_integration_fixtures() -> None:
"""Test extraction of components from fixture YAML files."""
yaml_content = {
+260
View File
@@ -0,0 +1,260 @@
"""Unit tests for script/build_helpers.py manifest override and build helpers."""
import os
from pathlib import Path
import sys
import textwrap
from unittest.mock import MagicMock, patch
import pytest
# Add the script directory to Python path so we can import build_helpers
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
)
import build_helpers # noqa: E402
from esphome.loader import ComponentManifest # noqa: E402
from tests.testing_helpers import ComponentManifestOverride # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_component_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
mod = MagicMock()
mod.to_code = to_code
mod.DEPENDENCIES = dependencies or []
return ComponentManifest(mod)
# ---------------------------------------------------------------------------
# filter_components_with_files
# ---------------------------------------------------------------------------
def test_filter_keeps_components_with_cpp_files(tmp_path: Path) -> None:
comp_dir = tmp_path / "mycomp"
comp_dir.mkdir()
(comp_dir / "mycomp_test.cpp").write_text("")
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
assert result == ["mycomp"]
def test_filter_keeps_components_with_h_files(tmp_path: Path) -> None:
comp_dir = tmp_path / "mycomp"
comp_dir.mkdir()
(comp_dir / "helpers.h").write_text("")
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
assert result == ["mycomp"]
def test_filter_drops_components_without_test_dir(tmp_path: Path) -> None:
result = build_helpers.filter_components_with_files(["nodir"], tmp_path)
assert result == []
def test_filter_drops_components_with_no_cpp_or_h(tmp_path: Path) -> None:
comp_dir = tmp_path / "mycomp"
comp_dir.mkdir()
(comp_dir / "README.md").write_text("")
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
assert result == []
# ---------------------------------------------------------------------------
# get_platform_components
# ---------------------------------------------------------------------------
def test_get_platform_components_discovers_subdirectory(tmp_path: Path) -> None:
(tmp_path / "bthome" / "sensor").mkdir(parents=True)
sensor_mod = MagicMock()
sensor_mod.IS_PLATFORM_COMPONENT = True
with patch(
"build_helpers.get_component", return_value=ComponentManifest(sensor_mod)
):
result = build_helpers.get_platform_components(["bthome"], tmp_path)
assert result == ["sensor.bthome"]
def test_get_platform_components_skips_pycache(tmp_path: Path) -> None:
(tmp_path / "bthome" / "__pycache__").mkdir(parents=True)
result = build_helpers.get_platform_components(["bthome"], tmp_path)
assert result == []
def test_get_platform_components_raises_for_invalid_domain(tmp_path: Path) -> None:
(tmp_path / "bthome" / "notadomain").mkdir(parents=True)
with (
patch("build_helpers.get_component", return_value=None),
pytest.raises(ValueError, match="notadomain"),
):
build_helpers.get_platform_components(["bthome"], tmp_path)
# ---------------------------------------------------------------------------
# load_test_manifest_overrides
# ---------------------------------------------------------------------------
def test_load_suppresses_to_code(tmp_path: Path) -> None:
"""to_code is always set to None before the override is called."""
async def real_to_code(config):
pass
inner = _make_component_manifest(to_code=real_to_code)
with (
patch("build_helpers.get_component", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
installed: ComponentManifestOverride = mock_set.call_args[0][1]
assert installed.to_code is None
def test_load_calls_override_fn(tmp_path: Path) -> None:
"""override_manifest() in test_init is called with the ComponentManifestOverride."""
comp_dir = tmp_path / "mycomp"
comp_dir.mkdir()
init_py = comp_dir / "__init__.py"
init_py.write_text(
textwrap.dedent("""\
def override_manifest(manifest):
manifest.dependencies = ["injected"]
""")
)
inner = _make_component_manifest()
override = ComponentManifestOverride(inner)
override.to_code = None
with (
patch("build_helpers.get_component", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
installed: ComponentManifestOverride = mock_set.call_args[0][1]
assert installed.dependencies == ["injected"]
def test_load_enable_codegen_in_override(tmp_path: Path) -> None:
"""An override_manifest that calls enable_codegen() restores to_code."""
async def real_to_code(config):
pass
comp_dir = tmp_path / "mycomp"
comp_dir.mkdir()
init_py = comp_dir / "__init__.py"
init_py.write_text(
textwrap.dedent("""\
def override_manifest(manifest):
manifest.enable_codegen()
""")
)
inner = _make_component_manifest(to_code=real_to_code)
with (
patch("build_helpers.get_component", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
installed: ComponentManifestOverride = mock_set.call_args[0][1]
assert installed.to_code is real_to_code
def test_load_no_override_file(tmp_path: Path) -> None:
"""No override file: manifest is wrapped and to_code suppressed, nothing else."""
inner = _make_component_manifest()
with (
patch("build_helpers.get_component", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
mock_set.assert_called_once()
key, installed = mock_set.call_args[0]
assert key == "mycomp"
assert isinstance(installed, ComponentManifestOverride)
def test_load_skips_already_wrapped(tmp_path: Path) -> None:
"""Components already wrapped as ComponentManifestOverride are not double-wrapped."""
inner = _make_component_manifest()
already_wrapped = ComponentManifestOverride(inner)
with (
patch("build_helpers.get_component", return_value=already_wrapped),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
mock_set.assert_not_called()
def test_load_skips_platform_component_already_wrapped(tmp_path: Path) -> None:
inner = _make_component_manifest()
already_wrapped = ComponentManifestOverride(inner)
with (
patch("build_helpers.get_platform", return_value=already_wrapped),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
mock_set.assert_not_called()
def test_load_wraps_top_level_component(tmp_path: Path) -> None:
inner = _make_component_manifest()
with (
patch("build_helpers.get_component", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
mock_set.assert_called_once()
key, installed = mock_set.call_args[0]
assert key == "mycomp"
assert isinstance(installed, ComponentManifestOverride)
assert installed.to_code is None
def test_load_wraps_platform_component(tmp_path: Path) -> None:
inner = _make_component_manifest()
with (
patch("build_helpers.get_platform", return_value=inner),
patch("build_helpers.set_testing_manifest") as mock_set,
):
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
mock_set.assert_called_once()
key, installed = mock_set.call_args[0]
assert key == "bthome.sensor"
assert isinstance(installed, ComponentManifestOverride)
assert installed.to_code is None
+63
View File
@@ -0,0 +1,63 @@
from typing import Any
from esphome.loader import ComponentManifest, _replace_component_manifest
class ComponentManifestOverride:
"""Mutable wrapper around ComponentManifest for test-specific attribute overrides.
When ``tests/components/<name>/__init__.py`` defines::
def override_manifest(manifest: ComponentManifestOverride) -> None:
...
the function receives an instance of this class wrapping the real component
manifest. Any attribute assignment stores an override; reads fall back to
the underlying ``ComponentManifest`` when no override has been set.
Example::
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
pass # lightweight no-op stub for C++ unit tests
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["extra_dep_for_tests"]
"""
def __init__(self, wrapped: "ComponentManifest") -> None:
object.__setattr__(self, "_wrapped", wrapped)
object.__setattr__(self, "_overrides", {})
def __getattr__(self, name: str) -> Any:
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
if name in overrides:
return overrides[name]
wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped")
return getattr(wrapped, name)
def __setattr__(self, name: str, value: Any) -> None:
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
overrides[name] = value
def enable_codegen(self) -> None:
"""Remove the to_code suppression, re-enabling code generation for this component.
Call this from ``override_manifest`` when the component needs its real (or a
custom stub) ``to_code`` to run during C++ unit test builds.
"""
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
overrides.pop("to_code", None)
def restore(self) -> None:
"""Clear all overrides, reverting to the wrapped manifest's values."""
object.__getattribute__(self, "_overrides").clear()
def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None:
"""Install a testing manifest override into the component cache.
Called from the C++ unit test infrastructure when a component's test
directory provides an ``override_manifest`` function.
"""
_replace_component_manifest(domain, manifest)
+98 -1
View File
@@ -2,7 +2,104 @@
from unittest.mock import MagicMock, patch
from esphome.loader import ComponentManifest
from esphome.loader import ComponentManifest, _replace_component_manifest, get_component
from tests.testing_helpers import ComponentManifestOverride
# ---------------------------------------------------------------------------
# ComponentManifestOverride
# ---------------------------------------------------------------------------
def _make_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
"""Return a ComponentManifest backed by a minimal mock module."""
mod = MagicMock()
mod.to_code = to_code
mod.DEPENDENCIES = dependencies or []
return ComponentManifest(mod)
def test_testing_manifest_delegates_to_wrapped() -> None:
"""Unoverridden attributes fall through to the wrapped manifest."""
inner = _make_manifest(dependencies=["wifi"])
tm = ComponentManifestOverride(inner)
assert tm.dependencies == ["wifi"]
def test_testing_manifest_override_shadows_wrapped() -> None:
"""An assigned attribute shadows the wrapped value."""
inner = _make_manifest(dependencies=["wifi"])
tm = ComponentManifestOverride(inner)
tm.dependencies = ["ble"]
assert tm.dependencies == ["ble"]
# Wrapped value unchanged
assert inner.dependencies == ["wifi"]
def test_testing_manifest_to_code_suppression() -> None:
"""Setting to_code=None suppresses code generation."""
async def real_to_code(config):
pass
inner = _make_manifest(to_code=real_to_code)
tm = ComponentManifestOverride(inner)
tm.to_code = None
assert tm.to_code is None
def test_testing_manifest_enable_codegen_removes_suppression() -> None:
"""enable_codegen() removes the to_code override, restoring the original."""
async def real_to_code(config):
pass
inner = _make_manifest(to_code=real_to_code)
tm = ComponentManifestOverride(inner)
tm.to_code = None
assert tm.to_code is None
tm.enable_codegen()
assert tm.to_code is real_to_code
def test_testing_manifest_enable_codegen_preserves_other_overrides() -> None:
"""enable_codegen() only removes to_code; other overrides survive."""
inner = _make_manifest(dependencies=["wifi"])
tm = ComponentManifestOverride(inner)
tm.to_code = None
tm.dependencies = ["ble"]
tm.enable_codegen()
assert tm.to_code is inner.to_code
assert tm.dependencies == ["ble"]
def test_testing_manifest_restore_clears_all_overrides() -> None:
"""restore() removes every override, reverting all attributes to wrapped values."""
async def real_to_code(config):
pass
inner = _make_manifest(to_code=real_to_code, dependencies=["wifi"])
tm = ComponentManifestOverride(inner)
tm.to_code = None
tm.dependencies = ["ble"]
tm.restore()
assert tm.to_code is real_to_code
assert tm.dependencies == ["wifi"]
def test_replace_component_manifest_installs_override() -> None:
"""_replace_component_manifest replaces the cached manifest for a domain."""
inner = _make_manifest()
override = ComponentManifestOverride(inner)
_replace_component_manifest("_test_dummy_domain", override)
assert get_component("_test_dummy_domain") is override
def test_component_manifest_resources_with_filter_source_files() -> None:
+8
View File
@@ -167,6 +167,13 @@ def mock_run_miniterm() -> Generator[Mock]:
yield mock
@pytest.fixture
def mock_wait_for_serial_port() -> Generator[Mock]:
"""Mock _wait_for_serial_port for testing."""
with patch("esphome.__main__._wait_for_serial_port") as mock:
yield mock
@pytest.fixture
def mock_upload_using_esptool() -> Generator[Mock]:
"""Mock upload_using_esptool for testing."""
@@ -1706,6 +1713,7 @@ def test_show_logs_serial(
mock_get_port_type: Mock,
mock_check_permissions: Mock,
mock_run_miniterm: Mock,
mock_wait_for_serial_port: Mock,
) -> None:
"""Test show_logs with serial port."""
setup_core(config={"logger": {}}, platform=PLATFORM_ESP32)
+126
View File
@@ -6,6 +6,7 @@ import pytest
from esphome import core, yaml_util
from esphome.components import substitutions
from esphome.config_helpers import Extend, Remove
from esphome.core import EsphomeError
from esphome.util import OrderedDict
@@ -306,3 +307,128 @@ def test_dump_sort_keys() -> None:
# nested keys should also be sorted
assert "a_key:" in sorted_dump
assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:")
@pytest.mark.parametrize(
"data",
[
{
"key1": "value1",
"key2": 42,
},
[1, 2, 3],
"simple string",
],
)
def test_config_context_mixin(data) -> None:
"""Test that ConfigContext mixin correctly stores and retrieves context vars in a dict."""
context_vars = {
"var1": "context_value1",
"var2": 100,
}
# Add context to the data
tagged_data = yaml_util.add_context(data, context_vars)
# Check that tagged_data has ConfigContext and correct vars
assert isinstance(tagged_data, type(data))
assert isinstance(tagged_data, yaml_util.ConfigContext)
assert tagged_data.vars == context_vars
# Check that original data is preserved
assert tagged_data == data
def test_config_context_mixin_no_context() -> None:
"""Test that add_context does not tag data when no context vars are provided."""
data = {"key": "value"}
# Add context with None
tagged_data = yaml_util.add_context(data, None)
# Should return original data without tagging
assert tagged_data is data
assert not isinstance(tagged_data, yaml_util.ConfigContext)
def test_config_context_mixin_merge_contexts() -> None:
"""Test that add_context merges new context vars with existing ones."""
data = {"key": "value"}
initial_context = {
"var1": "initial_value",
}
# First, add initial context
tagged_data = yaml_util.add_context(data, initial_context)
assert isinstance(tagged_data, yaml_util.ConfigContext)
assert tagged_data.vars == initial_context
# Now, add more context vars
new_context = {
"var2": "new_value",
"var1": "overridden_value", # This should override the initial var1
}
merged_tagged_data = yaml_util.add_context(tagged_data, new_context)
# Check that merged_tagged_data has merged context vars
expected_context = {
"var1": "overridden_value",
"var2": "new_value",
}
assert isinstance(merged_tagged_data, yaml_util.ConfigContext)
assert merged_tagged_data.vars == expected_context
# Check that original data is preserved
assert merged_tagged_data == data
@pytest.mark.parametrize("data", [42, 3.14, True, None])
def test_config_context_non_taggable(data) -> None:
"""Test that add_context ignores non-string scalar values."""
context_vars = {
"var1": "context_value",
}
# Add context to the scalar data
tagged_data = yaml_util.add_context(data, context_vars)
# Check that tagged_data has ConfigContext and correct vars
assert not isinstance(tagged_data, yaml_util.ConfigContext)
# Check that original data is preserved
assert tagged_data == data
def test_config_context_defaults_only() -> None:
"""Test that defaults: key is popped and used as context vars when no explicit vars given."""
data = {"defaults": {"x": "1", "y": "2"}, "key": "value"}
tagged = yaml_util.add_context(data, None)
assert isinstance(tagged, yaml_util.ConfigContext)
assert tagged.vars == {"x": "1", "y": "2"}
assert "defaults" not in tagged
def test_config_context_defaults_explicit_vars_override() -> None:
"""Test that explicit vars take precedence over defaults: values."""
data = {"defaults": {"x": "default_x", "z": "default_z"}, "key": "value"}
tagged = yaml_util.add_context(data, {"x": "explicit_x", "w": "explicit_w"})
assert isinstance(tagged, yaml_util.ConfigContext)
assert tagged.vars == {"x": "explicit_x", "z": "default_z", "w": "explicit_w"}
assert "defaults" not in tagged
def test_represent_extend() -> None:
"""Test that Extend objects are dumped as plain !extend scalars."""
assert yaml_util.dump({"key": Extend("my_id")}) == "key: !extend 'my_id'\n"
def test_represent_remove() -> None:
"""Test that Remove objects are dumped as plain !remove scalars."""
assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n"