Merge remote-tracking branch 'upstream/dev' into remove_posix_tz_parser

# Conflicts:
#	esphome/components/api/api_pb2_dump.cpp
This commit is contained in:
J. Nick Koston
2026-03-25 20:33:26 -10:00
354 changed files with 7932 additions and 3480 deletions
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
manifest.enable_codegen()
@@ -0,0 +1,306 @@
#include "esphome/core/defines.h"
#ifdef USE_API_NOISE
#include <benchmark/benchmark.h>
#include <cstring>
#include <memory>
#include "noise/protocol.h"
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// Helper to create and initialize a NoiseCipherState with ChaChaPoly.
// Returns nullptr on failure.
static NoiseCipherState *create_cipher() {
NoiseCipherState *cipher = nullptr;
int err = noise_cipherstate_new_by_id(&cipher, NOISE_CIPHER_CHACHAPOLY);
if (err != NOISE_ERROR_NONE || cipher == nullptr)
return nullptr;
// Initialize with a dummy 32-byte key (same pattern as handshake split produces)
uint8_t key[32];
memset(key, 0xAB, sizeof(key));
err = noise_cipherstate_init_key(cipher, key, sizeof(key));
if (err != NOISE_ERROR_NONE) {
noise_cipherstate_free(cipher);
return nullptr;
}
return cipher;
}
// Benchmark helper matching the exact pattern from
// APINoiseFrameHelper::write_protobuf_messages:
// - noise_buffer_init + noise_buffer_set_inout (same as production)
// - No explicit set_nonce (production relies on internal nonce increment)
// - Error checking on encrypt return
static void noise_encrypt_bench(benchmark::State &state, size_t plaintext_size) {
NoiseCipherState *cipher = create_cipher();
if (cipher == nullptr) {
state.SkipWithError("Failed to create cipher state");
return;
}
size_t mac_len = noise_cipherstate_get_mac_length(cipher);
size_t buf_capacity = plaintext_size + mac_len;
auto buffer = std::make_unique<uint8_t[]>(buf_capacity);
memset(buffer.get(), 0x42, plaintext_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
// Match production: init buffer, set inout, encrypt
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buffer.get(), plaintext_size, buf_capacity);
int err = noise_cipherstate_encrypt(cipher, &mbuf);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("noise_cipherstate_encrypt failed");
noise_cipherstate_free(cipher);
return;
}
}
benchmark::DoNotOptimize(buffer[0]);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
noise_cipherstate_free(cipher);
}
// --- Encrypt a typical sensor state message (small payload ~14 bytes) ---
// This is the most common message encrypted on every sensor update.
// 4 bytes type+len header + ~10 bytes payload.
static void NoiseEncrypt_SmallMessage(benchmark::State &state) { noise_encrypt_bench(state, 14); }
BENCHMARK(NoiseEncrypt_SmallMessage);
// --- Encrypt a medium message (~128 bytes, typical for LightStateResponse) ---
static void NoiseEncrypt_MediumMessage(benchmark::State &state) { noise_encrypt_bench(state, 128); }
BENCHMARK(NoiseEncrypt_MediumMessage);
// --- Encrypt a large message (~1024 bytes, typical for DeviceInfoResponse) ---
static void NoiseEncrypt_LargeMessage(benchmark::State &state) { noise_encrypt_bench(state, 1024); }
BENCHMARK(NoiseEncrypt_LargeMessage);
// Benchmark helper matching the exact pattern from
// APINoiseFrameHelper::read_packet:
// - noise_buffer_init + noise_buffer_set_inout with capacity == size (decrypt shrinks)
// - Error checking on decrypt return
//
// Pre-encrypts kInnerIterations messages with sequential nonces before the
// timed loop. Each outer iteration re-inits the decrypt key to reset the
// nonce back to 0, then decrypts all pre-encrypted messages in sequence.
// The init_key cost is amortized over kInnerIterations decrypts.
static void noise_decrypt_bench(benchmark::State &state, size_t plaintext_size) {
NoiseCipherState *encrypt_cipher = create_cipher();
NoiseCipherState *decrypt_cipher = create_cipher();
if (encrypt_cipher == nullptr || decrypt_cipher == nullptr) {
state.SkipWithError("Failed to create cipher state");
if (encrypt_cipher)
noise_cipherstate_free(encrypt_cipher);
if (decrypt_cipher)
noise_cipherstate_free(decrypt_cipher);
return;
}
size_t mac_len = noise_cipherstate_get_mac_length(encrypt_cipher);
size_t encrypted_size = plaintext_size + mac_len;
// Pre-encrypt kInnerIterations messages with sequential nonces (0..N-1).
auto ciphertexts = std::make_unique<uint8_t[]>(encrypted_size * kInnerIterations);
for (int i = 0; i < kInnerIterations; i++) {
uint8_t *ct = ciphertexts.get() + i * encrypted_size;
memset(ct, 0x42, plaintext_size);
NoiseBuffer enc_buf;
noise_buffer_init(enc_buf);
noise_buffer_set_inout(enc_buf, ct, plaintext_size, encrypted_size);
int err = noise_cipherstate_encrypt(encrypt_cipher, &enc_buf);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Pre-encrypt failed");
noise_cipherstate_free(encrypt_cipher);
noise_cipherstate_free(decrypt_cipher);
return;
}
}
// Working buffer — decrypt modifies in place
auto buffer = std::make_unique<uint8_t[]>(encrypted_size);
static constexpr uint8_t KEY[32] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB,
0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB,
0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB};
for (auto _ : state) {
// Reset nonce to 0 by re-initing the key (amortized over kInnerIterations)
noise_cipherstate_init_key(decrypt_cipher, KEY, sizeof(KEY));
for (int i = 0; i < kInnerIterations; i++) {
// Copy ciphertext into working buffer (decrypt modifies in place)
memcpy(buffer.get(), ciphertexts.get() + i * encrypted_size, encrypted_size);
// Decrypt matching production pattern
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buffer.get(), encrypted_size, encrypted_size);
int err = noise_cipherstate_decrypt(decrypt_cipher, &mbuf);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("noise_cipherstate_decrypt failed");
noise_cipherstate_free(encrypt_cipher);
noise_cipherstate_free(decrypt_cipher);
return;
}
}
benchmark::DoNotOptimize(buffer[0]);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
noise_cipherstate_free(encrypt_cipher);
noise_cipherstate_free(decrypt_cipher);
}
// --- Decrypt benchmarks (matching read_packet path) ---
static void NoiseDecrypt_SmallMessage(benchmark::State &state) { noise_decrypt_bench(state, 14); }
BENCHMARK(NoiseDecrypt_SmallMessage);
static void NoiseDecrypt_MediumMessage(benchmark::State &state) { noise_decrypt_bench(state, 128); }
BENCHMARK(NoiseDecrypt_MediumMessage);
static void NoiseDecrypt_LargeMessage(benchmark::State &state) { noise_decrypt_bench(state, 1024); }
BENCHMARK(NoiseDecrypt_LargeMessage);
// --- Full Noise_NNpsk0 handshake benchmark ---
// Measures the complete handshake between initiator and responder:
// - Create handshake states for both sides
// - Set PSK and prologue
// - Exchange messages (initiator write -> responder read -> responder write -> initiator read)
// - Split to get cipher states
// This is dominated by Curve25519 DH operations (expensive on ESP8266).
// No inner iterations — each handshake is already expensive enough.
static void NoiseHandshake_Full(benchmark::State &state) {
// Matching ESPHome's protocol: Noise_NNpsk0_25519_ChaChaPoly_SHA256
NoiseProtocolId nid;
memset(&nid, 0, sizeof(nid));
nid.pattern_id = NOISE_PATTERN_NN;
nid.cipher_id = NOISE_CIPHER_CHACHAPOLY;
nid.dh_id = NOISE_DH_CURVE25519;
nid.prefix_id = NOISE_PREFIX_STANDARD;
nid.hybrid_id = NOISE_DH_NONE;
nid.hash_id = NOISE_HASH_SHA256;
nid.modifier_ids[0] = NOISE_MODIFIER_PSK0;
// Dummy PSK (32 bytes) and prologue matching production setup
static constexpr uint8_t PSK[32] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB,
0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB,
0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB};
static constexpr uint8_t PROLOGUE[] = "NoESPHome";
// Message buffer for handshake exchange (max handshake message ~96 bytes)
uint8_t msg_buf[128];
for (auto _ : state) {
NoiseHandshakeState *initiator = nullptr;
NoiseHandshakeState *responder = nullptr;
NoiseCipherState *init_send = nullptr, *init_recv = nullptr;
NoiseCipherState *resp_send = nullptr, *resp_recv = nullptr;
int err;
// Create both handshake states
err = noise_handshakestate_new_by_id(&initiator, &nid, NOISE_ROLE_INITIATOR);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Failed to create initiator");
return;
}
err = noise_handshakestate_new_by_id(&responder, &nid, NOISE_ROLE_RESPONDER);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Failed to create responder");
noise_handshakestate_free(initiator);
return;
}
// Set PSK and prologue on both sides
noise_handshakestate_set_pre_shared_key(initiator, PSK, sizeof(PSK));
noise_handshakestate_set_pre_shared_key(responder, PSK, sizeof(PSK));
noise_handshakestate_set_prologue(initiator, PROLOGUE, sizeof(PROLOGUE) - 1);
noise_handshakestate_set_prologue(responder, PROLOGUE, sizeof(PROLOGUE) - 1);
noise_handshakestate_start(initiator);
noise_handshakestate_start(responder);
// Message 1: Initiator -> Responder
NoiseBuffer write_buf, read_buf;
noise_buffer_set_output(write_buf, msg_buf, sizeof(msg_buf));
err = noise_handshakestate_write_message(initiator, &write_buf, nullptr);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Initiator write_message failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
return;
}
noise_buffer_set_input(read_buf, msg_buf, write_buf.size);
err = noise_handshakestate_read_message(responder, &read_buf, nullptr);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Responder read_message failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
return;
}
// Message 2: Responder -> Initiator
noise_buffer_set_output(write_buf, msg_buf, sizeof(msg_buf));
err = noise_handshakestate_write_message(responder, &write_buf, nullptr);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Responder write_message failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
return;
}
noise_buffer_set_input(read_buf, msg_buf, write_buf.size);
err = noise_handshakestate_read_message(initiator, &read_buf, nullptr);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Initiator read_message failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
return;
}
// Split to get cipher states
err = noise_handshakestate_split(initiator, &init_send, &init_recv);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Initiator split failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
return;
}
err = noise_handshakestate_split(responder, &resp_send, &resp_recv);
if (err != NOISE_ERROR_NONE) {
state.SkipWithError("Responder split failed");
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
noise_cipherstate_free(init_send);
noise_cipherstate_free(init_recv);
return;
}
benchmark::DoNotOptimize(init_send);
// Cleanup
noise_handshakestate_free(initiator);
noise_handshakestate_free(responder);
noise_cipherstate_free(init_send);
noise_cipherstate_free(init_recv);
noise_cipherstate_free(resp_send);
noise_cipherstate_free(resp_recv);
}
}
BENCHMARK(NoiseHandshake_Full);
} // namespace esphome::api::benchmarks
#endif // USE_API_NOISE
@@ -0,0 +1,162 @@
#include "esphome/core/defines.h"
#ifdef USE_API_PLAINTEXT
#include <benchmark/benchmark.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/api/api_frame_helper_plaintext.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// Helper to drain accumulated data from the read side of a socket
// to prevent the write side from blocking.
static void drain_socket(int fd) {
char buf[65536];
while (::read(fd, buf, sizeof(buf)) > 0) {
}
}
// Helper to create a TCP loopback connection with an APIPlaintextFrameHelper
// on the write end. Returns the helper and the read-side fd.
// Uses real TCP sockets so TCP_NODELAY succeeds during init().
static std::pair<std::unique_ptr<APIPlaintextFrameHelper>, int> create_plaintext_helper() {
// Create a TCP listener on loopback
int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0; // OS-assigned port
::bind(listen_fd, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr));
::listen(listen_fd, 1);
// Get the assigned port
socklen_t addr_len = sizeof(addr);
::getsockname(listen_fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len);
// Connect from client side
int write_fd = ::socket(AF_INET, SOCK_STREAM, 0);
::connect(write_fd, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr));
// Accept on server side (this is our read fd)
int read_fd = ::accept(listen_fd, nullptr, nullptr);
::close(listen_fd);
// Make both ends non-blocking
int flags = ::fcntl(write_fd, F_GETFL, 0);
::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK);
flags = ::fcntl(read_fd, F_GETFL, 0);
::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK);
// Increase socket buffer sizes to reduce drain frequency
int bufsize = 1024 * 1024;
::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize));
::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
auto sock = std::make_unique<socket::Socket>(write_fd);
auto helper = std::make_unique<APIPlaintextFrameHelper>(std::move(sock));
helper->init();
return {std::move(helper), read_fd};
}
// --- Write a single SensorStateResponse through plaintext framing ---
// Measures the full write path: header construction, varint encoding,
// iovec assembly, and socket write.
static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
auto [helper, read_fd] = create_plaintext_helper();
uint8_t padding = helper->frame_header_padding();
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
buffer.clear();
SensorStateResponse msg;
msg.key = 0x12345678;
msg.state = 23.5f;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(padding + size);
ProtoWriteBuffer writer(&buffer, padding);
msg.encode(writer);
helper->write_protobuf_packet(SensorStateResponse::MESSAGE_TYPE, writer);
if ((i & 0xFF) == 0)
drain_socket(read_fd);
}
drain_socket(read_fd);
benchmark::DoNotOptimize(helper.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(PlaintextFrame_WriteSensorState);
// --- Write a batch of 5 SensorStateResponses in one call ---
// Measures batched write: multiple messages assembled into one writev.
static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
auto [helper, read_fd] = create_plaintext_helper();
uint8_t padding = helper->frame_header_padding();
uint8_t footer = helper->frame_footer_size();
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
buffer.clear();
MessageInfo messages[5] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
for (int j = 0; j < 5; j++) {
uint16_t offset = buffer.size();
SensorStateResponse msg;
msg.key = static_cast<uint32_t>(j);
msg.state = 23.5f + static_cast<float>(j);
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(offset + padding + size + footer);
ProtoWriteBuffer writer(&buffer, offset + padding);
msg.encode(writer);
messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size);
}
helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span<const MessageInfo>(messages, 5));
if ((i & 0xFF) == 0)
drain_socket(read_fd);
}
drain_socket(read_fd);
benchmark::DoNotOptimize(helper.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(PlaintextFrame_WriteBatch5);
} // namespace esphome::api::benchmarks
#endif // USE_API_PLAINTEXT
@@ -108,6 +108,7 @@ esphome:
area_id: area_20
api:
encryption:
sensor:
binary_sensor:
light:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,61 @@
#include <benchmark/benchmark.h>
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::binary_sensor::benchmarks {
static constexpr int kInnerIterations = 2000;
// Benchmark: publish_state with alternating values (forces state change every time)
static void BinarySensorPublish_Alternating(benchmark::State &state) {
BinarySensor sensor;
// First publish to establish initial state
sensor.publish_initial_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(i % 2 == 0);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(BinarySensorPublish_Alternating);
// Benchmark: publish_state with same value (tests dedup fast path)
static void BinarySensorPublish_NoChange(benchmark::State &state) {
BinarySensor sensor;
sensor.publish_initial_state(true);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(true);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(BinarySensorPublish_NoChange);
// Benchmark: publish_state with a callback registered
static void BinarySensorPublish_WithCallback(benchmark::State &state) {
BinarySensor sensor;
int callback_count = 0;
sensor.add_on_state_callback([&callback_count](bool) { callback_count++; });
sensor.publish_initial_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(i % 2 == 0);
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(BinarySensorPublish_WithCallback);
} // namespace esphome::binary_sensor::benchmarks
@@ -0,0 +1 @@
binary_sensor:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,12 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Sensor filter benchmarks need USE_SENSOR_FILTER defined.
# We use a custom to_code instead of enable_codegen() to avoid
# pulling in the full sensor component setup.
async def to_code(config):
cg.add_define("USE_SENSOR_FILTER")
manifest.to_code = to_code
@@ -0,0 +1,78 @@
#include <benchmark/benchmark.h>
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/sensor/filter.h"
namespace esphome::sensor::benchmarks {
static constexpr int kInnerIterations = 2000;
// Benchmark: sensor publish through a SlidingWindowMovingAverageFilter (window=5, send_every=1)
static void SensorFilter_SlidingWindowAvg(benchmark::State &state) {
Sensor sensor;
// Create filter: window_size=5, send_every=1, send_first_at=1
auto *filter = new SlidingWindowMovingAverageFilter(5, 1, 1);
sensor.add_filter(filter);
float value = 0.0f;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(value);
value += 0.1f;
if (value > 1000.0f)
value = 0.0f;
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorFilter_SlidingWindowAvg);
// Benchmark: sensor publish through ExponentialMovingAverageFilter
static void SensorFilter_ExponentialMovingAvg(benchmark::State &state) {
Sensor sensor;
// alpha=0.1, send_every=1, send_first_at=1
auto *filter = new ExponentialMovingAverageFilter(0.1f, 1, 1);
sensor.add_filter(filter);
float value = 0.0f;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(value);
value += 0.1f;
if (value > 1000.0f)
value = 0.0f;
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorFilter_ExponentialMovingAvg);
// Benchmark: sensor publish through a chain of 3 filters (offset + multiply + sliding window)
static void SensorFilter_Chain3(benchmark::State &state) {
Sensor sensor;
sensor.add_filters({
new OffsetFilter(1.0f),
new MultiplyFilter(2.0f),
new SlidingWindowMovingAverageFilter(5, 1, 1),
});
float value = 0.0f;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(value);
value += 0.1f;
if (value > 1000.0f)
value = 0.0f;
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorFilter_Chain3);
} // namespace esphome::sensor::benchmarks
@@ -0,0 +1,79 @@
#include <benchmark/benchmark.h>
#include "esphome/components/sensor/sensor.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;
// Test subclass to access protected configure_entity_() for benchmark setup.
class TestSensor : public sensor::Sensor {
public:
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
};
// --- Sensor::publish_state() with no callbacks registered ---
// Measures baseline publish overhead: state assignment, logging,
// internal_send_state_to_frontend, ControllerRegistry notification.
static void SensorPublish_NoCallbacks(benchmark::State &state) {
TestSensor sensor;
sensor.configure("test_sensor");
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(static_cast<float>(i));
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorPublish_NoCallbacks);
// --- Sensor::publish_state() with one state callback ---
// Measures callback dispatch overhead through LazyCallbackManager.
static void SensorPublish_WithCallback(benchmark::State &state) {
TestSensor sensor;
sensor.configure("test_sensor");
float callback_value = 0.0f;
sensor.add_on_state_callback([&callback_value](float value) { callback_value = value; });
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(static_cast<float>(i));
}
benchmark::DoNotOptimize(callback_value);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorPublish_WithCallback);
// --- Sensor::publish_state() with the same value every time ---
// Steady-state pattern: sensor reports an unchanged reading.
// Sensor doesn't dedup today, so this exercises the same code path
// as changing values, but tracks the common real-world pattern
// separately for regression detection.
static void SensorPublish_SameValue(benchmark::State &state) {
TestSensor sensor;
sensor.configure("test_sensor");
// Warm up so has_state is already set
sensor.publish_state(23.5f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(23.5f);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SensorPublish_SameValue);
} // namespace esphome::benchmarks
@@ -0,0 +1 @@
sensor:
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# socket must run its to_code to define USE_SOCKET_IMPL_BSD_SOCKETS
# which is needed by the api frame helper benchmarks.
manifest.enable_codegen()
@@ -15,7 +15,7 @@ def test_binary_sensor_is_setup(generate_main):
)
# Then
assert "new gpio::GPIOBinarySensor();" in main_cpp
assert "static gpio::GPIOBinarySensor *const" in main_cpp
assert "App.register_binary_sensor" in main_cpp
+2 -1
View File
@@ -13,7 +13,8 @@ def test_button_is_setup(generate_main):
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
# Then
assert "new wake_on_lan::WakeOnLanButton();" in main_cpp
assert "static wake_on_lan::WakeOnLanButton *const" in main_cpp
assert ") wake_on_lan::WakeOnLanButton();" in main_cpp
assert "App.register_button" in main_cpp
assert "App.register_component" in main_cpp
+1 -1
View File
@@ -134,7 +134,7 @@ def generate_main() -> Generator[Callable[[str | Path], str]]:
CORE.config_path = Path(path)
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
return CORE.cpp_global_section + CORE.cpp_main_section
yield generator
@@ -7,7 +7,11 @@ def test_deep_sleep_setup(generate_main):
"""
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml")
assert "deepsleep = new deep_sleep::DeepSleepComponent();" in main_cpp
assert (
"static deep_sleep::DeepSleepComponent *const deepsleep = reinterpret_cast<deep_sleep::DeepSleepComponent *>(deep_sleep__deepsleep__pstorage);"
in main_cpp
)
assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp
assert "App.register_component_(deepsleep);" in main_cpp
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
globals:
- id: my_global_int
type: int
initial_value: "42"
- id: my_global_float
type: float
initial_value: "1.5"
- id: my_global_bool
type: bool
initial_value: "true"
@@ -0,0 +1,27 @@
"""Tests for the globals component."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
def test_globals_placement_new_with_template_args(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that globals uses placement new with template arguments preserved."""
main_cpp = generate_main(component_config_path("globals_test.yaml"))
# Globals uses Pvariable with Type.new(template_args, initial_value)
# which exercises the template_args preservation in placement new.
assert "static globals::GlobalsComponent<int> *const my_global_int" in main_cpp
assert "sizeof(globals::GlobalsComponent<int>)" in main_cpp
assert "new(my_global_int) globals::GlobalsComponent<int>" in main_cpp
# Verify initial value is passed as constructor arg
assert "42" in main_cpp
# Check other globals are also generated
assert "sizeof(globals::GlobalsComponent<float>)" in main_cpp
assert "sizeof(globals::GlobalsComponent<bool>)" in main_cpp
@@ -16,7 +16,8 @@ def test_gpio_binary_sensor_basic_setup(
"""
main_cpp = generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
assert "new gpio::GPIOBinarySensor();" in main_cpp
assert "static gpio::GPIOBinarySensor *const" in main_cpp
assert ") gpio::GPIOBinarySensor();" in main_cpp
assert "App.register_binary_sensor" in main_cpp
# set_use_interrupt(true) should NOT be generated (uses C++ default)
assert "bs_gpio->set_use_interrupt(true);" not in main_cpp
+9 -1
View File
@@ -242,7 +242,15 @@ def test_image_generation(
main_cpp = generate_main(component_config_path("image_test.yaml"))
assert "uint8_t_id[] PROGMEM = {0x24, 0x21, 0x24, 0x21" in main_cpp
assert (
"cat_img = new image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);"
"alignas(image::Image) static unsigned char image__cat_img__pstorage[sizeof(image::Image)];"
in main_cpp
)
assert (
"static image::Image *const cat_img = reinterpret_cast<image::Image *>(image__cat_img__pstorage);"
in main_cpp
)
assert (
"new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);"
in main_cpp
)
@@ -22,6 +22,10 @@ def test_logger_pre_setup_before_other_components(generate_main):
# Find all "new " allocations (component creation)
new_allocations = list(re.finditer(r"\bnew [\w:]+", main_cpp))
# Find all "new(" allocations (component creation) and combine them
new_allocations.extend(re.finditer(r"\bnew\([^)]+\) [\w:]+", main_cpp))
# Sort allocations by position in the file
new_allocations.sort(key=lambda m: m.start())
assert len(new_allocations) > 0, "No component allocations found"
# Separate logger and non-logger allocations
@@ -119,7 +119,15 @@ def test_code_generation(
main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml"))
assert (
"p4_nano = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
"alignas(mipi_dsi::MIPI_DSI) static unsigned char mipi_dsi__p4_nano__pstorage[sizeof(mipi_dsi::MIPI_DSI)];"
in main_cpp
)
assert (
"static mipi_dsi::MIPI_DSI *const p4_nano = reinterpret_cast<mipi_dsi::MIPI_DSI *>(mipi_dsi__p4_nano__pstorage);"
in main_cpp
)
assert (
"new(p4_nano) mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
in main_cpp
)
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
+2 -2
View File
@@ -69,7 +69,7 @@ def test_packages_skip_update_false(
}
# Call with skip_update=False (default)
do_packages_pass(config, skip_update=False)
do_packages_pass(config, command_line_substitutions={}, skip_update=False)
# Verify clone_or_update was called with actual refresh value
mock_clone_or_update.assert_called_once()
@@ -104,7 +104,7 @@ def test_packages_default_no_skip(
}
# Call without skip_update parameter
do_packages_pass(config)
do_packages_pass(config, command_line_substitutions={})
# Verify clone_or_update was called with actual refresh value
mock_clone_or_update.assert_called_once()
+168 -12
View File
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
from esphome.components.substitutions import do_substitution_pass
import esphome.config as config_module
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
@@ -36,6 +37,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.util import OrderedDict
from esphome.yaml_util import add_context
# Test strings
TEST_DEVICE_NAME = "test_device_name"
@@ -69,8 +71,9 @@ def fixture_basic_esphome():
def packages_pass(config):
"""Wrapper around packages_pass that also resolves Extend and Remove."""
"""Passes the config through the packages processing steps."""
config = do_packages_pass(config)
config = do_substitution_pass(config)
config = merge_packages(config)
resolve_extend_remove(config)
return config
@@ -703,6 +706,85 @@ def test_remote_packages_with_files_list(
assert actual == expected
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_with_files_list_and_substitutions(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""
Ensures that packages are loaded as mixed list of dictionary and strings
"""
# Mock the response from git.clone_or_update
mock_revert = MagicMock()
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
# Mock the response from pathlib.Path.is_file
mock_is_file.return_value = True
# Mock the response from esphome.yaml_util.load_yaml
mock_load_yaml.side_effect = [
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
}
]
}
),
OrderedDict(
{
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
}
]
}
),
]
# Define the input config
config = {
CONF_PACKAGES: {
"package1": add_context(
{
CONF_URL: r"${url}",
CONF_REF: r"${branch}",
CONF_FILES: [
{CONF_PATH: r"$file"},
"sensor2.yaml",
],
CONF_REFRESH: "1d",
},
{
"branch": "main",
"file": TEST_YAML_FILENAME,
"url": "https://github.com/esphome/non-existant-repo",
},
)
}
}
expected = {
CONF_SENSOR: [
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_1,
},
{
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
CONF_NAME: TEST_SENSOR_NAME_2,
},
]
}
actual = packages_pass(config)
assert actual == expected
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
@@ -904,7 +986,7 @@ def test_packages_merge_substitutions() -> None:
},
}
actual = do_packages_pass(config)
actual = do_packages_pass(config, command_line_substitutions={})
assert actual == expected
@@ -968,33 +1050,107 @@ def test_package_merge() -> None:
assert actual == expected
def test_packages_invalid_type_raises() -> None:
"""Packages that are not a dict or list raise cv.Invalid."""
config = {
CONF_PACKAGES: "not_a_dict_or_list",
}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
):
do_packages_pass(config)
@pytest.mark.parametrize(
"invalid_package",
[
6,
"some string",
["some string"],
None,
True,
{"some_component": 8},
{3: 2},
{"some_component": r"${unevaluated expression}"},
],
)
def test_package_merge_invalid(invalid_package) -> None:
"""
Tests that trying to merge an invalid package raises an error.
"""
def test_invalid_package_contents_rejected(invalid_package: object) -> None:
"""Invalid package contents are rejected by PACKAGE_SCHEMA during do_packages_pass."""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
@pytest.mark.xfail(
reason="Deprecated single-package fallback swallows these errors. "
"Remove xfail when single-package deprecation is removed (2026.7.0).",
strict=True,
)
@pytest.mark.parametrize(
"invalid_package",
[
None,
["some string"],
{"some_component": 8},
{3: 2},
],
)
def test_invalid_package_contents_masked_by_deprecation(
invalid_package: object,
) -> None:
"""These invalid packages are swallowed by the deprecated single-package fallback."""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
def test_merge_packages_invalid_nested_type_raises() -> None:
"""Invalid nested packages type during merge raises cv.Invalid."""
config = {
CONF_PACKAGES: {
"pkg": {
CONF_PACKAGES: "invalid",
},
},
}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
):
merge_packages(config)
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_no_revert(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""Remote packages with revert=None load without retry logic."""
mock_clone_or_update.return_value = (Path("/tmp/noexists"), None)
mock_is_file.return_value = True
mock_load_yaml.return_value = OrderedDict(
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
)
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
actual = packages_pass(config)
assert actual[CONF_SENSOR] == [
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
]
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
@@ -0,0 +1,8 @@
esphome:
name: test
esp32:
board: esp32dev
status_led:
pin: GPIO2
@@ -0,0 +1,23 @@
"""Tests for status_led."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
def test_status_led_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test status_led generation."""
main_cpp = generate_main(component_config_path("status_led_test.yaml"))
assert (
"alignas(status_led::StatusLED) static unsigned char status_led__status_led_statusled_id__pstorage[sizeof(status_led::StatusLED)];"
in main_cpp
)
assert (
"static status_led::StatusLED *const status_led_statusled_id = reinterpret_cast<status_led::StatusLED *>(status_led__status_led_statusled_id__pstorage);"
in main_cpp
)
assert "new(status_led_statusled_id) status_led::StatusLED(" in main_cpp
+2 -1
View File
@@ -13,7 +13,8 @@ def test_text_is_setup(generate_main):
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert "new template_::TemplateText();" in main_cpp
assert "static template_::TemplateText *const" in main_cpp
assert ") template_::TemplateText();" in main_cpp
assert "App.register_text" in main_cpp
@@ -13,7 +13,8 @@ def test_text_sensor_is_setup(generate_main):
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
# Then
assert "new template_::TemplateTextSensor();" in main_cpp
assert "static template_::TemplateTextSensor *const" in main_cpp
assert ") template_::TemplateTextSensor();" in main_cpp
assert "App.register_text_sensor" in main_cpp
@@ -19,6 +19,7 @@ esp32:
disable_mbedtls_pkcs7: true
disable_regi2c_in_iram: true
disable_fatfs: true
sram1_as_iram: true
wifi:
ssid: MySSID
@@ -0,0 +1,18 @@
ethernet:
type: ENC28J60
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"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,19 @@
ethernet:
type: ENC28J60
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
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"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,18 @@
ethernet:
type: W5100
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"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1 @@
<<: !include common-enc28j60.yaml
@@ -0,0 +1 @@
<<: !include common-enc28j60-rp2040.yaml
@@ -0,0 +1 @@
<<: !include common-w5100-rp2040.yaml
@@ -8,6 +8,7 @@ infrared:
- platform: ir_rf_proxy
id: ir_rx
name: "IR Receiver"
receiver_frequency: 38kHz
remote_receiver_id: ir_receiver
# RF 900MHz receiver
+1 -1
View File
@@ -16,7 +16,7 @@ class MockUARTComponent : public uart::UARTComponent {
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(uart::FlushResult, flush, (), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
+22 -21
View File
@@ -37,11 +37,12 @@ lvgl:
on_resume:
logger.log: LVGL has resumed
on_boot:
logger.log: LVGL has started
- logger.log: LVGL has started
- lvgl.indicator.update:
id: meter_arc_indicator
start_value: 0
end_value: 180
bg_color: light_blue
disp_bg_color: color_id
disp_bg_image: cat_image
disp_bg_opa: cover
bottom_layer:
widgets:
- obj:
@@ -54,7 +55,6 @@ lvgl:
gradients:
- id: color_bar
direction: hor
# dither: err_diff
stops:
- color: 0xFF0000
position: 0
@@ -139,12 +139,11 @@ lvgl:
body:
text: This is a sample messagebox
bg_color: 0x808080
button_style:
bg_color: 0xff00
border_width: 4
buttons:
- id: msgbox_button
text: Button
bg_color: 0x00ff00
border_width: 4
- id: msgbox_apply
text: "Close"
on_click:
@@ -156,8 +155,8 @@ lvgl:
bg_opa: !lambda return 0.5;
- lvgl.image.update:
id: lv_image
zoom: !lambda return 512;
angle: !lambda return 100;
scale: !lambda return 512;
rotation: !lambda return 100;
pivot_x: !lambda return 20;
pivot_y: !lambda return 20;
offset_x: !lambda return 20;
@@ -283,8 +282,8 @@ lvgl:
then:
- lvgl.animimg.stop: anim_img
- lvgl.update:
disp_bg_color: 0xffff00
disp_bg_image: none
bottom_layer:
bg_color: 0xffff00
- lvgl.widget.show: message_box
- label:
text: "Hello shiny day"
@@ -357,8 +356,6 @@ lvgl:
pad_right: 10px
pad_top: 10px
shadow_color: light_blue
shadow_ofs_x: 5
shadow_ofs_y: 5
shadow_opa: cover
shadow_spread: 5
shadow_width: 10
@@ -369,12 +366,10 @@ lvgl:
text_letter_space: 4
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
@@ -466,11 +461,11 @@ lvgl:
id: button_button
width: 20%
height: 10%
transform_angle: !lambda return(180*100);
transform_rotation: !lambda return(180*100);
arc_width: !lambda return 4;
border_width: !lambda return 6;
shadow_ofs_x: !lambda return 6;
shadow_ofs_y: !lambda return 6;
shadow_offset_x: !lambda return 6;
shadow_offset_y: !lambda return 6;
shadow_spread: !lambda return 6;
shadow_width: !lambda return 6;
pressed:
@@ -642,8 +637,8 @@ lvgl:
border_opa: 80%
shadow_color: black
shadow_width: 10
shadow_ofs_x: 5
shadow_ofs_y: 5
shadow_offset_x: 5
shadow_offset_y: 5
shadow_spread: 4
shadow_opa: cover
outline_color: red
@@ -1110,6 +1105,12 @@ lvgl:
color: 0xA0A0A0
length: 80%
opa: 0%
- arc:
id: meter_arc_indicator
color: 0xFF0000
width: 6
start_value: 0
end_value: 360
- id: page3
layout: Horizontal
pad_all: 6px
+30 -17
View File
@@ -273,26 +273,39 @@ text_sensor:
display:
- platform: nextion
id: main_lcd
auto_wake_on_touch: true
brightness: 80%
command_spacing: 5ms
dump_device_info: true
exit_reparse_on_start: true
lambda: |-
ESP_LOGD("display","Display is being tested!");
max_commands_per_loop: 20
max_queue_age: 5000ms # Remove queue items after 5s
max_queue_size: 50
update_interval: 5s
on_sleep:
then:
lambda: 'ESP_LOGD("display","Display went to sleep");'
on_wake:
then:
lambda: 'ESP_LOGD("display","Display woke up");'
on_setup:
then:
lambda: 'ESP_LOGD("display","Display setup completed");'
on_page:
then:
lambda: 'ESP_LOGD("display","Display shows new page %u", x);'
on_buffer_overflow:
then:
logger.log: "Nextion reported a buffer overflow!"
command_spacing: 5ms
dump_device_info: true
max_queue_age: 5000ms # Remove queue items after 5s
on_page:
then:
lambda: 'ESP_LOGD("display","Display shows new page %u", x);'
on_setup:
then:
lambda: 'ESP_LOGD("display","Display setup completed");'
on_sleep:
then:
lambda: 'ESP_LOGD("display","Display went to sleep");'
on_touch:
then:
lambda: |-
ESP_LOGD("display",
"Display was touched at page %u, component %u, touch event: %s",
page_id, component_id, touch_event ? "press" : "release");
on_wake:
then:
lambda: 'ESP_LOGD("display","Display woke up");'
update_interval: 5s
start_up_page: 1
startup_override_ms: 10000ms # Wait 10s for display ready
touch_sleep_timeout: 3
wake_up_page: 2
+13
View File
@@ -0,0 +1,13 @@
number:
- platform: template
name: "Test Number"
id: test_number
optimistic: true
min_value: 0
max_value: 100
step: 1
sensor:
- platform: number
name: "Test Number Value"
source_id: test_number
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -5,6 +5,7 @@ namespace esphome::packet_transport::testing {
TEST(PacketTransportBinarySensorTest, AddBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.set_binary_sensor_count(1);
transport.add_binary_sensor("motion", &bs);
ASSERT_EQ(transport.binary_sensors_.size(), 1u);
EXPECT_STREQ(transport.binary_sensors_[0].id, "motion");
@@ -24,6 +25,7 @@ TEST(PacketTransportBinarySensorTest, UnencryptedBinarySensorRoundTrip) {
encoder.init_for_test("sender");
binary_sensor::BinarySensor local_bs;
local_bs.state = true;
encoder.set_binary_sensor_count(1);
encoder.add_binary_sensor("motion", &local_bs);
encoder.send_data_(true);
@@ -46,11 +48,13 @@ TEST(PacketTransportBinarySensorTest, MultipleSensorsRoundTrip) {
sensor::Sensor s1, s2;
s1.state = 10.0f;
s2.state = 20.0f;
encoder.set_sensor_count(2);
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
binary_sensor::BinarySensor bs1;
bs1.state = true;
encoder.set_binary_sensor_count(1);
encoder.add_binary_sensor("bs1", &bs1);
encoder.send_data_(true);
@@ -5,6 +5,7 @@ namespace esphome::packet_transport::testing {
TEST(PacketTransportSensorTest, AddSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.set_sensor_count(1);
transport.add_sensor("temp", &s);
ASSERT_EQ(transport.sensors_.size(), 1u);
EXPECT_STREQ(transport.sensors_[0].id, "temp");
@@ -26,6 +27,7 @@ TEST(PacketTransportSensorTest, UnencryptedSensorRoundTrip) {
encoder.init_for_test("sender");
sensor::Sensor local_sensor;
local_sensor.state = 42.5f;
encoder.set_sensor_count(1);
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
@@ -53,6 +55,7 @@ TEST(PacketTransportSensorTest, EncryptedSensorRoundTrip) {
encoder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 99.9f;
encoder.set_sensor_count(1);
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
@@ -77,6 +80,7 @@ TEST(PacketTransportSensorTest, SendDataOnlyUpdated) {
sensor::Sensor s1, s2;
s1.state = 1.0f;
s2.state = 2.0f;
encoder.set_sensor_count(2);
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
@@ -111,6 +115,7 @@ TEST(PacketTransportSensorTest, PingKeyIncludedInTransmittedPacket) {
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.set_sensor_count(1);
responder.add_sensor("temp", &local_sensor);
// Requester sends a MAGIC_PING that the responder processes
@@ -148,6 +153,7 @@ TEST(PacketTransportSensorTest, MissingPingKeyBlocksSensorData) {
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.set_sensor_count(1);
responder.add_sensor("temp", &local_sensor);
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
+4
View File
@@ -6,4 +6,8 @@ sensor:
humidity:
name: SHT4X Humidity
address: 0x44
precision: High
heater_max_duty: 0.02
heater_power: High
heater_time: Long
update_interval: 15s
+15
View File
@@ -0,0 +1,15 @@
sensor:
- platform: spa06_i2c
i2c_id: i2c_bus
address: 0x77
temperature:
id: spa06_i2c_temperature
name: Outside Temperature
sample_rate: 1
oversampling: NONE
pressure:
name: Outside Pressure
id: spa06_i2c_pressure
sample_rate: 25p4
oversampling: 16X
update_interval: 15s
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
+15
View File
@@ -0,0 +1,15 @@
sensor:
- platform: spa06_spi
spi_id: spi_bus
cs_pin: ${cs_pin}
temperature:
id: spa06_spi_temperature
name: Outside Temperature
sample_rate: 1
oversampling: NONE
pressure:
name: Outside Pressure
id: spa06_spi_pressure
sample_rate: 25p4
oversampling: 16X
update_interval: 15s
@@ -0,0 +1,7 @@
substitutions:
cs_pin: GPIO5
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,7 @@
substitutions:
cs_pin: GPIO15
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,7 @@
substitutions:
cs_pin: GPIO17
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
<<: !include common.yaml
+5
View File
@@ -23,3 +23,8 @@ text:
min_length: 8
max_length: 32
mode: password
text_sensor:
- platform: text
name: "Test Text State"
source_id: test_text
+54 -2
View File
@@ -593,8 +593,6 @@ static time_t esptime_recalc_local(int year, int month, int day, int hour, int m
t.hour = hour;
t.minute = min;
t.second = sec;
t.day_of_week = 1; // Placeholder for fields_in_range()
t.day_of_year = 1;
t.recalc_timestamp_local();
return t.timestamp;
}
@@ -750,6 +748,60 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) {
EXPECT_EQ(esp_result, libc_result);
}
TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) {
// Regression test for issue #15115: DateTimeEntity::state_as_esptime() constructs
// an ESPTime with only year/month/day/hour/minute/second set (no day_of_week or
// day_of_year). recalc_timestamp_local() must work without those fields.
const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0";
setenv("TZ", tz_str, 1);
tzset();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Construct ESPTime with only date/time fields (like state_as_esptime does)
ESPTime t{};
t.year = 2026;
t.month = 3;
t.day_of_month = 20;
t.hour = 23;
t.minute = 14;
t.second = 55;
// day_of_week and day_of_year are deliberately left as 0
t.recalc_timestamp_local();
// Must NOT return -1 (the bug: fields_in_range() rejected valid times)
EXPECT_NE(t.timestamp, -1);
// Verify against libc
time_t libc_result = libc_mktime(2026, 3, 20, 23, 14, 55);
EXPECT_EQ(t.timestamp, libc_result);
}
TEST(RecalcTimestampLocal, MinimalFieldsNoDST) {
// Same test but with a timezone that has no DST
const char *tz_str = "IST-5:30";
setenv("TZ", tz_str, 1);
tzset();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
ESPTime t{};
t.year = 2026;
t.month = 3;
t.day_of_month = 23;
t.hour = 10;
t.minute = 0;
t.second = 0;
t.recalc_timestamp_local();
EXPECT_NE(t.timestamp, -1);
time_t libc_result = libc_mktime(2026, 3, 23, 10, 0, 0);
EXPECT_EQ(t.timestamp, libc_result);
}
TEST(RecalcTimestampLocal, YearBoundaryDST) {
// Test southern hemisphere DST across year boundary
// Australia/Sydney: DST active from October to April (spans Jan 1)
+1 -1
View File
@@ -30,7 +30,7 @@ class MockUARTComponent : public UARTComponent {
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(FlushResult, flush, (), (override));
MOCK_METHOD(UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
@@ -0,0 +1,14 @@
uart:
- id: uart_id
tx_pin: PA23
rx_pin: PA18
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
switch:
- platform: uart
name: "UART Switch"
uart_id: uart_id
data: [0x01, 0x02, 0x03]
@@ -153,9 +153,9 @@ bool MockUartComponent::read_array(uint8_t *data, size_t len) {
size_t MockUartComponent::available() { return this->rx_buffer_.size(); }
uart::FlushResult MockUartComponent::flush() {
uart::UARTFlushResult MockUartComponent::flush() {
// Nothing to flush in mock
return uart::FlushResult::ASSUMED_SUCCESS;
return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS;
}
void MockUartComponent::set_rx_full_threshold(size_t rx_full_threshold) {
@@ -28,7 +28,7 @@ class MockUartComponent : public uart::UARTComponent, public Component {
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
size_t available() override;
uart::FlushResult flush() override;
uart::UARTFlushResult flush() override;
void set_rx_full_threshold(size_t rx_full_threshold) override;
void set_rx_timeout(size_t rx_timeout) override;
@@ -0,0 +1,57 @@
esphome:
name: light-cb-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: cb_cold_white_output
type: float
write_action:
- logger.log:
format: "CB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: cb_warm_white_output
type: float
write_action:
- logger.log:
format: "CB_WW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_cold_white_output
type: float
write_action:
- logger.log:
format: "NCB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_warm_white_output
type: float
write_action:
- logger.log:
format: "NCB_WW_OUTPUT:%.6f"
args: [state]
light:
- platform: cwww
name: "Test CB Light"
id: test_cb_light
cold_white: cb_cold_white_output
warm_white: cb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: true
gamma_correct: 2.8
- platform: cwww
name: "Test NCB Light"
id: test_ncb_light
cold_white: ncb_cold_white_output
warm_white: ncb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: false
gamma_correct: 2.8
@@ -0,0 +1,39 @@
esphome:
name: light-initial-state-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: test_red
type: float
write_action:
- lambda: ""
- platform: template
id: test_green
type: float
write_action:
- lambda: ""
- platform: template
id: test_blue
type: float
write_action:
- lambda: ""
light:
- platform: rgb
name: "Test Light"
id: test_light
red: test_red
green: test_green
blue: test_blue
restore_mode: ALWAYS_OFF
initial_state:
color_mode: RGB
state: true
brightness: 0.75
red: 1.0
green: 0.5
blue: 0.0
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -3,6 +3,7 @@ esphome:
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
@@ -0,0 +1,188 @@
"""Integration test for constant_brightness with gamma correction.
Tests both constant_brightness: true and false cwww lights with gamma
correction in a single compilation to verify:
- constant_brightness: true maintains constant total CW+WW power output
- constant_brightness: false correctly varies total power across color temps
This is a regression test for https://github.com/esphome/esphome/issues/15040
where the gamma LUT refactor (#14123) broke constant_brightness by applying
gamma after the balancing formula instead of before it.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_constant_brightness(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test constant_brightness true and false behavior with gamma correction."""
# Track output values for both lights from log lines
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
latest: dict[str, float] = {
"cb_cw": 0.0,
"cb_ww": 0.0,
"ncb_cw": 0.0,
"ncb_ww": 0.0,
}
def on_log_line(line: str) -> None:
for pattern, key in [
(cb_cw_pattern, "cb_cw"),
(cb_ww_pattern, "cb_ww"),
(ncb_cw_pattern, "ncb_cw"),
(ncb_ww_pattern, "ncb_ww"),
]:
match = pattern.search(line)
if match:
latest[key] = float(match.group(1))
loop = asyncio.get_running_loop()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
lights = [e for e in entities if isinstance(e, LightInfo)]
cb_light = next(e for e in lights if e.object_id.endswith("cb_light"))
ncb_light = next(e for e in lights if e.object_id.endswith("ncb_light"))
# Use InitialStateHelper to wait for initial state broadcast
initial_state_helper = InitialStateHelper(entities)
# Track state changes per light key
state_futures: dict[int, asyncio.Future[EntityState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
async def send_and_wait(
light_key: int, timeout: float = 5.0, **kwargs: Any
) -> LightState:
"""Send a light command and wait for the state response."""
state_futures[light_key] = loop.create_future()
client.light_command(key=light_key, **kwargs)
try:
return await asyncio.wait_for(state_futures[light_key], timeout=timeout)
except TimeoutError:
pytest.fail(f"Timeout waiting for light state after command: {kwargs}")
# --- Test constant_brightness: true ---
# Turn on CB light at full brightness
await send_and_wait(
cb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
test_mireds = [
153.0, # Pure cold white
200.0, # Mostly cold
280.0, # Mixed
326.5, # Midpoint
400.0, # Mostly warm
500.0, # Pure warm white
]
cb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
cb_light.key, color_temperature=mireds, transition_length=0
)
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
# All totals should be approximately equal (constant brightness)
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
assert reference_total > 0, (
f"Reference total power is zero, CB light outputs not working. "
f"Values: {cb_totals}"
)
for mireds, cw, ww in cb_totals:
total = cw + ww
assert total == pytest.approx(reference_total, rel=0.05), (
f"constant_brightness: Total power at {mireds} mireds "
f"({total:.4f}) differs from reference ({reference_total:.4f}) "
f"by more than 5%. CW={cw:.4f}, WW={ww:.4f}. "
f"All values: {cb_totals}"
)
# --- Test constant_brightness: false ---
# Turn on NCB light at full brightness
await send_and_wait(
ncb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
ncb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
ncb_light.key, color_temperature=mireds, transition_length=0
)
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
midpoint = ncb_totals[3] # 326.5 mireds - midpoint
# At pure cold white, WW should be ~0
assert extreme_cw[2] == pytest.approx(0.0, abs=0.01), (
f"Pure cold white should have WW~0, got WW={extreme_cw[2]:.4f}"
)
# At pure warm white, CW should be ~0
assert extreme_ww[1] == pytest.approx(0.0, abs=0.01), (
f"Pure warm white should have CW~0, got CW={extreme_ww[1]:.4f}"
)
# At midpoint, both channels should be non-zero
assert midpoint[1] > 0.05, f"Midpoint CW should be >0.05, got {midpoint[1]:.4f}"
assert midpoint[2] > 0.05, f"Midpoint WW should be >0.05, got {midpoint[2]:.4f}"
# Total power at midpoint should be higher than at the extremes
midpoint_total = midpoint[1] + midpoint[2]
extreme_cw_total = extreme_cw[1] + extreme_cw[2]
extreme_ww_total = extreme_ww[1] + extreme_ww[2]
assert midpoint_total > extreme_cw_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure CW total "
f"({extreme_cw_total:.4f}). All values: {ncb_totals}"
)
assert midpoint_total > extreme_ww_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure WW total "
f"({extreme_ww_total:.4f}). All values: {ncb_totals}"
)
@@ -0,0 +1,38 @@
"""Integration test for light initial_state configuration.
Tests that the initial_state values are correctly applied at boot when
no saved preferences exist. The initial_state callback populates defaults
that the restore logic uses as a fallback.
"""
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that initial_state values are applied at boot."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
light = require_entity(entities, "test_light")
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(lambda s: None))
await helper.wait_for_initial_states()
state = helper.initial_states[light.key]
# restore_mode: ALWAYS_OFF overrides state to false
assert state.state is False
# But the color values from initial_state should be applied
assert state.brightness == pytest.approx(0.75, abs=0.05)
assert state.red == pytest.approx(1.0, abs=0.01)
assert state.green == pytest.approx(0.5, abs=0.01)
assert state.blue == pytest.approx(0.0, abs=0.01)
+14 -19
View File
@@ -79,9 +79,15 @@ async def test_uart_mock_ld2412(
],
)
# Signal when we see recovery frame values
# Signal when we see all recovery frame values
recovery_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
lambda: (
pytest.approx(50.0) in collector.sensor_states["moving_distance"]
and pytest.approx(75.0) in collector.sensor_states["still_distance"]
and pytest.approx(100.0) in collector.sensor_states["moving_energy"]
and pytest.approx(80.0) in collector.sensor_states["still_energy"]
and pytest.approx(50.0) in collector.sensor_states["detection_distance"]
)
)
async with (
@@ -150,23 +156,12 @@ async def test_uart_mock_ld2412(
)
# Recovery frame: moving=50, still=75, energy=100/80, detect=50
recovery_idx = next(
i
for i, v in enumerate(collector.sensor_states["moving_distance"])
if v == pytest.approx(50.0)
)
assert collector.sensor_states["still_distance"][recovery_idx] == pytest.approx(
75.0
)
assert collector.sensor_states["moving_energy"][recovery_idx] == pytest.approx(
100.0
)
assert collector.sensor_states["still_energy"][recovery_idx] == pytest.approx(
80.0
)
assert collector.sensor_states["detection_distance"][
recovery_idx
] == pytest.approx(50.0)
# Check values exist (waiter already ensured all are present)
assert pytest.approx(50.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(75.0) in collector.sensor_states["still_distance"]
assert pytest.approx(100.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(80.0) in collector.sensor_states["still_energy"]
assert pytest.approx(50.0) in collector.sensor_states["detection_distance"]
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
@@ -0,0 +1,90 @@
"""Tests for __pstorage symbol attribution in memory analyzer."""
from unittest.mock import patch
from esphome.analyze_memory import _PSTORAGE_SUFFIX, 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()
return analyzer
def test_pstorage_suffix_constant() -> None:
"""Verify the suffix constant matches what codegen produces."""
assert _PSTORAGE_SUFFIX == "__pstorage"
def test_match_pstorage_simple_component() -> None:
"""Simple component name like 'logger'."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("logger__logger_id__pstorage")
assert result == "[esphome]logger"
def test_match_pstorage_underscore_component() -> None:
"""Component with underscore like 'web_server'."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("web_server__webserver_id__pstorage")
assert result == "[esphome]web_server"
def test_match_pstorage_api() -> None:
"""API component."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("api__apiserver_id__pstorage")
assert result == "[esphome]api"
def test_match_pstorage_deep_sleep() -> None:
"""Component with underscore: deep_sleep."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("deep_sleep__deepsleep__pstorage")
assert result == "[esphome]deep_sleep"
def test_match_pstorage_status_led() -> None:
"""Component with underscore: status_led."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("status_led__statusled_id__pstorage")
assert result == "[esphome]status_led"
def test_match_pstorage_external_component() -> None:
"""External component should be attributed correctly."""
analyzer = _make_analyzer(external_components={"my_custom"})
result = analyzer._match_pstorage_component("my_custom__thing_id__pstorage")
assert result == "[external]my_custom"
def test_match_pstorage_no_dunder_returns_none() -> None:
"""Symbol without double underscore separator returns None."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("something__pstorage")
assert result is None
def test_match_pstorage_unknown_component_returns_none() -> None:
"""Unknown component namespace returns None."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("nonexistent__thing_id__pstorage")
assert result is None
def test_match_pstorage_esphome_component() -> None:
"""esphome:: namespace types map to the esphome component."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component(
"esphome__esphomeotacomponent_id__pstorage"
)
assert result == "[esphome]esphome"
def test_match_pstorage_user_id_with_component_prefix() -> None:
"""User-chosen ID that happens to contain a component name."""
analyzer = _make_analyzer()
result = analyzer._match_pstorage_component("logger__relay1__pstorage")
assert result == "[esphome]logger"
@@ -0,0 +1,117 @@
"""Tests for the gamma LUT table generation."""
import pytest
from esphome.components.light import generate_gamma_table
def _simulate_gamma_correct_lut(table: list[int], value: float) -> float:
"""Simulate the C++ gamma_correct_lut interpolation from light_state.cpp."""
if value <= 0.0:
return 0.0
if value >= 1.0:
return 1.0
scaled = value * 255.0
idx = int(scaled)
if idx >= 255:
return table[255] / 65535.0
frac = scaled - idx
a = float(table[idx])
b = float(table[idx + 1])
return (a + frac * (b - a)) / 65535.0
def test_table_length() -> None:
"""Table must always have exactly 256 entries."""
table = generate_gamma_table(2.8)
assert len(table) == 256
def test_index_zero_is_zero() -> None:
"""Index 0 must be 0 so true off remains off."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[0] == 0, f"gamma={gamma}"
def test_index_255_is_max() -> None:
"""Index 255 must be 65535 (full on)."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[255] == 65535, f"gamma={gamma}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_nonzero_indices_are_nonzero(gamma: float) -> None:
"""All indices > 0 must produce non-zero values.
This prevents zero_means_zero breakage: non-zero input must always
produce non-zero output so FloatOutput applies min_power scaling.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
f"gamma={gamma}: table[{i}]={table[i]} < table[{i - 1}]={table[i - 1]}"
)
def test_linear_gamma() -> None:
"""With gamma=0 (linear), table should be evenly spaced."""
table = generate_gamma_table(0)
assert table[0] == 0
assert table[128] == round(128 / 255.0 * 65535)
assert table[255] == 65535
@pytest.mark.parametrize("brightness", [0.01, 0.005, 0.001, 1 / 255])
def test_small_brightness_nonzero_after_lut(brightness: float) -> None:
"""Small but non-zero brightness must produce non-zero output through the LUT.
Regression test for #15055: with zero_means_zero=true, a gamma-corrected
value of exactly 0.0 causes FloatOutput to skip min_power scaling, turning
the LED off instead of to minimum brightness.
"""
table = generate_gamma_table(2.8)
result = _simulate_gamma_correct_lut(table, brightness)
assert result > 0.0, (
f"brightness={brightness}: gamma LUT returned 0.0, would break zero_means_zero"
)
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_small_brightness_nonzero_all_gammas(gamma: float) -> None:
"""1% brightness must be non-zero for all common gamma values."""
table = generate_gamma_table(gamma)
result = _simulate_gamma_correct_lut(table, 0.01)
assert result > 0.0, f"gamma={gamma}: 1% brightness returned 0.0"
def test_lut_zero_returns_zero() -> None:
"""LUT with input 0.0 must return 0.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 0.0) == 0.0
def test_lut_one_returns_one() -> None:
"""LUT with input 1.0 must return 1.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 1.0) == 1.0
def test_lut_output_monotonically_nondecreasing() -> None:
"""LUT output must be monotonically non-decreasing across the full range."""
table = generate_gamma_table(2.8)
prev = 0.0
for i in range(1001):
value = i / 1000.0
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result
@@ -59,6 +59,7 @@ def _add_board(
vendor: str = "",
name: str | None = None,
pins_header: str | None = None,
extra_flags: str = "",
) -> None:
"""Add a board JSON and variant to the fake arduino-pico tree."""
if variant is None:
@@ -69,11 +70,15 @@ def _add_board(
json_dir = arduino_pico / "tools" / "json"
variants_dir = arduino_pico / "variants"
build: dict = {
"mcu": mcu,
"variant": variant,
}
if extra_flags:
build["extra_flags"] = extra_flags
board_json = {
"build": {
"mcu": mcu,
"variant": variant,
},
"build": build,
"name": name,
"vendor": vendor,
}
@@ -271,3 +276,35 @@ def test_placeholder_pins_not_treated_as_virtual(arduino_pico: Path) -> None:
assert "MISO" not in board_pins["badpin"]
assert boards["badpin"]["max_virtual_pin"] == 64
def test_cyw43_supported_flag_sets_wifi(arduino_pico: Path) -> None:
"""Boards with PICO_CYW43_SUPPORTED=1 in extra_flags should have wifi=True."""
_add_board(
arduino_pico,
"rpipicow",
vendor="Raspberry Pi",
name="Pico W",
pins_header=PICOW_PINS_HEADER,
extra_flags="-DARDUINO_RASPBERRY_PI_PICO_W -DPICO_CYW43_SUPPORTED=1 -DCYW43_PIN_WL_DYNAMIC=1",
)
_, boards = load_boards(arduino_pico)
assert boards["rpipicow"]["wifi"] is True
def test_board_without_cyw43_has_no_wifi(arduino_pico: Path) -> None:
"""Boards without PICO_CYW43_SUPPORTED should not have wifi field."""
_add_board(
arduino_pico,
"rpipico",
vendor="Raspberry Pi",
name="Pico",
pins_header=PICO_PINS_HEADER,
extra_flags="-DARDUINO_RASPBERRY_PI_PICO",
)
_, boards = load_boards(arduino_pico)
assert "wifi" not in boards["rpipico"]
@@ -38,3 +38,20 @@ test_list:
- '{ 79, 82 }'
- a: 15 should be 15, overridden from command line
b: 20 should stay as 20, not overridden
- aa:
- 1
- 2
- 3
- 4
- 5
- 6
bb:
- 7
- 8
- 9
- aa:
x: 1
y: 3
z: 4
bb:
w: 5
@@ -44,3 +44,13 @@ test_list:
- '{ ${position.x}, ${position.y} }'
- a: ${a} should be 15, overridden from command line
b: ${b} should stay as 20, not overridden
# Test merging lists when substituted keys resolve to an existing key
- ${ "aa" }: [1, 2, 3]
${ "a" + "a" }: [4, 5, 6]
${ "bb" }: [7, 8, 9]
# Test merging dicts when substituted keys resolve to an existing key
- ${ "aa" }: {"x": 1, "y": 2}
${ "a" + "a" }: {"y": 3, "z": 4}
${ "bb" }: {"w": 5}
@@ -9,6 +9,11 @@ substitutions:
numberOne: 1
var1: 79
double_width: 14
double_height: 16
y: ${x}
x: ${y}
b: 79
c: 80
test_list:
- The area is 56
- 56
@@ -27,3 +32,4 @@ test_list:
- chr(97) = a
- len([1,2,3]) = 3
- width = 7, double_width = 14
- a = ${a}
@@ -1,4 +1,7 @@
substitutions:
y: ${x} # Circular reference, expect to pass unresolved.
x: ${y} # Circular reference, expect to pass unresolved.
double_height: ${height * 2}
width: 7
height: 8
enabled: true
@@ -9,6 +12,8 @@ substitutions:
numberOne: 1
var1: 79
double_width: ${width * 2}
c: ${b+1}
b: ${undefined_variable | default(79) }
test_list:
- "The area is ${width * height}"
@@ -25,3 +30,4 @@ test_list:
- chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width}
- a = ${a}
@@ -1,7 +1,3 @@
substitutions:
x: 10
y: 20
z: 30
values_from_repo1_main:
- package_name: package1
x: 3
@@ -28,3 +24,20 @@ values_from_repo1_main:
y: 20
z: 5
volume: 1000
- package_name: package6
x: 12
y: 13
z: 5
volume: 780
- package_name: default
x: 10
y: 20
z: 5
volume: 1000
substitutions:
x: 10
y: 20
z: 30
my_repo: repo1
my_file: file1
my_ref: main
@@ -2,16 +2,26 @@ substitutions:
x: 10
y: 20
z: 30
my_repo: default_repo
my_file: default_file
my_ref: main
# The following key is only used by the test framework
# to simulate command line substitutions
command_line_substitutions:
my_repo: repo1
my_file: file1
packages:
package1:
url: https://github.com/esphome/repo1
ref: main
files:
- path: file1.yaml
vars:
package_name: package1
x: 3
y: 4
ref: main
package2: !include # a package that just includes the given remote package
file: remote_package_proxy.yaml
vars:
@@ -41,3 +51,13 @@ packages:
repo: repo1
file: file1.yaml
ref: main
package6:
url: https://github.com/esphome/${my_repo}
ref: ${my_ref}
files:
- path: ${my_file + ".yaml"}
vars:
package_name: package6
x: 12
y: 13
package7: github://esphome/${my_repo}/${my_file + ".yaml"}@${my_ref}
@@ -0,0 +1,44 @@
fancy_component: &id001
- id: component9
value: 9
some_component:
- id: component1
value: 1
- id: component2
value: 2
- id: component3
value: 3
- id: component4
value: 4
- id: component5
value: 79
power: 200
- id: component6
value: 6
- id: component7
value: 7
switch: &id002
- platform: gpio
id: switch1
pin: 12
- platform: gpio
id: switch2
pin: 13
display:
- platform: ili9xxx
dimensions:
width: 100
height: 480
substitutions:
extended_component: component5
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
fancy_component: *id001
pin: 12
some_switches: *id002
package_selection: fancy_package
fancy_subst: 42
@@ -0,0 +1,63 @@
substitutions:
package_options:
alternative_package:
alternative_component:
- id: component8
value: 8
fancy_package:
substitutions:
fancy_subst: 42
fancy_component:
- id: component9
value: 9
pin: 12
some_switches:
- platform: gpio
id: switch1
pin: ${pin}
- platform: gpio
id: switch2
pin: ${pin+1}
package_selection: fancy_package
packages:
- ${ package_options[package_selection] }
- some_component:
- id: component1
value: 1
- some_component:
- id: component2
value: 2
- switch: ${ some_switches }
- packages:
package_with_defaults: !include
file: display.yaml
vars:
native_width: 100
high_dpi: false
my_package:
packages:
- packages:
special_package:
substitutions:
extended_component: component5
some_component:
- id: component3
value: 3
some_component:
- id: component4
value: 4
- id: !extend ${ extended_component }
power: 200
value: 79
some_component:
- id: component5
value: 5
some_component:
- id: component6
value: 6
- id: component7
value: 7
@@ -0,0 +1,49 @@
substitutions:
a: 10
b: 20
x: 79
test_list:
- level1:
a: 10
b: 20
c: 10
d: 20
e: ${e}
f: ${f}
g: ${g}
h: ${h}
i: ${i}
j: ${j}
x: 80
y: 40
level2:
- level2:
a: 10
b: 20
c: 10
d: 20
e: 20
f: 40
g: ${g}
h: ${h}
i: ${i}
j: ${j}
x: 81
y: 40
level3:
- level3:
a: 10
b: 20
c: 10
d: 20
e: 20
f: 40
g: 100
h: 200
i: 30
j: ${undefined_variable}
x: 82
y: 40
- a: 10
b: 20
x: 79
@@ -0,0 +1,16 @@
substitutions:
a: 10
b: 20
x: 79
test_list:
- !include
file: level1_package.yaml
vars:
x: ${x+1}
y: ${d*2}
c: ${a}
d: ${b}
- a: ${a}
b: ${b}
x: ${x}
@@ -0,0 +1,5 @@
values:
- var1: $var1
- a: 10
- b: B-default
- c: The value of C is 79
@@ -0,0 +1,7 @@
# Test that include_vars with vars works even when there are no substitutions key defined.
packages:
- !include
file: inc1.yaml
vars:
a: 10
c: 79
@@ -0,0 +1,69 @@
substitutions:
a: from base config
b: from package3
c: from nested package4
nested_package:
nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package1:
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2:
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3:
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package4:
packages:
- nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package_map:
package1:
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2:
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3: &id001
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
selected_package_number: 3
selected_package_name: package3
selected_package: *id001
base_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package1_test_list:
- a: from base config
- b: from package3
- c: from nested package4
package2_test_list:
- a: from package2 vars
- b: from package3
- c: from nested package4
package3_test_list:
- a: from base config
- b: from package3
- c: from nested package4
nested_package_test_list:
- a: from base config
- b: from package3
- c: from nested package4
@@ -0,0 +1,62 @@
command_line_substitutions:
selected_package_number: 3
substitutions:
a: from base config
package1: &p1
substitutions:
a: from package1
b: from package1
c: from package1
package1_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
package2: &p2 !include
file: package2.yaml
vars:
a: from package2 vars
package3: &p3
substitutions:
a: from package3
b: from package3
c: from package3
package3_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
package4:
substitutions:
nested_package:
substitutions:
c: from nested package4
nested_package_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
packages:
- ${ nested_package }
package_map:
package1: *p1
package2: *p2
package3: *p3
selected_package_number: 2 # will be overridden by command line substitutions
selected_package_name: package${ selected_package_number }
selected_package: ${ package_map[selected_package_name] }
packages:
- ${ package1 }
- ${ package2 }
- ${ selected_package }
- ${ package4 }
base_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
@@ -0,0 +1,21 @@
# this file is included by 07-include_hierarchy.input.yaml
level1:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # from vars when including
d: ${d} # from vars when including
e: ${e} # undefined at this level
f: ${f} # undefined at this level
g: ${g} # undefined at this level
h: ${h} # undefined at this level
i: ${i} # undefined at this level
j: ${j} # undefined at this level
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
level2:
- !include
file: level2_package.yaml
vars:
e: ${c*2}
f: ${d*2}
x: ${x+1}
@@ -0,0 +1,21 @@
# this file is included by level1_package.yaml
level2:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # visible from level1 vars
d: ${d} # visible from level1 vars
e: ${e} # from vars when including
f: ${f} # from vars when including
g: ${g} # undefined at this level
h: ${h} # undefined at this level
i: ${i} # undefined at this level
j: ${j} # undefined at this level
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
level3:
- !include
file: level3_package.yaml
vars:
g: ${e*5}
h: ${f*5}
x: ${x+1}
@@ -0,0 +1,16 @@
# this file is included by level2_package.yaml
defaults:
i: 30
level3:
a: ${a} # top-level substitution
b: ${b} # top-level substitution
c: ${c} # visible from level1 vars
d: ${d} # visible from level1 vars
e: ${e} # visible from level2 vars
f: ${f} # visible from level2 vars
g: ${g} # from vars when including
h: ${h} # from vars when including
i: ${i} # Should take the default value of 30
j: ${undefined_variable} # Does not exist, should be output as-is
x: ${x} # from vars when including, calculated
y: ${y} # from vars when including, calculated
@@ -0,0 +1,10 @@
# included from 10-dynamic_packages.input.yaml
substitutions:
a: from package2 # must not override base config's a
# b not defined here, won't override package1's b
c: from package2 # will override package1's c
package2_test_list:
- a: ${ a }
- b: ${ b }
- c: ${ c }
+27
View File
@@ -1,6 +1,7 @@
import pytest
from esphome import codegen as cg
from esphome.cpp_generator import _extract_component_ns
# Test interface remains the same.
@@ -75,3 +76,29 @@ from esphome import codegen as cg
)
def test_exists(attr):
assert hasattr(cg, attr)
@pytest.mark.parametrize(
("type_str", "expected"),
(
("esphome::dsmr::Dsmr", "dsmr"),
("esphome::logger::Logger", "logger"),
("esphome::web_server::WebServer", "web_server"),
("esphome::deep_sleep::DeepSleep", "deep_sleep"),
("esphome::Component", "esphome"),
("Logger", "esphome"),
# Template types with :: in template args must not confuse extraction
(
"esphome::Automation<std::optional<bool>, std::optional<bool>>",
"esphome",
),
(
"esphome::StatelessLambdaAction<std::optional<bool>, std::optional<bool>>",
"esphome",
),
# Namespaced template type
("esphome::sensor::Sensor<std::string>", "sensor"),
),
)
def test_extract_component_ns(type_str, expected):
assert _extract_component_ns(type_str) == expected
+41 -6
View File
@@ -1762,7 +1762,34 @@ def test_show_logs_api(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.1.101"]
CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True
)
@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_no_states(
mock_run_logs: Mock,
) -> None:
"""Test show_logs with --no-states flag."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MDNS: {CONF_DISABLED: False},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
args = MockArgs()
args.no_states = True
devices = ["192.168.1.100"]
result = show_logs(CORE.config, args, devices)
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=False
)
@@ -1788,7 +1815,9 @@ def test_show_logs_api_with_fqdn_mdns_disabled(
assert result == 0
# Should use the FQDN directly, not try MQTT lookup
mock_run_logs.assert_called_once_with(CORE.config, ["device.example.com"])
mock_run_logs.assert_called_once_with(
CORE.config, ["device.example.com"], subscribe_states=True
)
@patch("esphome.components.api.client.run_logs")
@@ -1816,7 +1845,9 @@ def test_show_logs_api_with_mqtt_fallback(
assert result == 0
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"])
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.200"], subscribe_states=True
)
@patch("esphome.mqtt.show_logs")
@@ -2746,7 +2777,7 @@ def test_show_logs_api_static_ip_with_mqttip(
# Verify run_logs was called with both IPs
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.2.50"]
CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True
)
@@ -2782,7 +2813,9 @@ def test_show_logs_api_multiple_mqttip_resolves_once(
# Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution
# The _resolve_network_devices helper filters out both after first resolution
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.2.50", "192.168.2.51", "192.168.1.100"]
CORE.config,
["192.168.2.50", "192.168.2.51", "192.168.1.100"],
subscribe_states=True,
)
@@ -2862,7 +2895,9 @@ def test_show_logs_api_mqtt_timeout_fallback(
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_detect_external_components_no_external(

Some files were not shown because too many files have changed in this diff Show More