Merge branch 'dev' into fan-store-preset-modes-on-entity

This commit is contained in:
J. Nick Koston
2026-04-06 18:46:32 -10:00
committed by GitHub
625 changed files with 13974 additions and 7229 deletions
@@ -1,3 +1,4 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
@@ -5,3 +6,16 @@ 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()
original_to_code = manifest.to_code
async def to_code(config):
await original_to_code(config)
# Enable BLE proto message types for benchmarks. The real
# bluetooth_proxy component is ESP32-only; a lightweight stub
# header in tests/benchmarks/stubs/ satisfies the include.
cg.add_define("USE_BLUETOOTH_PROXY")
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3)
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
manifest.to_code = to_code
@@ -0,0 +1,67 @@
#pragma once
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <memory>
#include <utility>
#include "esphome/components/socket/socket.h"
namespace esphome::api::benchmarks {
// Helper to drain accumulated data from the read side of a socket
// to prevent the write side from blocking.
inline void drain_socket(int fd) {
char buf[65536];
while (::read(fd, buf, sizeof(buf)) > 0) {
}
}
// Create a TCP loopback socket pair. Returns the write-side Socket
// (wrapped for ESPHome) and the raw read-side fd for draining.
// Both ends are non-blocking with 16MB buffers.
inline std::pair<std::unique_ptr<socket::Socket>, int> create_tcp_loopback() {
// 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);
// Use large socket buffers so benchmarks never hit WOULD_BLOCK
// during a single outer iteration (2000 × ~15B messages = ~30KB).
int bufsize = 16 * 1024 * 1024;
::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize));
::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
return {std::make_unique<socket::Socket>(write_fd), read_fd};
}
} // namespace esphome::api::benchmarks
@@ -0,0 +1,235 @@
#include <benchmark/benchmark.h>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/light/color_mode.h"
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// --- ListEntitiesSensorResponse ---
static ListEntitiesSensorResponse make_sensor_response() {
ListEntitiesSensorResponse msg;
msg.object_id = StringRef::from_lit("living_room_temperature");
msg.key = 0x12345678;
msg.name = StringRef::from_lit("Living Room Temperature");
#ifdef USE_ENTITY_ICON
msg.icon = StringRef::from_lit("mdi:thermometer");
#endif
msg.entity_category = enums::ENTITY_CATEGORY_NONE;
msg.disabled_by_default = false;
msg.unit_of_measurement = StringRef::from_lit("°C");
msg.accuracy_decimals = 1;
msg.force_update = false;
msg.device_class = StringRef::from_lit("temperature");
msg.state_class = enums::STATE_CLASS_MEASUREMENT;
#ifdef USE_DEVICES
msg.device_id = 1;
#endif
return msg;
}
static void CalculateSize_ListEntitiesSensorResponse(benchmark::State &state) {
auto msg = make_sensor_response();
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_ListEntitiesSensorResponse);
static void Encode_ListEntitiesSensorResponse(benchmark::State &state) {
auto msg = make_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_ListEntitiesSensorResponse);
static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) {
auto msg = make_sensor_response();
APIBuffer buffer;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_ListEntitiesSensorResponse);
// --- ListEntitiesBinarySensorResponse ---
static ListEntitiesBinarySensorResponse make_binary_sensor_response() {
ListEntitiesBinarySensorResponse msg;
msg.object_id = StringRef::from_lit("front_door_contact");
msg.key = 0xAABBCCDD;
msg.name = StringRef::from_lit("Front Door Contact");
#ifdef USE_ENTITY_ICON
msg.icon = StringRef::from_lit("mdi:door");
#endif
msg.entity_category = enums::ENTITY_CATEGORY_NONE;
msg.disabled_by_default = false;
msg.device_class = StringRef::from_lit("door");
msg.is_status_binary_sensor = false;
#ifdef USE_DEVICES
msg.device_id = 2;
#endif
return msg;
}
static void CalculateSize_ListEntitiesBinarySensorResponse(benchmark::State &state) {
auto msg = make_binary_sensor_response();
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_ListEntitiesBinarySensorResponse);
static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) {
auto msg = make_binary_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_ListEntitiesBinarySensorResponse);
static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &state) {
auto msg = make_binary_sensor_response();
APIBuffer buffer;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse);
// --- ListEntitiesLightResponse ---
static light::ColorModeMask light_color_modes;
static FixedVector<const char *> light_effects;
static ListEntitiesLightResponse make_light_response() {
// Initialize static data on first call
static bool initialized = false;
if (!initialized) {
light_color_modes.insert(light::ColorMode::RGB_WHITE);
light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE);
light_effects.init(3);
light_effects.push_back("None");
light_effects.push_back("Rainbow");
light_effects.push_back("Strobe");
initialized = true;
}
ListEntitiesLightResponse msg;
msg.object_id = StringRef::from_lit("kitchen_ceiling_light");
msg.key = 0x55667788;
msg.name = StringRef::from_lit("Kitchen Ceiling Light");
#ifdef USE_ENTITY_ICON
msg.icon = StringRef::from_lit("mdi:ceiling-light");
#endif
msg.entity_category = enums::ENTITY_CATEGORY_NONE;
msg.disabled_by_default = false;
msg.supported_color_modes = &light_color_modes;
msg.min_mireds = 153.0f;
msg.max_mireds = 500.0f;
msg.effects = &light_effects;
#ifdef USE_DEVICES
msg.device_id = 3;
#endif
return msg;
}
static void CalculateSize_ListEntitiesLightResponse(benchmark::State &state) {
auto msg = make_light_response();
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_ListEntitiesLightResponse);
static void Encode_ListEntitiesLightResponse(benchmark::State &state) {
auto msg = make_light_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_ListEntitiesLightResponse);
static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) {
auto msg = make_light_response();
APIBuffer buffer;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_ListEntitiesLightResponse);
} // namespace esphome::api::benchmarks
@@ -2,12 +2,9 @@
#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 "bench_helpers.h"
#include "esphome/components/api/api_frame_helper_plaintext.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
@@ -16,57 +13,12 @@ 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 [sock, read_fd] = create_tcp_loopback();
auto helper = std::make_unique<APIPlaintextFrameHelper>(std::move(sock));
helper->init();
return {std::move(helper), read_fd};
}
@@ -97,9 +49,6 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
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());
@@ -144,9 +93,6 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
}
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());
@@ -10,10 +10,9 @@ namespace esphome::api::benchmarks {
// sub-microsecond benchmarks.
static constexpr int kInnerIterations = 2000;
// Helper: encode a message into a buffer and return it.
// Benchmarks encode once in setup, then decode the resulting bytes in a loop.
// This keeps decode benchmarks in sync with the actual protobuf schema —
// hand-encoded byte arrays would silently break when fields change.
// Helper: encode a message into an APIBuffer for reuse in decode benchmarks.
// Optimization barriers are applied to the decode target objects via
// DoNotOptimize/ClobberMemory, not to this buffer.
template<typename T> static APIBuffer encode_message(const T &msg) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
@@ -23,6 +22,12 @@ template<typename T> static APIBuffer encode_message(const T &msg) {
return buffer;
}
/// Force a pointer through an asm barrier so the compiler cannot
/// prove its contents are unchanged across iterations.
/// benchmark::DoNotOptimize/ClobberMemory are insufficient under
/// CodSpeed's valgrind-based instrumentation.
static void escape(void *p) { asm volatile("" : : "g"(p) : "memory"); }
// --- HelloRequest decode (string + varint fields) ---
static void Decode_HelloRequest(benchmark::State &state) {
@@ -31,13 +36,18 @@ static void Decode_HelloRequest(benchmark::State &state) {
source.api_version_major = 1;
source.api_version_minor = 10;
auto encoded = encode_message(source);
auto *data = encoded.data();
auto size = encoded.size();
benchmark::DoNotOptimize(data);
benchmark::DoNotOptimize(size);
for (auto _ : state) {
HelloRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
HelloRequest msg;
escape(&msg);
msg.decode(data, size);
escape(&msg);
}
benchmark::DoNotOptimize(msg.api_version_major);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
@@ -50,13 +60,18 @@ static void Decode_SwitchCommandRequest(benchmark::State &state) {
source.key = 0x12345678;
source.state = true;
auto encoded = encode_message(source);
auto *data = encoded.data();
auto size = encoded.size();
benchmark::DoNotOptimize(data);
benchmark::DoNotOptimize(size);
for (auto _ : state) {
SwitchCommandRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
SwitchCommandRequest msg;
escape(&msg);
msg.decode(data, size);
escape(&msg);
}
benchmark::DoNotOptimize(msg.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
@@ -78,13 +93,18 @@ static void Decode_LightCommandRequest(benchmark::State &state) {
source.has_effect = true;
source.effect = StringRef::from_lit("rainbow");
auto encoded = encode_message(source);
auto *data = encoded.data();
auto size = encoded.size();
benchmark::DoNotOptimize(data);
benchmark::DoNotOptimize(size);
for (auto _ : state) {
LightCommandRequest msg;
for (int i = 0; i < kInnerIterations; i++) {
msg.decode(encoded.data(), encoded.size());
LightCommandRequest msg;
escape(&msg);
msg.decode(data, size);
escape(&msg);
}
benchmark::DoNotOptimize(msg.brightness);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
@@ -295,4 +295,93 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) {
}
BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh);
// --- BluetoothLERawAdvertisementsResponse (12 adverts, highest-volume BLE message) ---
#ifdef USE_BLUETOOTH_PROXY
static BluetoothLERawAdvertisementsResponse make_ble_raw_advs_12() {
static const uint8_t fake_adv_data[] = {
0x02, 0x01, 0x06, 0x03, 0x03, 0x9F, 0xFE, 0x17, 0x16, 0x9F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
BluetoothLERawAdvertisementsResponse msg;
msg.advertisements_len = 12;
for (int i = 0; i < 12; i++) {
auto &adv = msg.advertisements[i];
adv.address = 0xAABBCCDD0000ULL + i;
adv.rssi = -60 - i;
adv.address_type = 1;
memcpy(adv.data, fake_adv_data, sizeof(fake_adv_data));
adv.data_len = sizeof(fake_adv_data);
}
return msg;
}
static void CalculateSize_BLERawAdvs12(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_BLERawAdvs12);
static void Encode_BLERawAdvs12(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_BLERawAdvs12);
static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
APIBuffer buffer;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_BLERawAdvs12);
static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh);
#endif // USE_BLUETOOTH_PROXY
} // namespace esphome::api::benchmarks
@@ -0,0 +1,191 @@
#include "esphome/core/defines.h"
#if defined(USE_API_PLAINTEXT) && defined(USE_SENSOR)
#include <benchmark/benchmark.h>
#include <unistd.h>
#include "bench_helpers.h"
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_server.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::api {
// Friend functions declared in APIConnection for benchmark access.
void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; }
void bench_clear_batch(APIConnection *conn) { conn->clear_batch_(); }
void bench_process_batch(APIConnection *conn) { conn->process_batch_(); }
} // namespace esphome::api
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// Helper to create a TCP loopback connection with an APIConnection.
// Returns the connection and the read-side fd for draining.
static std::pair<std::unique_ptr<APIConnection>, int> create_api_connection() {
auto [sock, read_fd] = create_tcp_loopback();
auto conn = std::make_unique<APIConnection>(std::move(sock), global_api_server);
conn->start();
return {std::move(conn), read_fd};
}
// 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); }
};
// --- send_sensor_state: immediate send path ---
// Measures: send_message_smart_ → prepare buffer → dispatch_message_ →
// try_send_sensor_state → fill key/device_id + proto encode → frame write →
// TCP send. This is the per-client cost when batch_delay=0 and initial states
// have been sent.
static void SendSensorState_Immediate(benchmark::State &state) {
auto [conn, read_fd] = create_api_connection();
bench_enable_immediate_send(conn.get());
// batch_delay must be 0 for should_send_immediately_ to return true
uint16_t saved_delay = global_api_server->get_batch_delay();
global_api_server->set_batch_delay(0);
TestSensor sensor;
sensor.configure("test_sensor");
sensor.publish_state(23.5f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->send_sensor_state(&sensor);
}
drain_socket(read_fd);
benchmark::DoNotOptimize(conn.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
global_api_server->set_batch_delay(saved_delay);
::close(read_fd);
}
BENCHMARK(SendSensorState_Immediate);
// --- send_sensor_state: batch path (cold — first call allocates) ---
// Measures: send_message_smart_ → schedule_message_ → deferred batch add.
// Includes one-time vector allocation cost.
static void SendSensorState_Batch_Cold(benchmark::State &state) {
auto [conn, read_fd] = create_api_connection();
TestSensor sensor;
sensor.configure("test_sensor");
sensor.publish_state(23.5f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->send_sensor_state(&sensor);
}
benchmark::DoNotOptimize(conn.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(SendSensorState_Batch_Cold);
// --- send_sensor_state: batch path (warm — buffer already allocated) ---
// Measures steady-state batch cost after the vector has been allocated
// and cleared at least once. This is the typical path during normal
// operation after the first batch has been processed.
static void SendSensorState_Batch_Warm(benchmark::State &state) {
auto [conn, read_fd] = create_api_connection();
TestSensor sensor;
sensor.configure("test_sensor");
sensor.publish_state(23.5f);
// Warm up: send once to allocate, then clear to keep capacity
conn->send_sensor_state(&sensor);
bench_clear_batch(conn.get());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->send_sensor_state(&sensor);
}
benchmark::DoNotOptimize(conn.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(SendSensorState_Batch_Warm);
// --- process_batch_: single sensor state (encode + frame + write) ---
// Measures the deferred batch processing path: dispatch_message_ →
// try_send_sensor_state → fill + proto encode → send_buffer → frame write.
// This is the cost paid on the next loop() after batching.
static void ProcessBatch_SingleSensor(benchmark::State &state) {
auto [conn, read_fd] = create_api_connection();
TestSensor sensor;
sensor.configure("test_sensor");
sensor.publish_state(23.5f);
// Warm up batch vector
conn->send_sensor_state(&sensor);
bench_process_batch(conn.get());
drain_socket(read_fd);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->send_sensor_state(&sensor);
bench_process_batch(conn.get());
}
drain_socket(read_fd);
benchmark::DoNotOptimize(conn.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(ProcessBatch_SingleSensor);
// --- process_batch_: 5 different sensors ---
// Measures batch processing with multiple items queued.
// This exercises the multi-message path in process_batch_.
static void ProcessBatch_5Sensors(benchmark::State &state) {
auto [conn, read_fd] = create_api_connection();
TestSensor sensors[5];
for (int i = 0; i < 5; i++) {
char name[20];
snprintf(name, sizeof(name), "sensor_%d", i);
sensors[i].configure(name);
sensors[i].publish_state(23.5f + static_cast<float>(i));
}
// Warm up batch vector
for (auto &s : sensors)
conn->send_sensor_state(&s);
bench_process_batch(conn.get());
drain_socket(read_fd);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
for (auto &s : sensors)
conn->send_sensor_state(&s);
bench_process_batch(conn.get());
}
drain_socket(read_fd);
benchmark::DoNotOptimize(conn.get());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
::close(read_fd);
}
BENCHMARK(ProcessBatch_5Sensors);
} // namespace esphome::api::benchmarks
#endif // USE_API_PLAINTEXT && USE_SENSOR
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,55 @@
#include <benchmark/benchmark.h>
#include "esphome/components/button/button.h"
namespace esphome::button::benchmarks {
static constexpr int kInnerIterations = 2000;
// Minimal Button for benchmarking — press_action() is a no-op.
class BenchButton : public Button {
public:
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
protected:
void press_action() override {}
};
// --- Button::press() ---
// Measures: ESP_LOGD + press_action() + callback dispatch.
static void ButtonPress(benchmark::State &state) {
BenchButton button;
button.configure("test_button");
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
button.press();
}
benchmark::DoNotOptimize(&button);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ButtonPress);
// --- Button::press() with callback ---
// Measures callback dispatch overhead.
static void ButtonPress_WithCallback(benchmark::State &state) {
BenchButton button;
button.configure("test_button");
uint64_t callback_count = 0;
button.add_on_press_callback([&callback_count]() { callback_count++; });
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
button.press();
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ButtonPress_WithCallback);
} // namespace esphome::button::benchmarks
@@ -0,0 +1 @@
button:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,121 @@
#include <benchmark/benchmark.h>
#include "esphome/components/number/number.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
// Minimal Number for benchmarking — control() publishes the value back.
class BenchNumber : public number::Number {
public:
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
protected:
void control(float value) override { this->publish_state(value); }
};
// Helper to create a typical number entity for benchmarks.
static void setup_number(BenchNumber &number) {
number.configure("test_number");
number.traits.set_min_value(0.0f);
number.traits.set_max_value(100.0f);
number.traits.set_step(1.0f);
number.traits.set_mode(number::NUMBER_MODE_SLIDER);
}
// --- Number::publish_state() ---
// Measures the publish path: set_has_state, store value, callback dispatch.
static void NumberPublish_State(benchmark::State &state) {
BenchNumber number;
setup_number(number);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
number.publish_state(static_cast<float>(i % 100));
}
benchmark::DoNotOptimize(number.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(NumberPublish_State);
// --- Number::publish_state() with callback ---
// Measures callback dispatch overhead.
static void NumberPublish_WithCallback(benchmark::State &state) {
BenchNumber number;
setup_number(number);
uint64_t callback_count = 0;
number.add_on_state_callback([&callback_count](float) { callback_count++; });
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
number.publish_state(static_cast<float>(i % 100));
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(NumberPublish_WithCallback);
// --- NumberCall::perform() set value ---
// The most common number call — setting an absolute value.
// Exercises: validation against min/max, control() dispatch.
static void NumberCall_SetValue(benchmark::State &state) {
BenchNumber number;
setup_number(number);
number.publish_state(50.0f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
float val = static_cast<float>(i % 100);
number.make_call().set_value(val).perform();
}
benchmark::DoNotOptimize(number.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(NumberCall_SetValue);
// --- NumberCall::perform() increment ---
// Exercises: state read, step arithmetic, max clamping.
static void NumberCall_Increment(benchmark::State &state) {
BenchNumber number;
setup_number(number);
number.publish_state(0.0f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
number.make_call().number_increment(true).perform();
}
benchmark::DoNotOptimize(number.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(NumberCall_Increment);
// --- NumberCall::perform() decrement ---
// Exercises: state read, step arithmetic, min clamping.
static void NumberCall_Decrement(benchmark::State &state) {
BenchNumber number;
setup_number(number);
number.publish_state(100.0f);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
number.make_call().number_decrement(true).perform();
}
benchmark::DoNotOptimize(number.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(NumberCall_Decrement);
} // namespace esphome::benchmarks
@@ -0,0 +1 @@
number:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,157 @@
#include <benchmark/benchmark.h>
#include "esphome/components/select/select.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
// Minimal Select for benchmarking — control() publishes directly by index.
class BenchSelect : public select::Select {
public:
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
protected:
void control(size_t index) override { this->publish_state(index); }
};
// Helper to create a select with the given options.
static void setup_select(BenchSelect &select, const char *name, std::initializer_list<const char *> options) {
select.configure(name);
select.traits.set_options(options);
select.publish_state(size_t(0));
}
// --- Select::publish_state(size_t) ---
// The fast path: publish by index, no string lookup.
static void SelectPublish_ByIndex(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.publish_state(static_cast<size_t>(i % 4));
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectPublish_ByIndex);
// --- Select::publish_state(const char *) ---
// The string path: requires index_of() lookup via strncmp.
static void SelectPublish_ByString(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
const char *options[] = {"off", "still", "move", "still+move"};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.publish_state(options[i % 4]);
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectPublish_ByString);
// --- Select::publish_state() with callback ---
// Measures callback dispatch overhead on the index path.
static void SelectPublish_WithCallback(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
uint64_t callback_count = 0;
select.add_on_state_callback([&callback_count](size_t) { callback_count++; });
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.publish_state(static_cast<size_t>(i % 4));
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectPublish_WithCallback);
// --- SelectCall::perform() set by index ---
// The fast call path — no string matching needed.
static void SelectCall_SetByIndex(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.make_call().set_index(i % 4).perform();
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectCall_SetByIndex);
// --- SelectCall::perform() set by option string ---
// Exercises the string lookup path through index_of().
static void SelectCall_SetByOption(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
const char *options[] = {"off", "still", "move", "still+move"};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.make_call().set_option(options[i % 4]).perform();
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectCall_SetByOption);
// --- SelectCall::perform() next with cycling ---
// Exercises the navigation path through active_index_.
static void SelectCall_NextCycle(benchmark::State &state) {
BenchSelect select;
setup_select(select, "test_select", {"off", "still", "move", "still+move"});
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.make_call().select_next(true).perform();
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectCall_NextCycle);
// --- SelectCall with 10 options (string lookup) ---
// Worst-case string matching with more options.
static void SelectCall_SetByOption_10Options(benchmark::State &state) {
BenchSelect select;
setup_select(
select, "test_select",
{"off", "still", "move", "still+move", "custom1", "custom2", "custom3", "custom4", "custom5", "custom6"});
// Pick options spread across the list to exercise different search depths
const char *picks[] = {"off", "custom3", "custom6", "move"};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
select.make_call().set_option(picks[i % 4]).perform();
}
benchmark::DoNotOptimize(select.active_index());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SelectCall_SetByOption_10Options);
} // namespace esphome::benchmarks
@@ -0,0 +1 @@
select:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,137 @@
#include <benchmark/benchmark.h>
#include "esphome/components/switch/switch.h"
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
// Minimal Switch for benchmarking — write_state() publishes directly.
class BenchSwitch : public switch_::Switch {
public:
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
protected:
void write_state(bool state) override { this->publish_state(state); }
};
// --- Switch::publish_state() alternating ---
// Forces state change every call, exercising the full publish path.
static void SwitchPublish_Alternating(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
sw.publish_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.publish_state(i % 2 == 0);
}
benchmark::DoNotOptimize(sw.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchPublish_Alternating);
// --- Switch::publish_state() no change ---
// Tests the deduplication fast path in publish_dedup_.
static void SwitchPublish_NoChange(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
sw.publish_state(true);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.publish_state(true);
}
benchmark::DoNotOptimize(sw.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchPublish_NoChange);
// --- Switch::publish_state() with callback ---
// Measures callback dispatch overhead on state changes.
static void SwitchPublish_WithCallback(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
uint64_t callback_count = 0;
sw.add_on_state_callback([&callback_count](bool) { callback_count++; });
sw.publish_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.publish_state(i % 2 == 0);
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchPublish_WithCallback);
// --- Switch::turn_on() / turn_off() ---
// The front-end call path: turn_on → write_state → publish_state.
static void SwitchTurnOn(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
sw.publish_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.turn_on();
}
benchmark::DoNotOptimize(sw.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchTurnOn);
// --- Switch::toggle() alternating ---
// Exercises the toggle path which reads current state to determine target.
static void SwitchToggle(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
sw.publish_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.toggle();
}
benchmark::DoNotOptimize(sw.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchToggle);
// --- Switch::publish_state() inverted ---
// Verifies the inversion path doesn't add significant overhead.
static void SwitchPublish_Inverted(benchmark::State &state) {
BenchSwitch sw;
sw.configure("test_switch");
sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF);
sw.set_inverted(true);
sw.publish_state(false);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sw.publish_state(i % 2 == 0);
}
benchmark::DoNotOptimize(sw.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(SwitchPublish_Inverted);
} // namespace esphome::benchmarks
@@ -0,0 +1 @@
switch:
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
@@ -0,0 +1,108 @@
#include <benchmark/benchmark.h>
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text_sensor::benchmarks {
static constexpr int kInnerIterations = 2000;
// --- publish_state(const char *) with short string, value changes each time ---
// Exercises: memcmp check (mismatch), string assign, callback dispatch.
static void TextSensorPublish_Short_Changing(benchmark::State &state) {
TextSensor sensor;
// Pre-populate with different short strings
const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(values[i % 4]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(TextSensorPublish_Short_Changing);
// --- publish_state(const char *) with short string, same value (dedup path) ---
// Exercises: memcmp check (match), skips string assign.
static void TextSensorPublish_Short_NoChange(benchmark::State &state) {
TextSensor sensor;
sensor.publish_state("192.168.1.100");
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state("192.168.1.100");
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(TextSensorPublish_Short_NoChange);
// --- publish_state with longer string (firmware version, MAC address) ---
// Exercises: memcmp on longer strings, string assign with potential realloc.
static void TextSensorPublish_Long_Changing(benchmark::State &state) {
TextSensor sensor;
const char *values[] = {
"2025.12.0-dev (Jan 15 2025, 10:30:00)",
"2025.12.1-dev (Feb 20 2025, 14:45:00)",
"2025.12.2-dev (Mar 10 2025, 08:15:00)",
"2025.12.3-dev (Apr 5 2025, 16:00:00)",
};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(values[i % 4]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(TextSensorPublish_Long_Changing);
// --- publish_state with callback ---
// Measures callback dispatch overhead for text sensors.
static void TextSensorPublish_WithCallback(benchmark::State &state) {
TextSensor sensor;
uint64_t callback_count = 0;
sensor.add_on_state_callback([&callback_count](const std::string &) { callback_count++; });
const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(values[i % 4]);
}
benchmark::DoNotOptimize(callback_count);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(TextSensorPublish_WithCallback);
// --- publish_state(const char *, size_t) direct ---
// The lowest-level overload, avoids strlen.
static void TextSensorPublish_WithLen(benchmark::State &state) {
TextSensor sensor;
static constexpr const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"};
static constexpr size_t lens[] = {11, 11, 11, 11};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
sensor.publish_state(values[i % 4], lens[i % 4]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(TextSensorPublish_WithLen);
} // namespace esphome::text_sensor::benchmarks
@@ -0,0 +1 @@
text_sensor:
+107 -17
View File
@@ -8,7 +8,24 @@ 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;
// Must be divisible by all batch sizes used below (3, 10) to avoid
// pool imbalance at iteration boundaries that causes spurious malloc.
static constexpr int kInnerIterations = 2100;
// Warm the scheduler pool by registering and replacing items twice.
// The first batch allocates fresh items; the second batch cancels them and
// populates the recycling pool with the cancelled items from the first batch.
static void warm_pool(Scheduler &scheduler, Component *component, int batch_size, uint32_t delay) {
uint32_t now = millis();
for (int i = 0; i < batch_size; i++) {
scheduler.set_timeout(component, static_cast<uint32_t>(i), delay, []() {});
}
scheduler.call(++now);
for (int i = 0; i < batch_size; i++) {
scheduler.set_timeout(component, static_cast<uint32_t>(i), delay, []() {});
}
scheduler.call(++now);
}
// --- Scheduler fast path: no work to do ---
@@ -83,11 +100,21 @@ static void Scheduler_SetTimeout(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Register 3 timeouts then call() — realistic worst case where multiple
// components schedule in the same loop iteration. Keeps item count within
// the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free.
static constexpr int kBatchSize = 3;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 1000);
for (auto _ : state) {
uint32_t now = millis();
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % 5), 1000, []() {});
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % kBatchSize), 1000, []() {});
if ((i + 1) % kBatchSize == 0) {
scheduler.call(++now);
}
}
scheduler.process_to_add();
scheduler.call(++now);
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
@@ -99,22 +126,22 @@ BENCHMARK(Scheduler_SetTimeout);
static void Scheduler_SetInterval(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Number of distinct interval keys; controls how many unique timers exist
// simultaneously and the drain cadence for process_to_add().
static constexpr int kKeyCount = 5;
// Register 3 intervals then call() — realistic worst case where multiple
// components schedule in the same loop iteration. Keeps item count within
// the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free.
static constexpr int kBatchSize = 3;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 1000);
for (auto _ : state) {
uint32_t now = millis();
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_interval(&dummy_component, static_cast<uint32_t>(i % kKeyCount), 1000, []() {});
// Drain to_add_ periodically to reflect production behavior where
// process_to_add() runs each main loop iteration. Without this,
// cancelled items accumulate in to_add_ causing O(n²) scan cost.
if ((i + 1) % kKeyCount == 0) {
scheduler.process_to_add();
scheduler.set_interval(&dummy_component, static_cast<uint32_t>(i % kBatchSize), 1000, []() {});
if ((i + 1) % kBatchSize == 0) {
scheduler.call(++now);
}
}
// Final drain in case kInnerIterations is not a multiple of 5
scheduler.process_to_add();
scheduler.call(++now);
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
@@ -128,16 +155,79 @@ static void Scheduler_Defer(benchmark::State &state) {
Component dummy_component;
// defer() is Component::defer which calls set_timeout(delay=0).
// Call set_timeout directly since defer() is protected.
// Component::defer(func) passes nullptr as the name, which skips
// cancel_item_locked_ entirely — matching production behavior where
// defers are anonymous fire-and-forget callbacks.
static constexpr int kBatchSize = 3;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 0);
for (auto _ : state) {
uint32_t now = millis();
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % 5), 0, []() {});
scheduler.set_timeout(&dummy_component, static_cast<const char *>(nullptr), 0, []() {});
if ((i + 1) % kBatchSize == 0) {
scheduler.call(++now);
}
}
scheduler.process_to_add();
scheduler.call(++now);
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_Defer);
// --- Scheduler: defer with same ID (cancel-and-replace pattern) ---
static void Scheduler_Defer_SameID(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Measures defer with a fixed numeric ID — each call cancels the previous
// pending defer before adding the new one. This is the pattern used by
// components that defer work but want to coalesce rapid updates.
static constexpr int kBatchSize = 3;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 0);
for (auto _ : state) {
uint32_t now = millis();
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(0), 0, []() {});
if ((i + 1) % kBatchSize == 0) {
scheduler.call(++now);
}
}
scheduler.call(++now);
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_Defer_SameID);
// --- Scheduler: set_timeout with batch size exceeding pool (cliff test) ---
static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure
// the performance cliff when the recycling pool is exhausted and items
// must be malloc'd/freed.
static constexpr int kBatchSize = 10;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 1000);
for (auto _ : state) {
uint32_t now = millis();
for (int i = 0; i < kInnerIterations; i++) {
scheduler.set_timeout(&dummy_component, static_cast<uint32_t>(i % kBatchSize), 1000, []() {});
if ((i + 1) % kBatchSize == 0) {
scheduler.call(++now);
}
}
scheduler.call(++now);
benchmark::DoNotOptimize(scheduler);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Scheduler_SetTimeout_ExceedPool);
} // namespace esphome::benchmarks
@@ -0,0 +1,38 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp needs when USE_BLUETOOTH_PROXY is defined,
// without pulling in ESP32 BLE dependencies.
#pragma once
#include "esphome/components/api/api_pb2.h"
namespace esphome {
namespace api {
class APIConnection;
} // namespace api
namespace bluetooth_proxy {
class BluetoothProxy {
public:
api::APIConnection *get_api_connection() const { return nullptr; }
void subscribe_api_connection(api::APIConnection *conn, uint32_t flags) {}
void unsubscribe_api_connection(api::APIConnection *conn) {}
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {}
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {}
void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {}
void bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {}
void bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {}
void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {}
void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {}
void send_connections_free(api::APIConnection *conn) {}
void bluetooth_scanner_set_mode(bool active) {}
void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {}
uint32_t get_feature_flags() const { return 0; }
void get_bluetooth_mac_address_pretty(char *buf) const { buf[0] = '\0'; }
};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern BluetoothProxy *global_bluetooth_proxy;
} // namespace bluetooth_proxy
} // namespace esphome
@@ -0,0 +1,81 @@
"""Tests for display component metadata functions."""
from unittest.mock import patch
from esphome.components.display import (
DisplayMetaData,
add_metadata,
get_all_display_metadata,
get_display_metadata,
)
from esphome.cpp_generator import MockObj
def test_add_metadata_with_string_id():
"""Test adding metadata with a plain string ID."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("my_display", 320, 240, True)
meta = get_display_metadata("my_display")
assert meta == DisplayMetaData(
width=320, height=240, has_writer=True, has_hardware_rotation=False
)
def test_add_metadata_with_mockobj_id():
"""Test adding metadata with a MockObj ID (converted via str())."""
with patch("esphome.components.display.CORE.data", {}):
mock_id = MockObj("my_display_obj")
add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True)
meta = get_display_metadata("my_display_obj")
assert meta == DisplayMetaData(
width=480, height=320, has_writer=False, has_hardware_rotation=True
)
def test_add_metadata_hardware_rotation_default():
"""Test that has_hardware_rotation defaults to False."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 128, 64, False)
meta = get_display_metadata("disp")
assert meta.has_hardware_rotation is False
def test_get_display_metadata_missing_returns_none():
"""Test that querying a non-existent ID returns None."""
with patch("esphome.components.display.CORE.data", {}):
data = get_display_metadata("no_such_display")
assert data.width == 0
assert data.height == 0
assert data.has_writer is False
assert data.has_hardware_rotation is False
def test_add_multiple_displays():
"""Test adding metadata for multiple displays."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp_a", 320, 240, True)
add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True)
all_meta = get_all_display_metadata()
assert len(all_meta) == 2
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True)
def test_add_metadata_overwrites_existing():
"""Test that adding metadata for the same ID overwrites the previous entry."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 320, 240, True)
add_metadata("disp", 640, 480, False, has_hardware_rotation=True)
meta = get_display_metadata("disp")
assert meta == DisplayMetaData(640, 480, False, True)
def test_metadata_is_frozen():
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
meta = DisplayMetaData(320, 240, True, False)
try:
meta.width = 640
assert False, "Expected FrozenInstanceError"
except AttributeError:
pass
@@ -133,6 +133,6 @@ def test_code_generation(
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp
assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
@@ -1,6 +1,7 @@
"""Tests for mpip_spi configuration validation."""
from collections.abc import Callable, Generator
from unittest import mock
import pytest
@@ -12,6 +13,16 @@ from esphome.core import CORE
from esphome.pins import gpio_pin_schema
@pytest.fixture(autouse=True)
def mock_spi_final_validate():
"""Mock spi.final_validate_device_schema since unit tests have no real SPI bus config."""
with mock.patch(
"esphome.components.spi.final_validate_device_schema",
return_value=lambda config: None,
):
yield
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""
@@ -0,0 +1,202 @@
"""Tests for display metadata created by mipi_spi component."""
from collections.abc import Callable
from pathlib import Path
from esphome.components.display import (
DisplayMetaData,
get_all_display_metadata,
get_display_metadata,
)
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
get_instance,
)
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config):
"""Run schema + final validation and return the validated config."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
def test_metadata_native_quad_default_test_card(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A quad-mode display with no explicit drawing gets a test card from final validation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
config = validated_config({"model": "JC3636W518"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.width == 360
assert meta.height == 360
# final validation auto-enables show_test_card when no drawing methods are configured
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
def test_metadata_single_mode_with_dc_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A single-mode display with no explicit drawing gets a test card from final validation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "ST7735",
"dc_pin": 18,
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.width == 128
assert meta.height == 160
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
def test_metadata_custom_dimensions(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A custom model picks up explicit dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 480, "height": 320},
"init_sequence": [[0xA0, 0x01]],
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.width == 480
assert meta.height == 320
# final validation auto-enables show_test_card
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
def test_metadata_with_test_card_has_writer(
set_core_config: SetCoreConfigCallable,
) -> None:
"""When show_test_card is enabled, has_writer should be True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 240, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"show_test_card": True,
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.has_writer is True
def test_metadata_no_swap_xy_not_full_hardware_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model that disables swap_xy should report has_hardware_rotation=False."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only
config = validated_config({"model": "JC3248W535"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.has_hardware_rotation is False
def test_metadata_multiple_displays_independent(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Multiple displays each get their own metadata entry."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config_a = validated_config(
{
"id": "disp_a",
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
}
)
config_b = validated_config(
{
"id": "disp_b",
"model": "custom",
"dc_pin": 19,
"dimensions": {"width": 128, "height": 64},
"init_sequence": [[0xA0, 0x01]],
}
)
get_instance(config_a)
get_instance(config_b)
all_meta = get_all_display_metadata()
# final validation auto-enables show_test_card for both
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True)
def test_metadata_via_code_generation_native(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""Full code generation for native.yaml should produce correct metadata."""
generate_main(component_fixture_path("native.yaml"))
all_meta = get_all_display_metadata()
# native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=360, height=360, has_writer=True, has_hardware_rotation=True
)
def test_metadata_via_code_generation_lvgl(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""Full code generation for lvgl.yaml should produce correct metadata."""
generate_main(component_fixture_path("lvgl.yaml"))
all_meta = get_all_display_metadata()
# lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=128, height=160, has_writer=False, has_hardware_rotation=True
)
+2 -7
View File
@@ -204,11 +204,6 @@ def test_transform_and_init_sequence_errors(
r"extra keys not allowed @ data\['brightness'\]",
id="brightness_not_supported",
),
pytest.param(
{"model": "T-DISPLAY-S3-PRO"},
"PSRAM is required for this display",
id="psram_required",
),
],
)
def test_esp32s3_specific_errors(
@@ -319,7 +314,7 @@ def test_native_generation(
main_cpp = generate_main(component_fixture_path("native.yaml"))
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, display::DISPLAY_ROTATION_0_DEGREES, 1, 1>()"
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, true, 1, 1>()"
in main_cpp
)
assert "set_init_sequence({240, 1, 8, 242" in main_cpp
@@ -335,7 +330,7 @@ def test_lvgl_generation(
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
assert (
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0>();"
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
+11
View File
@@ -4,3 +4,14 @@ sensor:
tvoc:
name: AGS10 TVOC
update_interval: 60s
button:
- platform: template
name: "Test AGS10 Actions"
on_press:
- ags10.set_zero_point:
id: ags10_1
mode: CURRENT_VALUE
- ags10.new_i2c_address:
id: ags10_1
address: 0x1A
+5
View File
@@ -12,6 +12,11 @@ esphome:
trigger_keep: 10s
stage_gain: 3
power_consumption: 70uA
- at581x.settings:
id: waveradar
frequency: !lambda "return 5800;"
poweron_selfcheck_time: !lambda "return 2000;"
power_consumption: !lambda "return 70;"
- at581x.reset:
id: waveradar
+1
View File
@@ -13,3 +13,4 @@ dsmr:
request_pin: ${request_pin}
request_interval: 20s
receive_timeout: 100ms
thermal_mbus_id: 3
@@ -0,0 +1,41 @@
*** DO NOT USE THIS KEY...EVER ***
-----BEGIN RSA PRIVATE KEY-----
MIIG5AIBAAKCAYEA0J665DlxzUzzouzH96fxqXybEfFU7H1oSf2fUHwoNMgUG7Vc
SHxuFJkpsUnxg9br09/v5THOXfUj5t/Arog6FGiL7i0HXCYDMnSn2EzQWR+DY2Qj
C3YzTLvcOQ40gFjDWfzAheAMCQmc5xQeB3YmaXQf+fUWH/PfFs9Pm+L92YTv2XC1
B2q5s8K8hUWghO472A+UMrreuDcltNJ+TbuSRHK0NQzKpKo0Vkl4HycczGDgpa8D
h68JL/BKVeJAjKxWd/xcj/FCk661ODXi0esB/mGQP3hAthWpwi+gdkWczWs1Ocr6
VxKje1zFm9SEq+SmCViPY/Pu8Xs7steqz3b3JtRGtKQE0r3B+hBKI7aRudOZyz0s
kqoL1zYrAWmoTWBqa2tj1ACPqtr2LyHGt2aVrHRQGJf21mPYIy9GIOv+3v3GzIAK
az2B8Z93Bw1biwNZDr1SLNYfQVaJT1hQavmdlvwW8vqLUGDcQlk42yOF6nAmvAPu
Wzxf+QFEtJT6Am65AgMBAAECggGAB0d+mG+LscDtYGI4MQNGaqZLJ+NelfjjPm+v
0yhd48eWcggQPgQ/eA8HFiVRHMtPQ7+U2I+2Fm+zDr+AcuaUdjlWppsiHlxCMMzC
vYiinXV8yWdJVMFNVXBZpRECknbmbBmmYxV3/gm8lJCOYq7D9NqFMhzT5o4FGv/l
VHhlaKVblB/7ZRSbgbL6DoFpMjI42tdiUanVEyLzeR1+JDq3BhXlhVNar8ezl04t
d5LPDa+UrxtN+XpJTQeqpFgGbhImSxjzCjo0kbGiEx/DwWuFJxguIcDU25sM4g2+
ivtn7N11U0oaqNwsz7p4cKAm8toJYxxXWZvKdj1kZvCZ+BtyH0/MtOa2Q6v91HOh
zY4KEl5wxQYnxJrgqevSm8rrC51tLOCidZ16cHba8sjrK69xysEazk43roHLFXDp
JpH7Zd8LETjFWGVfUz6vppzkt6mrJk0DNuMLk/UwpPHzW2pu1qDiHPCb9+ra1S9U
t55hT2TBFDcG/NmZZnyHQoh8METhAoHBAPIG0G8Cd4fmkvbgCRLzCIWsH6zGHS9o
80Rj9Gu93B+m/F9GtgyYuX+DKSdMdw3IJamUsBwofT2wynmkuJFhLtD1FmYtlsXf
TWp8g8CfFGrIXDvin5E3heyhvtFiOjXlw0Q8yMmQXr5LF0i3WyFCPQM20ugClB7N
CQBOVAfpVoRU1fA6UjjHibFRwi1b4bLV69QiERPCJfcny/DPkZpu7I1fiINmwzEb
O5mIFo5F4TQADEreWplXEmhEXIzDMFIwEQKBwQDcqimCcO3RSysZMQhhUfk8G19I
yRNwvi2fK5LiGCZMYjeYKqg1rBN4yCf9PTwaqBNRqXTg13Fc7zrOkSI+0oDa4FWI
/kMEztaUK+Kwd2NKc96aXHMBGF+1Sx7Ygnr9e2dyqDqRij2/qlQYY1EDz7cYldaX
YNrXcQQeNJbqydjRDYi+9bI+wDkrK/5PxE1sGmqS1RMKxoJCZmxNiQT3PmXM/oNR
Ev6N9CDklFtClWNcD0Uum+mxNJ53ldZDx4UI/CkCgcEA6R6BI3vX0FHaGureMp9f
BQoulEdbEzBeqPAyHJkKbn50Nf0xGt78RYL7X7v6LI8tH7N1Eho5z/L6g8KSeI2H
/4MiqRaeVEdrFPeMHDvd+aC1noUBt2komS2OU7XuZb3CoHZ/3A4wA9DmQ4dAwr8/
b1oeOZVKQISzd9T6gYhSajIgwzwZuFESInaitvf6ZDxC49hQZJyr3u05NeFo2Lyh
Iuby4cZYmnMlrBN1zmImseSd8ntL/sjslPvLvVXAtFlRAoHBAIDuG5rPiOTE2sW5
VIAoeUuZYq8QbX9uXxGlUAkyuw3eRUVvhyD1DduAd30Ljla05bTNIjFNMDtwvBd9
zViPfiJk+RU2GspwYAfrLGSXHTifQu1GHxwAtcsjvT4b3ujEdckUakQnVbTrPH+T
Z/6mGwEOa3e/a559tj4/0/4TOc/L7J5GyILJpZ2H8uuAcww60xI/1QRywCEz3wve
hzw/BRQlkWyJgJpIjf+Af2IEDy327iExj/WuHPkaXzrzFNQPIQKBwAM6qeNOxrO3
V91wg4+44FAsOda62fZ0GlCM7ETnEjLbFamtCKEcDNfijwTa54LcZ6yObyutD1RN
dhj4Z6QKuYnsE02agv9CtXdFEVEXaqj4pshdgVOwGK34OidT4yIJQGLrRAQ/JiGH
x6CoGUCNIAq5J08VdosLTD9qdn1zv8USCAP0ReKnRMndTzENLYz9G3nQyHgt5GzI
YoSRtrWnXrQp2Yn3epk74gFAJtKozWNV4Du35FJBjmSeMuRivonNMQ==
-----END RSA PRIVATE KEY-----
*** DO NOT USE THIS KEY...EVER ***
@@ -0,0 +1,10 @@
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_key: ../../components/esp32/dummy_signing_key.pem
signing_scheme: rsa3072
<<: !include common.yaml
@@ -69,3 +69,11 @@ esp32_ble_server:
- ble_server.descriptor.set_value:
id: test_change_descriptor
value: !lambda return bytebuffer::ByteBuffer::wrap({0x03, 0x04, 0x05}).get_data();
- ble_server.characteristic.set_value:
id: test_change_characteristic
value:
data: [0xfc, 0xef, 0xfe, 0x86]
- ble_server.descriptor.set_value:
id: test_change_descriptor
value:
data: [0x01, 0x02, 0x03]
@@ -14,3 +14,6 @@ esphome:
assert(x == 95);
x = clamp_at_most(x, 40);
assert(x == 40);
- lambda: |-
float value = 0.0f;
sscanf("3.14", "%f", &value);
+2
View File
@@ -29,6 +29,8 @@ espnow:
data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};'
- espnow.broadcast:
data: "Hello, World!"
- espnow.broadcast:
data: "it's a test"
- espnow.broadcast:
data: [0x01, 0x02, 0x03, 0x04, 0x05]
- espnow.broadcast:
@@ -1 +1,20 @@
<<: !include common-w5500.yaml
ethernet:
type: W5500
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"
interface: spi2
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
+7
View File
@@ -0,0 +1,7 @@
sensor:
- platform: hdc2080
temperature:
name: Temperature
humidity:
name: Humidity
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
+12
View File
@@ -1,5 +1,6 @@
sensor:
- platform: htu21d
id: htu21d_sensor
i2c_id: i2c_bus
model: htu21d
temperature:
@@ -9,3 +10,14 @@ sensor:
heater:
name: Heater
update_interval: 15s
button:
- platform: template
name: "Test HTU21D Actions"
on_press:
- htu21d.set_heater:
id: htu21d_sensor
status: true
- htu21d.set_heater_level:
id: htu21d_sensor
level: 5
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
+160 -6
View File
@@ -30,18 +30,63 @@ binary_sensor:
return y;
lvgl:
id: lvgl_id
rotation: 90
log_level: debug
resume_on_input: true
on_pause:
logger.log: LVGL is Paused
- logger.log: LVGL is Paused
- lvgl.display.set_rotation: 90
on_resume:
logger.log: LVGL has resumed
- logger.log: LVGL has resumed
- lvgl.display.set_rotation:
rotation: 0
lvgl_id: lvgl_id
on_boot:
- logger.log: LVGL has started
- lvgl.indicator.update:
id: meter_arc_indicator
start_value: 0
end_value: 180
on_invalidate_area:
logger.log: Invalidate area
on_resolution_change:
logger.log: Resolution changed
on_color_format_change:
logger.log: Color format changed
on_refr_request:
logger.log: Refresh request
on_refr_start:
logger.log: Refresh start
on_refr_ready:
logger.log: Refresh ready
on_render_start:
logger.log: Render start
on_render_ready:
logger.log: Render ready
on_flush_start:
logger.log: Flush start
on_flush_finish:
logger.log: Flush finish
on_flush_wait_start:
logger.log: Flush wait start
on_flush_wait_finish:
logger.log: Flush wait finish
on_update_layout_complete:
logger.log: Update layout complete
on_vsync:
logger.log: Vsync
on_vsync_request:
logger.log: Vsync request
on_screen_load_start:
logger.log: Screen load start
on_screen_load:
logger.log: Screen loaded
on_screen_unload:
logger.log: Screen unloaded
on_screen_unload_start:
logger.log: Screen unload start
bg_color: light_blue
bottom_layer:
widgets:
@@ -49,6 +94,7 @@ lvgl:
bg_color: 0x000000
bg_opa: cover
theme:
dark_mode: true
obj:
border_width: 1
@@ -232,7 +278,7 @@ lvgl:
- roller:
id: lv_roller
visible_row_count: 2
anim_time: 500ms
anim_duration: 500ms
options:
- Nov
- Dec
@@ -288,7 +334,9 @@ lvgl:
- label:
text: "Hello shiny day"
text_color: 0xFFFFFF
align: bottom_mid
align_to:
id: hello_label
align: OUT_LEFT_TOP
- label:
id: setup_lambda_label
# Test lambda in widget property during setup (LvContext)
@@ -315,20 +363,27 @@ lvgl:
align: top_left
- container:
align: center
anim_duration: 1s
arc_opa: COVER
arc_color: 0xFF0000
arc_rounded: false
arc_width: 3
anim_time: 1s
base_dir: auto
bg_color: light_blue
bg_grad_color: light_blue
bg_grad_dir: hor
bg_grad_opa: cover
bg_grad_stop: 128
bg_image_opa: transp
bg_image_recolor: light_blue
bg_image_recolor_opa: 50%
bg_main_opa: cover
bg_main_stop: 0
bg_opa: 20%
blend_mode: normal
blur_backdrop: false
blur_quality: auto
blur_radius: 0
border_color: 0x00FF00
border_opa: cover
border_post: true
@@ -336,7 +391,15 @@ lvgl:
border_width: 4
clip_corner: false
color_filter_opa: transp
drop_shadow_color: 0x000000
drop_shadow_offset_x: 5
drop_shadow_offset_y: 5
drop_shadow_opa: cover
drop_shadow_quality: precision
drop_shadow_radius: 10
ext_click_area: 100px
height: 50%
image_opa: cover
image_recolor: light_blue
image_recolor_opa: cover
line_width: 10
@@ -344,6 +407,10 @@ lvgl:
line_dash_gap: 10
line_rounded: false
line_color: light_blue
margin_bottom: 4
margin_left: 4
margin_right: 4
margin_top: 4
opa: cover
opa_layered: cover
outline_color: light_blue
@@ -353,8 +420,12 @@ lvgl:
pad_all: 10px
pad_bottom: 10px
pad_left: 10px
pad_radial: 0
pad_right: 10px
pad_top: 10px
recolor: 0xFF0000
recolor_opa: transp
rotary_sensitivity: 256
shadow_color: light_blue
shadow_opa: cover
shadow_spread: 5
@@ -366,6 +437,9 @@ lvgl:
text_letter_space: 4
text_line_space: 4
text_opa: cover
text_outline_stroke_color: 0x000000
text_outline_stroke_opa: cover
text_outline_stroke_width: 2
transform_rotation: 90
transform_height: 100
transform_pivot_x: 50%
@@ -375,8 +449,10 @@ lvgl:
transform_scale_y: 0.8
transform_skew_x: 10
transform_skew_y: 20
transform_width: 100
shadow_offset_x: 3
shadow_offset_y: 3
translate_radial: 0
translate_x: 10
translate_y: 10
max_height: 100
@@ -481,6 +557,7 @@ lvgl:
image:
src: cat_image
align: top_left
bitmap_mask_src: alert
on_click:
- lvgl.widget.focus: spin_up
- lvgl.widget.focus: next
@@ -559,6 +636,76 @@ lvgl:
logger.log: Button clicked
on_long_press_repeat:
logger.log: Button clicked
on_pressing:
logger.log: Button pressing
on_press_lost:
logger.log: Button press lost
on_single_click:
logger.log: Button single clicked
on_double_click:
logger.log: Button double clicked
on_triple_click:
logger.log: Button triple clicked
on_scroll_throw_begin:
logger.log: Scroll throw begin
on_gesture:
logger.log: Gesture detected
on_key:
logger.log: Key event
on_rotary:
logger.log: Rotary event
on_leave:
logger.log: Leave event
on_hit_test:
logger.log: Hit test
on_indev_reset:
logger.log: Indev reset
on_hover_over:
logger.log: Hover over
on_hover_leave:
logger.log: Hover leave
on_cover_check:
logger.log: Cover check
on_refr_ext_draw_size:
logger.log: Refr ext draw size
on_draw_main_begin:
logger.log: Draw main begin
on_draw_main:
logger.log: Draw main
on_draw_main_end:
logger.log: Draw main end
on_draw_post_begin:
logger.log: Draw post begin
on_draw_post:
logger.log: Draw post
on_draw_post_end:
logger.log: Draw post end
on_draw_task_add:
logger.log: Draw task add
on_insert:
logger.log: Insert event
on_refresh:
logger.log: Refresh event
on_state_change:
logger.log: State changed
on_create:
logger.log: Create event
on_delete:
logger.log: Delete event
on_child_change:
logger.log: Child changed
on_child_create:
logger.log: Child created
on_child_delete:
logger.log: Child deleted
on_size_change:
logger.log: Size changed
on_style_change:
logger.log: Style changed
on_layout_change:
logger.log: Layout changed
on_get_self_size:
logger.log: Get self size
- led:
id: lv_led
color: 0x00FF00
@@ -1051,7 +1198,7 @@ lvgl:
- ticks:
width: 1
count: 61
length: 20%
length: 20
radial_offset: 5
color: 0xFFFFFF
major:
@@ -1159,6 +1306,13 @@ image:
type: BINARY
transparency: chroma_key
- id: alert
file: $component_dir/logo-text.svg
type: grayscale
resize: 100x100
invert_alpha: true
transparency: alpha_channel
color:
- id: light_blue
hex: "3340FF"
+12 -2
View File
@@ -1,6 +1,10 @@
mcp23008:
i2c_id: i2c_bus
id: mcp23008_hub
- i2c_id: i2c_bus
id: mcp23008_hub
- i2c_id: i2c_bus
id: mcp23008_hub_int
address: 0x21
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -9,6 +13,12 @@ binary_sensor:
mcp23xxx: mcp23008_hub
number: 0
mode: INPUT
- platform: gpio
id: mcp23008_binary_sensor_int
pin:
mcp23xxx: mcp23008_hub_int
number: 0
mode: INPUT
switch:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+12 -2
View File
@@ -1,6 +1,10 @@
mcp23017:
i2c_id: i2c_bus
id: mcp23017_hub
- i2c_id: i2c_bus
id: mcp23017_hub
- i2c_id: i2c_bus
id: mcp23017_hub_int
address: 0x21
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -9,6 +13,12 @@ binary_sensor:
mcp23xxx: mcp23017_hub
number: 0
mode: INPUT
- platform: gpio
id: mcp23017_binary_sensor_int
pin:
mcp23xxx: mcp23017_hub_int
number: 0
mode: INPUT
switch:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+1
View File
@@ -2,3 +2,4 @@ mcp23s08:
- id: mcp23s08_hub
cs_pin: ${cs_pin}
deviceaddress: 0
interrupt_pin: ${interrupt_pin}
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO5
interrupt_pin: GPIO15
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO15
interrupt_pin: GPIO0
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO5
interrupt_pin: GPIO2
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
+1
View File
@@ -2,3 +2,4 @@ mcp23s17:
- id: mcp23s17_hub
cs_pin: ${cs_pin}
deviceaddress: 0
interrupt_pin: ${interrupt_pin}
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO5
interrupt_pin: GPIO15
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO15
interrupt_pin: GPIO0
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
@@ -1,5 +1,6 @@
substitutions:
cs_pin: GPIO5
interrupt_pin: GPIO2
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
@@ -61,3 +61,8 @@ media_player:
- media_player.volume_up:
- media_player.volume_down:
- media_player.volume_set: 50%
- media_player.enqueue: http://localhost/media.mp3
- media_player.enqueue: !lambda 'return "http://localhost/media.mp3";'
- media_player.enqueue:
media_url: http://localhost/media.mp3
announcement: true
@@ -0,0 +1,396 @@
#include "../common.h"
namespace esphome::mitsubishi_cn105::testing {
struct TestContext {
MockUARTComponent uart;
uart::UARTDevice device{&uart};
TestableMitsubishiCN105 sut{device};
TestContext() { this->sut.set_current_time(0); }
};
TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
auto ctx = TestContext{};
ctx.sut.set_current_time(123);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
ctx.sut.initialize();
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{123});
}
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
auto ctx = TestContext{};
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Connect response
ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55});
ctx.sut.set_current_time(200);
ASSERT_FALSE(ctx.sut.update());
// All bytes from UART should be consumed
EXPECT_TRUE(ctx.uart.rx.empty());
// After successful connect we request status, first settings (0x02)
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{200});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Clear TX bytes.
ctx.uart.tx.clear();
// Settings response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99});
// Settings should still have initial values
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan());
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN);
ctx.sut.set_current_time(300);
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.rx.empty());
// Check settings that we just read from received package
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO);
// Now fetch room temperature (0x03)
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{300});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Clear TX bytes.
ctx.uart.tx.clear();
// Room temperature response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
// Room temperature should still have initial value
EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan());
ctx.sut.set_current_time(400);
EXPECT_FALSE(ctx.sut.is_status_initialized());
ASSERT_TRUE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.rx.empty());
EXPECT_TRUE(ctx.sut.is_status_initialized());
// Check room temperature we just read from received package
EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{400});
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
auto ctx = TestContext{};
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
// No response (no RX data), no retry yet
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
// Still no response after 1999ms, no retry yet
ctx.sut.set_current_time(1999);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
// Stop waiting after 2s and retry connect
ctx.sut.set_current_time(2000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{2000});
}
TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
auto ctx = TestContext{};
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
// RX noise/unexpected traffic
ctx.uart.push_rx({0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C,
0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A,
0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46});
// Make sure we have enough bytes in buffer.
ASSERT_GT(ctx.uart.rx.size(), 64);
// No valid response, no state change expected
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
// Watchdog interrupts reading (max. 64 bytes at once) so we do not spend the whole loop draining UART
EXPECT_FALSE(ctx.uart.rx.empty());
// Next update will read remaining bytes, no state change expected
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_TRUE(ctx.uart.rx.empty());
}
TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) {
auto ctx = TestContext{};
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
// Mixed RX stream with partial, malformed, and oversized frames to test parser robustness
ctx.uart.push_rx({// ─────────────────────────────
// Noise (no 0xFC) -> should be ignored via preamble reset
// ────────────────────────────
0x01, 0x02, 0x03, 0x04, 0x05,
// ─────────────────────────────
// Partial frame (declares payload len=5, but we cut it short)
// Later bytes will eventually force checksum mismatch and reset
// ─────────────────────────────
0xFC, 0x62, 0x01, 0x30, 0x05, 0xAA, 0xBB,
// ─────────────────────────────
// Invalid header (header byte 3 should be 0x01, header byte 4 should be 0x30)
// Should reset quickly on header mismatch
// ─────────────────────────────
0xFC, 0x62, 0xFF, 0xFF, 0x02, 0x01, 0x02, 0x00,
// ─────────────────────────────
// Oversized length field (rejected by payload-too-large check at HEADER_LEN)
// ─────────────────────────────
0xFC, 0x62, 0x01, 0x30, 0xFE, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A,
0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
// ─────────────────────────────
// Valid unknown-type frame (type=0x62), should be parsed successfully then ignored
// Frame: FC 62 01 30 02 AA BB 30
// ─────────────────────────────
0xFC, 0x62, 0x01, 0x30, 0x02, 0xAA, 0xBB, 0x30,
// ─────────────────────────────
// Invalid checksum (should be rejected at checksum check)
// ─────────────────────────────
0xFC, 0x62, 0x01, 0x30, 0x02, 0x10, 0x20, 0xFF,
// ─────────────────────────────
// Back-to-back VALID frames (unknown type=0x62) to stress boundary handling.
// Frame A: FC 62 01 30 01 02 6C
// Frame B: FC 62 01 30 01 03 6B
// ─────────────────────────────
0xFC, 0x62, 0x01, 0x30, 0x01, 0x02, 0x6C, 0xFC, 0x62, 0x01, 0x30, 0x01, 0x03, 0x6B,
// ─────────────────────────────
// Trailing noise
// ─────────────────────────────
0x55, 0x66, 0x77, 0x88});
// Drain RX - no valid response, no state change expected
int iterations = 0;
while (!ctx.uart.rx.empty() && iterations++ < 10) {
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
}
EXPECT_TRUE(ctx.uart.rx.empty());
}
TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
auto ctx = TestContext{};
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(80000);
// No scheduled status update
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Status update completed, schedule next status update
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{80000});
// Wait for update_interval (ms) before doing another status update
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(81999);
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(82000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
auto ctx = TestContext{};
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55});
ctx.sut.update();
EXPECT_TRUE(ctx.sut.status().power_on);
EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_);
EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET);
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) {
auto ctx = TestContext{};
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD});
ctx.sut.update();
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_);
EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f);
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY);
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4);
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().room_temperature, 16.0f);
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f);
}
TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
auto ctx = TestContext{};
ctx.sut.set_power(true);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
}
TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) {
auto ctx = TestContext{};
ctx.sut.set_target_temperature(23.0f);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x71));
}
TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) {
auto ctx = TestContext{};
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.set_target_temperature(26.0f);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0xC5));
}
TEST(MitsubishiCN105Tests, ApplyModeCool) {
auto ctx = TestContext{};
ctx.sut.set_mode(MitsubishiCN105::Mode::COOL);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x02, 0x00, 0x00, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78));
}
TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
auto ctx = TestContext{};
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1);
ctx.sut.apply_settings();
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73));
}
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
auto ctx = TestContext{};
// Waiting for next scheduled status update
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
// Nothing to do in update (rx empty, no timeout)
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
// Write new values
ctx.sut.use_temperature_encoding_b_ = true;
ctx.sut.set_power(false);
ctx.sut.set_target_temperature(25.0f);
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
// Waiting for next status update must be interrupted and new values send to AC
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
// Write ACK response
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,7 @@
#include "common.h"
namespace esphome::mitsubishi_cn105 {
uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; }
} // namespace esphome::mitsubishi_cn105
@@ -0,0 +1,58 @@
#pragma once
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <initializer_list>
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105::testing {
class MockUARTComponent : public uart::UARTComponent {
public:
std::vector<uint8_t> tx;
std::vector<uint8_t> rx;
void push_rx(std::initializer_list<uint8_t> data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); }
// UARTComponent
void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); }
bool read_array(uint8_t *data, size_t len) override {
if (this->rx.size() < len) {
return false;
}
std::copy(this->rx.begin(), this->rx.begin() + len, data);
this->rx.erase(this->rx.begin(), this->rx.begin() + len);
return true;
}
size_t available() override { return this->rx.size(); }
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
class TestableMitsubishiCN105 : public MitsubishiCN105 {
public:
using MitsubishiCN105::MitsubishiCN105;
using MitsubishiCN105::State;
using MitsubishiCN105::state_;
using MitsubishiCN105::write_timeout_start_ms_;
using MitsubishiCN105::status_update_start_ms_;
using MitsubishiCN105::use_temperature_encoding_b_;
void set_state(State s) { this->set_state_(s); }
void apply_settings() { this->apply_settings_(); }
static inline uint32_t test_loop_time_ms = 0;
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,4 @@
climate:
- platform: mitsubishi_cn105
name: "AC Test"
uart_id: uart_bus
@@ -0,0 +1,4 @@
packages:
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart_9600_even: !include ../../test_build_components/common/uart_9600_even/rp2040-ard.yaml
<<: !include common.yaml
+5
View File
@@ -3,6 +3,11 @@ pca9554:
i2c_id: i2c_bus
pin_count: 8
address: 0x3F
- id: pca9554_hub_int
i2c_id: i2c_bus
pin_count: 8
address: 0x3E
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+5
View File
@@ -3,6 +3,11 @@ pcf8574:
i2c_id: i2c_bus
address: 0x21
pcf8575: false
- id: pcf8574_hub_int
i2c_id: i2c_bus
address: 0x22
pcf8575: false
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+12 -3
View File
@@ -1,7 +1,11 @@
pi4ioe5v6408:
i2c_id: i2c_bus
id: pi4ioe1
address: 0x44
- i2c_id: i2c_bus
id: pi4ioe1
address: 0x44
- i2c_id: i2c_bus
id: pi4ioe1_int
address: 0x45
interrupt_pin: ${interrupt_pin}
switch:
- platform: gpio
@@ -16,3 +20,8 @@ binary_sensor:
pin:
pi4ioe5v6408: pi4ioe1
number: 1
- platform: gpio
id: sensor1_int
pin:
pi4ioe5v6408: pi4ioe1_int
number: 1
@@ -1,6 +1,7 @@
substitutions:
i2c_sda: GPIO21
i2c_scl: GPIO22
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,6 +1,7 @@
substitutions:
i2c_sda: GPIO4
i2c_scl: GPIO5
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
@@ -1,127 +0,0 @@
import pytest
from esphome.components import socket
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
)
from esphome.core import CORE
def _setup_platform(platform=PLATFORM_ESP8266) -> None:
"""Set up CORE.data with a platform for testing."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
def test_require_wake_loop_threadsafe__first_call() -> None:
"""Test that first call sets up define and consumes socket."""
_setup_platform()
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify CORE.data was updated
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
# Verify the define was added
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
def test_require_wake_loop_threadsafe__idempotent() -> None:
"""Test that subsequent calls are idempotent."""
# Set up initial state as if already called
CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
CORE.config = {"ethernet": True}
# Call again - should not raise or fail
socket.require_wake_loop_threadsafe()
# Verify state is still True
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
# Define should not be added since flag was already True
assert not any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
def test_require_wake_loop_threadsafe__multiple_calls() -> None:
"""Test that multiple calls only set up once."""
_setup_platform()
# Call three times
CORE.config = {"openthread": True}
socket.require_wake_loop_threadsafe()
socket.require_wake_loop_threadsafe()
socket.require_wake_loop_threadsafe()
# Verify CORE.data was set
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
# Verify the define was added (only once, but we can just check it exists)
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
def test_require_wake_loop_threadsafe__no_networking() -> None:
"""Test that wake loop is NOT configured when no networking is configured."""
# Set up config without any networking components
CORE.config = {"esphome": {"name": "test"}, "logger": {}}
# Call require_wake_loop_threadsafe
socket.require_wake_loop_threadsafe()
# Verify CORE.data flag was NOT set (since has_networking returns False)
assert socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED not in CORE.data
# Verify the define was NOT added
assert not any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -> None:
"""Test that no socket is consumed when no networking is configured."""
# Set up config without any networking components
CORE.config = {"logger": {}}
# Track initial socket consumer state
initial_udp = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
# Call require_wake_loop_threadsafe
socket.require_wake_loop_threadsafe()
# Verify no socket was consumed
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
assert udp_consumers == initial_udp
@pytest.mark.parametrize(
"platform",
[PLATFORM_ESP32, PLATFORM_BK72XX, PLATFORM_RTL87XX, PLATFORM_LN882X],
)
def test_require_wake_loop_threadsafe__fast_select_no_udp_socket(
platform: str,
) -> None:
"""Test that fast select platforms use task notifications instead of UDP socket."""
_setup_platform(platform)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify the define was added
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
# Verify no UDP socket was consumed (fast select platforms use FreeRTOS task notifications)
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
def test_require_wake_loop_threadsafe__non_fast_select_consumes_udp_socket() -> None:
"""Test that platforms without fast select consume a UDP socket for wake notifications."""
_setup_platform(PLATFORM_ESP8266)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify UDP socket was consumed
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert udp_consumers.get("socket.wake_loop_threadsafe") == 1
+4
View File
@@ -6,5 +6,9 @@ api:
time:
- platform: homeassistant
on_time:
- seconds: "0,10,20,30,40,50"
then:
- logger.log: "CronTrigger fired (every 10 seconds)"
- platform: sntp
id: sntp_time
+109
View File
@@ -758,6 +758,115 @@ TEST(PosixTzParser, EpochToLocalDstTransition) {
EXPECT_EQ(local.tm_isdst, 1);
}
// ============================================================================
// Leap year edge cases for closed-form year arithmetic
// ============================================================================
TEST(PosixTzParser, EpochToLocalLeapYear2000) {
// 2000 is a leap year (divisible by 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
// Feb 29, 2000 12:00:00 UTC
time_t epoch = make_utc(2000, 2, 29, 12);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 100); // 2000
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 29);
EXPECT_EQ(local.tm_hour, 12);
}
TEST(PosixTzParser, EpochToLocalNonLeapYear2100) {
// 2100 is NOT a leap year (divisible by 100 but not 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
// Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29
time_t epoch = make_utc(2100, 3, 1);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200); // 2100
EXPECT_EQ(local.tm_mon, 2); // March
EXPECT_EQ(local.tm_mday, 1);
// Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29)
epoch = make_utc(2100, 2, 28, 23, 59, 59);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200);
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 28);
}
TEST(PosixTzParser, EpochToLocalLeapYear2400) {
// 2400 is a leap year (divisible by 400)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
time_t epoch = make_utc(2400, 2, 29, 6);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 500); // 2400
EXPECT_EQ(local.tm_mon, 1); // February
EXPECT_EQ(local.tm_mday, 29);
EXPECT_EQ(local.tm_hour, 6);
}
TEST(PosixTzParser, EpochToLocalNewYearBoundaries) {
// Test year boundary — last second of 2099 and first second of 2100
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
struct tm local;
// Dec 31, 2099 23:59:59 UTC
time_t epoch = make_utc(2099, 12, 31, 23, 59, 59);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 199); // 2099
EXPECT_EQ(local.tm_mon, 11); // December
EXPECT_EQ(local.tm_mday, 31);
// Jan 1, 2100 00:00:00 UTC
epoch = make_utc(2100, 1, 1);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 200); // 2100
EXPECT_EQ(local.tm_mon, 0); // January
EXPECT_EQ(local.tm_mday, 1);
}
TEST(PosixTzParser, EpochToLocalDstAcrossCenturyBoundary) {
// DST transition in year 2100 (non-leap) with US Eastern rules
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz));
// July 4, 2100 16:00 UTC = 12:00 EDT
time_t epoch = make_utc(2100, 7, 4, 16);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_hour, 12);
EXPECT_EQ(local.tm_isdst, 1);
// Jan 15, 2100 10:00 UTC = 05:00 EST
epoch = make_utc(2100, 1, 15, 10);
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_hour, 5);
EXPECT_EQ(local.tm_isdst, 0);
}
TEST(PosixTzParser, EpochToLocalFarFutureYear5000) {
// Year 5000 — days/365 estimate overshoots by ~2 years due to leap days,
// requiring multiple correction steps in days_to_year.
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
time_t epoch = make_utc(5000, 6, 15, 12);
struct tm local;
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
EXPECT_EQ(local.tm_year, 3100); // 5000
EXPECT_EQ(local.tm_mon, 5); // June
EXPECT_EQ(local.tm_mday, 15);
EXPECT_EQ(local.tm_hour, 12);
}
// ============================================================================
// Verification against libc
// ============================================================================
+2
View File
@@ -5,3 +5,5 @@ display:
intensity: 3
lambda: |-
it.print("1234");
static const uint8_t buf[] = {0x3f, 0x06, 0x5b, 0x4f | 0x80};
it.set_buffer(buf, sizeof(buf));
+7
View File
@@ -198,6 +198,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
' - "-g" # Add debug symbols',
)
# Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
return content
@@ -0,0 +1,105 @@
esphome:
name: test-multi-click
host:
api:
batch_delay: 0ms
services:
- service: run_all_tests
then:
# Prime the binary sensor with an initial OFF state.
# trigger_on_initial_state defaults to false, so the first
# state change from unknown won't fire callbacks.
- binary_sensor.template.publish:
id: test_button
state: false
- delay: 50ms
# Test 1: Single click (ON < 50ms, OFF >= 30ms)
- binary_sensor.template.publish:
id: test_button
state: true
- delay: 20ms
- binary_sensor.template.publish:
id: test_button
state: false
# Wait for single click trigger (30ms) + cooldown (100ms) + margin
- delay: 200ms
# Test 2: Double click (ON < 50ms, OFF < 25ms, ON < 50ms, OFF >= 25ms)
- binary_sensor.template.publish:
id: test_button
state: true
- delay: 20ms
- binary_sensor.template.publish:
id: test_button
state: false
- delay: 15ms
- binary_sensor.template.publish:
id: test_button
state: true
- delay: 20ms
- binary_sensor.template.publish:
id: test_button
state: false
# Wait for double click trigger (25ms) + cooldown (100ms) + margin
- delay: 200ms
# Test 3: Long press (ON >= 80ms)
- binary_sensor.template.publish:
id: test_button
state: true
- delay: 100ms
- binary_sensor.template.publish:
id: test_button
state: false
logger:
level: VERBOSE
globals:
- id: single_click_count
type: int
initial_value: "0"
- id: double_click_count
type: int
initial_value: "0"
- id: long_press_count
type: int
initial_value: "0"
binary_sensor:
- platform: template
name: "Test Button"
id: test_button
on_multi_click:
# Single press
- timing:
- ON for at most 50ms
- OFF for at least 30ms
invalid_cooldown: 100ms
then:
- lambda: |-
id(single_click_count) += 1;
ESP_LOGI("multi_click_test", "SINGLE_CLICK count=%d", id(single_click_count));
# Double press
- timing:
- ON for at most 50ms
- OFF for at most 25ms
- ON for at most 50ms
- OFF for at least 25ms
invalid_cooldown: 100ms
then:
- lambda: |-
id(double_click_count) += 1;
ESP_LOGI("multi_click_test", "DOUBLE_CLICK count=%d", id(double_click_count));
# Long press
- timing:
- ON for at least 80ms
invalid_cooldown: 100ms
then:
- lambda: |-
id(long_press_count) += 1;
ESP_LOGI("multi_click_test", "LONG_PRESS count=%d", id(long_press_count));
@@ -22,6 +22,8 @@ uart_mock:
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
# auto_start must be false to avoid races: the test presses the
# "Start Scenario" button only after subscribing to states.
auto_start: false
debug:
responses:
@@ -46,7 +48,7 @@ modbus:
modbus_controller:
- address: 1
id: modbus_controller_ok
max_cmd_retries: 0
max_cmd_retries: 2
update_interval: 1s
- address: 2
id: modbus_controller_slow
@@ -89,4 +91,4 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(virtual_uart_dev).start_scenario();'
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -22,6 +22,8 @@ uart:
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
# auto_start must be false to avoid races: the test presses the
# "Start Scenario" button only after subscribing to states.
auto_start: false
debug:
on_tx:
@@ -40,7 +42,8 @@ uart_mock:
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00};
- uart_mock.inject_rx: # Second USB packet: rest of response (staged with 40ms latency)
delay: 40ms
data: !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
data:
!lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x42,0x6F,0xCC,0xCD,0x43,0x7C,0xB8,0x10,0x3D,0x38,0x51,0xEC,
0x43,0x81,0x1B,0xE7,0x3B,0x03,0x12,0x6F,0x50,0x1B};
@@ -61,4 +64,4 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(virtual_uart_dev).start_scenario();'
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -0,0 +1,124 @@
esphome:
name: uart-mock-modbus-server-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_dev
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
auto_start: false
debug:
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
- delay: 100ms
# Read holding register 7 on device 2
# Reply from device 2
# Read holding register 5 on device 1 (read_after_peer_response)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x02,
0x03,
0x02,
0x00,
0xF0,
0xFC,
0x00,
0x01,
0x03,
0x00,
0x05,
0x00,
0x01,
0x94,
0x0B,
]
- delay: 100ms
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
- delay: 100ms
# Read holding register 7 on device 2, with no response
# Read holding register A on device 1 (read_after_peer_timeout)
inject_rx:
[
0x02,
0x03,
0x00,
0x07,
0x00,
0x01,
0x35,
0xF8,
0x01,
0x03,
0x00,
0x0A,
0x00,
0x01,
0xA4,
0x08,
]
modbus:
uart_id: virtual_uart_dev
role: server
modbus_controller:
- address: 1
server_registers:
- address: 0x03
value_type: U_WORD
read_lambda: |-
id(basic_read).publish_state(1);
return 1;
- address: 0x05
value_type: U_WORD
read_lambda: |-
id(read_after_peer_response).publish_state(1);
return 1;
- address: 0x0A
value_type: U_WORD
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
sensor:
- platform: template
name: "basic_read"
id: basic_read
- platform: template
name: "read_after_peer_response"
id: read_after_peer_response
- platform: template
name: "read_after_peer_timeout"
id: read_after_peer_timeout
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
@@ -0,0 +1,180 @@
esphome:
name: uart-mock-modbus-server-contro
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
server_registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 99;
- address: 0x03
value_type: S_WORD
read_lambda: return -99;
- address: 0x05
value_type: U_DWORD
read_lambda: return 16909060;
- address: 0x08
value_type: S_DWORD
read_lambda: return -16909060;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return 67305985;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return -67305985;
- address: 0x11
value_type: U_QWORD
read_lambda: return 72623859790382856;
- address: 0x16
value_type: S_QWORD
read_lambda: return -72623859790382856;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return 578437695752307201;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return -578437695752307201;
- address: 0x25
value_type: FP32
read_lambda: return 3.14;
- address: 0x28
value_type: FP32_R
read_lambda: return 3.14;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
@@ -0,0 +1,118 @@
esphome:
name: uart-mock-modbus-server-mult
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
- id: virtual_uart_server_2
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
- uart_mock.inject_rx:
id: virtual_uart_server_2
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_server_2
id: virtual_modbus_server_2
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_1
- address: 2
modbus_id: virtual_modbus_client
update_interval: 1s
id: modbus_controller_2
- address: 1
modbus_id: virtual_modbus_server
server_registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 919;
- address: 2
modbus_id: virtual_modbus_server_2
server_registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 929;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_2
name: "reg_u_word_2"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_server_2).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
@@ -0,0 +1,330 @@
esphome:
name: uart-mock-modbus-srv-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_u_word
type: uint16_t
initial_value: "11"
- id: stored_s_word
type: int16_t
initial_value: "-11"
- id: stored_u_dword
type: uint32_t
initial_value: "1001"
- id: stored_s_dword
type: int32_t
initial_value: "-1001"
- id: stored_u_dword_r
type: uint32_t
initial_value: "3003"
- id: stored_s_dword_r
type: int32_t
initial_value: "-3003"
- id: stored_u_qword
type: uint64_t
initial_value: "5005"
- id: stored_s_qword
type: int64_t
initial_value: "-5005"
- id: stored_u_qword_r
type: uint64_t
initial_value: "7007"
- id: stored_s_qword_r
type: int64_t
initial_value: "-7007"
- id: stored_fp32
type: float
initial_value: "1.5"
- id: stored_fp32_r
type: float
initial_value: "2.5"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 2s
id: modbus_controller_1
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
server_registers:
- address: 0x01
value_type: U_WORD
read_lambda: return id(stored_u_word);
write_lambda: id(stored_u_word) = x; return true;
- address: 0x03
value_type: S_WORD
read_lambda: return id(stored_s_word);
write_lambda: id(stored_s_word) = x; return true;
- address: 0x05
value_type: U_DWORD
read_lambda: return id(stored_u_dword);
write_lambda: id(stored_u_dword) = x; return true;
- address: 0x08
value_type: S_DWORD
read_lambda: return id(stored_s_dword);
write_lambda: id(stored_s_dword) = x; return true;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return id(stored_u_dword_r);
write_lambda: id(stored_u_dword_r) = x; return true;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return id(stored_s_dword_r);
write_lambda: id(stored_s_dword_r) = x; return true;
- address: 0x11
value_type: U_QWORD
read_lambda: return id(stored_u_qword);
write_lambda: id(stored_u_qword) = x; return true;
- address: 0x16
value_type: S_QWORD
read_lambda: return id(stored_s_qword);
write_lambda: id(stored_s_qword) = x; return true;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return id(stored_u_qword_r);
write_lambda: id(stored_u_qword_r) = x; return true;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return id(stored_s_qword_r);
write_lambda: id(stored_s_qword_r) = x; return true;
- address: 0x25
value_type: FP32
read_lambda: return id(stored_fp32);
write_lambda: id(stored_fp32) = x; return true;
- address: 0x28
value_type: FP32_R
read_lambda: return id(stored_fp32_r);
write_lambda: id(stored_fp32_r) = x; return true;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32"
address: 0x25
register_type: holding
value_type: FP32
min_value: -16777215
max_value: 16777215
step: 0.01
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
min_value: -16777215
max_value: 16777215
step: 0.01
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
@@ -22,6 +22,8 @@ uart_mock:
baud_rate: 9600
rx_full_threshold: 120
rx_timeout: 2
# auto_start must be false to avoid races: the test presses the
# "Start Scenario" button only after subscribing to states.
auto_start: false
debug:
on_tx:
@@ -61,4 +63,4 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(virtual_uart_dev).start_scenario();'
- lambda: "id(virtual_uart_dev).start_scenario();"
+106
View File
@@ -346,3 +346,109 @@ class SensorStateCollector:
else:
self._waiters.append((condition, future))
return future
class SensorTracker:
"""Data-driven sensor state tracker with expected-value futures.
Tracks sensor state updates and resolves futures when sensors report
specific expected values. Eliminates per-sensor future boilerplate.
Usage::
tracker = SensorTracker(["reg_u_word", "reg_s_word"])
futures = tracker.expect_all({"reg_u_word": 99, "reg_s_word": -99})
# ... subscribe_states with tracker.on_state, start scenario ...
await tracker.await_all(futures)
"""
def __init__(self, sensor_names: list[str]) -> None:
self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names}
self.key_to_sensor: dict[int, str] = {}
self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {}
_ANY = object() # Sentinel: match any value
def expect(self, name: str, value: object) -> asyncio.Future:
"""Register an expected value for *name* and return a future for it."""
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._expectations.setdefault(name, []).append((value, future))
return future
def expect_any(self, name: str) -> asyncio.Future:
"""Register a future that resolves on *any* state update for *name*."""
return self.expect(name, self._ANY)
def expect_all(self, expected: dict[str, object]) -> dict[str, asyncio.Future]:
"""Call ``expect`` for every entry and return a dict of futures."""
return {name: self.expect(name, value) for name, value in expected.items()}
def on_state(self, state: EntityState) -> None:
"""State callback suitable for ``subscribe_states``."""
if not isinstance(state, SensorState) or state.missing_state:
return
sensor_name = self.key_to_sensor.get(state.key)
if not sensor_name or sensor_name not in self.sensor_states:
return
self.sensor_states[sensor_name].append(state.state)
for expected_value, future in self._expectations.get(sensor_name, []):
if not future.done() and (
expected_value is self._ANY or state.state == expected_value
):
future.set_result(True)
break
async def await_change(
self, future: asyncio.Future, name: str, timeout: float = 2.0
) -> None:
"""Wait for a sensor future to resolve; fail the test on timeout."""
try:
await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
import pytest
pytest.fail(
f"Timeout waiting for {name} change. Received sensor states:\n"
f" {name}: {self.sensor_states[name]}\n"
)
async def await_must_not_change(
self, future: asyncio.Future, name: str, timeout: float = 2.0
) -> None:
"""Assert a sensor future does NOT resolve within the timeout."""
try:
await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
return # Expected
import pytest
pytest.fail(
f"{name} change should not have been triggered, but was. "
f"Received sensor states:\n {name}: {self.sensor_states[name]}\n"
)
async def await_all(
self, futures: dict[str, asyncio.Future], timeout: float = 2.0
) -> None:
"""Await every future in *futures*, failing with per-sensor diagnostics."""
for name, future in futures.items():
await self.await_change(future, name, timeout=timeout)
async def setup_and_start_scenario(self, client) -> list:
"""Wire up subscriptions, wait for initial states, press Start Scenario."""
entities, _ = await client.list_entities_services()
self.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(self.sensor_states.keys()))
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
import pytest
pytest.fail("Timeout waiting for initial states")
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
return entities
@@ -0,0 +1,82 @@
"""Integration test for on_multi_click binary sensor automation.
Tests that on_multi_click correctly triggers for single click, double click,
and long press patterns using a template binary sensor with timing
orchestrated entirely in YAML.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_multi_click_trigger(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that on_multi_click triggers for single, double, and long press patterns."""
loop = asyncio.get_running_loop()
single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)")
double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)")
long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)")
single_click_future: asyncio.Future[int] = loop.create_future()
double_click_future: asyncio.Future[int] = loop.create_future()
long_press_future: asyncio.Future[int] = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for multi-click trigger messages."""
if m := single_click_pattern.search(line):
if not single_click_future.done():
single_click_future.set_result(int(m.group(1)))
elif m := double_click_pattern.search(line):
if not double_click_future.done():
double_click_future.set_result(int(m.group(1)))
elif (m := long_press_pattern.search(line)) and not long_press_future.done():
long_press_future.set_result(int(m.group(1)))
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
test_service = next((s for s in services if s.name == "run_all_tests"), None)
assert test_service is not None, "run_all_tests service not found"
# Kick off the entire test sequence (runs in YAML with delays)
await client.execute_service(test_service, {})
# Wait for all three triggers
try:
count = await asyncio.wait_for(single_click_future, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger."
)
assert count == 1, f"Expected single click count=1, got {count}"
try:
count = await asyncio.wait_for(double_click_future, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger."
)
assert count == 1, f"Expected double click count=1, got {count}"
try:
count = await asyncio.wait_for(long_press_future, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for LONG_PRESS - on_multi_click did not trigger."
)
assert count == 1, f"Expected long press count=1, got {count}"
+9 -2
View File
@@ -73,9 +73,16 @@ async def test_uart_mock_ld2410(
],
)
# Signal when we see recovery frame values
# Signal when we see ALL recovery frame values to avoid race where some
# arrive after the waiter fires but before we index into the lists
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(127.0) in collector.sensor_states["detection_distance"]
)
)
async with (
+8 -1
View File
@@ -83,11 +83,18 @@ async def test_uart_mock_ld2450(
],
)
# Signal when we see recovery frame values (target 1 distance ≈ 500mm)
# Signal when we see all recovery frame values
# Must wait for ALL values to avoid race where some arrive after the waiter fires
recovery_received = collector.add_waiter(
lambda: (
pytest.approx(500.0, abs=1.0)
in collector.sensor_states["target_1_distance"]
and pytest.approx(300.0) in collector.sensor_states["target_1_x"]
and pytest.approx(400.0) in collector.sensor_states["target_1_y"]
and pytest.approx(30.0) in collector.sensor_states["target_1_speed"]
and pytest.approx(1.0) in collector.sensor_states["target_count"]
and pytest.approx(1.0) in collector.sensor_states["moving_target_count"]
and pytest.approx(0.0) in collector.sensor_states["still_target_count"]
)
)
+259 -224
View File
@@ -14,15 +14,67 @@ test_uart_mock_modbus_no_threshold :
from __future__ import annotations
import asyncio
from pathlib import Path
from collections.abc import Callable
from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, EntityState, SensorState
from aioesphomeapi import NumberInfo
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity
from .state_utils import SensorTracker, find_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@dataclass
class RegisterTestCase:
"""Test parameters for a single modbus register write/read round-trip."""
initial_value: object
write_number_name: str
write_value: float
post_write_value: object
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_modbus_line_callback() -> tuple[Callable[[str], None], list[str], list[str]]:
"""Return a (callback, error_lines, warning_lines) tuple for tracking modbus log output.
Only captures bus-level modbus messages ([modbus:]), not modbus_controller
scheduling noise (e.g. "Duplicate modbus command found").
"""
error_log_lines: list[str] = []
warning_log_lines: list[str] = []
def line_callback(line: str) -> None:
if "[E][modbus:" in line:
error_log_lines.append(line)
if "[W][modbus:" in line:
warning_log_lines.append(line)
return line_callback, error_log_lines, warning_log_lines
def _assert_no_modbus_errors(
error_log_lines: list[str], warning_log_lines: list[str]
) -> None:
assert len(error_log_lines) == 0, (
"Expect no errors logged by the modbus mock, but got:\n"
+ "\n".join(error_log_lines)
)
assert len(warning_log_lines) == 0, (
"Expect no warnings logged by the modbus mock, but got:\n"
+ "\n".join(warning_log_lines)
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_uart_mock_modbus(
yaml_config: str,
@@ -30,127 +82,41 @@ async def test_uart_mock_modbus(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test basic modbus data parsing."""
# Replace external component path placeholder
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
tracker = SensorTracker(
[
"basic_register",
"delayed_response",
"late_response",
"no_response",
"exception_response",
]
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"basic_register": [],
"delayed_response": [],
"late_response": [],
"no_response": [],
"exception_response": [],
}
basic_register_changed = loop.create_future()
delayed_response_changed = loop.create_future()
late_response_changed = loop.create_future()
no_response_changed = loop.create_future()
exception_response_changed = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
if (
sensor_name == "basic_register"
and state.state == 259.0
and not basic_register_changed.done()
):
basic_register_changed.set_result(True)
elif (
sensor_name == "delayed_response"
and state.state == 255.0
and not delayed_response_changed.done()
):
delayed_response_changed.set_result(True)
elif (
sensor_name == "late_response" and not late_response_changed.done()
):
late_response_changed.set_result(True)
elif sensor_name == "no_response" and not no_response_changed.done():
no_response_changed.set_result(True)
elif (
sensor_name == "exception_response"
and not exception_response_changed.done()
):
exception_response_changed.set_result(True)
basic_register_changed = tracker.expect("basic_register", 259.0)
delayed_response_changed = tracker.expect("delayed_response", 255.0)
# late_response / no_response / exception_response: expect *any* value
# (these should never fire, so we use a permissive match via expect_any)
late_response_changed = tracker.expect_any("late_response")
no_response_changed = tracker.expect_any("no_response")
exception_response_changed = tracker.expect_any("exception_response")
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
await tracker.setup_and_start_scenario(client)
# Build key mappings for all sensor types
all_names = list(sensor_states.keys())
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
# Set up initial state helper
initial_state_helper = InitialStateHelper(entities)
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")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
try:
await asyncio.wait_for(delayed_response_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for delayed_response change. Received sensor states:\n"
f" delayed_response: {sensor_states['delayed_response']}\n"
)
try:
await asyncio.wait_for(late_response_changed, timeout=2.0)
pytest.fail(
f"late_response change should not have been triggered, but was. Received sensor states:\n"
f" late_response: {sensor_states['late_response']}\n"
)
except TimeoutError:
pass # Expected timeout since we never inject a response for late_response
try:
await asyncio.wait_for(no_response_changed, timeout=2.0)
pytest.fail(
f"no_response change should not have been triggered, but was. Received sensor states:\n"
f" no_response: {sensor_states['no_response']}\n"
)
except TimeoutError:
pass # Expected timeout since we never inject a response for no_response
# Wait for basic register to be updated with successful parse
try:
await asyncio.wait_for(basic_register_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Basic Register change. Received sensor states:\n"
f" basic_register: {sensor_states['basic_register']}\n"
)
try:
await asyncio.wait_for(exception_response_changed, timeout=2.0)
pytest.fail(
f"exception_response change should not have been triggered, but was. Received sensor states:\n"
f" exception_response: {sensor_states['exception_response']}\n"
)
except TimeoutError:
pass
await tracker.await_change(delayed_response_changed, "delayed_response")
await tracker.await_change(basic_register_changed, "basic_register")
# Run all "must not change" checks concurrently — each waits the full
# timeout, so sequential execution would multiply the wall time.
await asyncio.gather(
tracker.await_must_not_change(late_response_changed, "late_response"),
tracker.await_must_not_change(no_response_changed, "no_response"),
tracker.await_must_not_change(
exception_response_changed, "exception_response"
),
)
@pytest.mark.asyncio
@@ -159,69 +125,17 @@ async def test_uart_mock_modbus_timing(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test basic modbus data parsing."""
# Replace external component path placeholder
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
"""Test modbus timing with multi-register SDM meter response."""
loop = asyncio.get_running_loop()
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"sdm_voltage": [],
}
voltage_changed = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
# Check if this is a good voltage reading (243V)
if (
sensor_name == "sdm_voltage"
and state.state > 200.0
and not voltage_changed.done()
):
voltage_changed.set_result(True)
tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage")
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
# Build key mappings for all sensor types
all_names = list(sensor_states.keys())
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
# Set up initial state helper
initial_state_helper = InitialStateHelper(entities)
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")
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for voltage to be updated with successful parse
try:
await asyncio.wait_for(voltage_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for SDM voltage change. Received sensor states:\n"
f" sdm_voltage: {sensor_states['sdm_voltage']}\n"
)
await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage")
@pytest.mark.asyncio
@@ -234,66 +148,187 @@ async def test_uart_mock_modbus_no_threshold(
Without the 50ms fallback timeout, the chunked response with a 40ms gap
between USB packets would cause a false timeout and CRC failure cascade.
Bus-level warnings (CRC failures, buffer clears) are expected during
chunked reassembly — the test only verifies the final value arrives.
"""
# Replace external component path placeholder
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"sdm_voltage": [],
}
voltage_changed = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
sensor_name = key_to_sensor.get(state.key)
if sensor_name and sensor_name in sensor_states:
sensor_states[sensor_name].append(state.state)
# Check if this is a good voltage reading (243V)
if (
sensor_name == "sdm_voltage"
and state.state > 200.0
and not voltage_changed.done()
):
voltage_changed.set_result(True)
tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage")
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage")
# Build key mappings for all sensor types
all_names = list(sensor_states.keys())
key_to_sensor = build_key_to_entity_mapping(entities, all_names)
# Set up initial state helper
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
@pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test modbus server parsing with peer traffic on a shared bus."""
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
# Start the UART mock scenario now that we're subscribed
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
tracker = SensorTracker(
["basic_read", "read_after_peer_response", "read_after_peer_timeout"]
)
futures = tracker.expect_all(
{
"basic_read": 1,
"read_after_peer_response": 1,
"read_after_peer_timeout": 1,
}
)
# Wait for voltage to be updated with successful parse
try:
await asyncio.wait_for(voltage_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for SDM voltage change. Received sensor states:\n"
f" sdm_voltage: {sensor_states['sdm_voltage']}\n"
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test server/controller functionality for all read register types."""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {
"reg_u_word": 99,
"reg_s_word": -99,
"reg_u_dword": 16909060,
"reg_s_dword": -16909060,
"reg_u_dword_r": pytest.approx(67305985),
"reg_s_dword_r": pytest.approx(-67305985),
"reg_u_qword": pytest.approx(72623859790382856),
"reg_s_qword": pytest.approx(-72623859790382856),
"reg_u_qword_r": pytest.approx(578437695752307201),
"reg_s_qword_r": pytest.approx(-578437695752307201),
"reg_fp32": pytest.approx(3.14),
"reg_fp32_r": pytest.approx(3.14),
}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_write(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test server/controller write functionality for all register value types.
Verifies that writing to modbus server registers via the controller updates
the server's stored values, which are then read back correctly on the next poll.
All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R).
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
register_test_cases: dict[str, RegisterTestCase] = {
"reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42),
"reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42),
"reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002),
"reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002),
"reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004),
"reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004),
"reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006),
"reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006),
"reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008),
"reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008),
"reg_fp32": RegisterTestCase(
pytest.approx(1.5, abs=0.01),
"write_fp32",
3.14,
pytest.approx(3.14, abs=0.01),
),
"reg_fp32_r": RegisterTestCase(
pytest.approx(2.5, abs=0.01),
"write_fp32_r",
6.28,
pytest.approx(6.28, abs=0.01),
),
}
tracker = SensorTracker(list(register_test_cases.keys()))
# Phase 1: expect initial baseline values
initial_futures = tracker.expect_all(
{name: case.initial_value for name, case in register_test_cases.items()}
)
# Phase 2: expect post-write values (registered now so on_state can match them)
written_futures = tracker.expect_all(
{name: case.post_write_value for name, case in register_test_cases.items()}
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
# Wait for initial baseline values to confirm the controller <-> server
# connection is working before issuing writes
await tracker.await_all(initial_futures, timeout=4.0)
# Issue write commands for all register types
for case in register_test_cases.values():
entity = find_entity(entities, case.write_number_name, NumberInfo)
assert entity is not None, (
f"{case.write_number_name} number entity not found"
)
client.number_command(entity.key, case.write_value)
# Wait for sensors to reflect the written values (round-trip write+read)
await tracker.await_all(written_futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test server/controller functionality with multiple servers."""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)

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