mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 02:21:30 +00:00
Merge branch 'dev' into remove-set-retry
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1036,8 +1036,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;
|
||||
}
|
||||
@@ -1187,6 +1185,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user