From f250fa73d0f3e36872c84000458425fc902f732b Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:32 +0200 Subject: [PATCH 01/43] [ld2420] Resume UART reads after a silent command timeout (#21131) --- esphome/components/ld2420/ld2420.cpp | 1 + .../uart_mock_ld2420_config_exit.yaml | 97 +++++++++++++++++++ .../test_uart_mock_ld2420_config_exit.py | 79 +++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml create mode 100644 tests/integration/test_uart_mock_ld2420_config_exit.py diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index e81f117a1b3..b5f3292fbc2 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -683,6 +683,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) { this->handle_cmd_error(this->cmd_reply_.error); } } + this->cmd_active_ = false; // Resend and give-up are done; let loop() read the UART again return error; } diff --git a/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml b/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml new file mode 100644 index 00000000000..dfea7b23ad8 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2420_config_exit.yaml @@ -0,0 +1,97 @@ +esphome: + name: uart-mock-ld2420-config-exit + +host: +api: + batch_delay: 0ms +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"] +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + # Energy reports with distance 100, then 101, more than the 1 s report interval apart. + # Each delay counts from the previous injection. + injections: + - delay: 1200ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x23, 0x00, 0x01, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5] + - delay: 1500ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x23, 0x00, 0x01, 0x65, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5] + on_tx: + - then: + - lambda: |- + // Rebuild command frames however the writes are split, and answer each by its command byte. + // Reply to config exit (0xFE): 0 none, 1 failure status, 2 too short, 3 success + static constexpr int MODE = EXIT_REPLY_MODE; + static std::vector buf; + static int exit_sends = 0; + buf.insert(buf.end(), data.begin(), data.end()); + while (buf.size() >= 6) { + if (buf[0] != 0xFD || buf[1] != 0xFC || buf[2] != 0xFB || buf[3] != 0xFA) { + buf.erase(buf.begin()); + continue; + } + size_t len = buf[4] | (buf[5] << 8); + if (buf.size() < len + 10) + return; + uint8_t cmd = buf[6]; + size_t params = len >= 2 ? (len - 2) / 2 : 0; + buf.erase(buf.begin(), buf.begin() + len + 10); + std::vector body = {cmd, 0x01, 0x00, 0x00}; // reply word, status + if (cmd == 0x68) { + continue; // restart has no reply + } else if (cmd == 0x00) { + body.insert(body.end(), {0x06, 0x00, 0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30}); // "v2.0.0" + } else if (cmd == 0x08) { + body.insert(body.end(), params * 4, 0x00); + } else if (cmd == 0xFE) { + ESP_LOGI("ld2420_test", "Config exit sent %d", ++exit_sends); + if (MODE == 0) + continue; + if (MODE == 1) + body[2] = 0x01; + if (MODE == 2) + body.resize(1); + } + std::vector reply = {0xFD, 0xFC, 0xFB, 0xFA, static_cast(body.size()), 0x00}; + reply.insert(reply.end(), body.begin(), body.end()); + reply.insert(reply.end(), {0x04, 0x03, 0x02, 0x01}); + id(mock_uart).inject_to_rx_buffer(reply); + } + +button: + - platform: template + name: "Start Scenario" + on_press: + - lambda: 'id(mock_uart).start_scenario();' + +ld2420: + id: ld2420_dev + uart_id: mock_uart + +sensor: + - platform: ld2420 + ld2420_id: ld2420_dev + moving_distance: + name: "Moving Distance" diff --git a/tests/integration/test_uart_mock_ld2420_config_exit.py b/tests/integration/test_uart_mock_ld2420_config_exit.py new file mode 100644 index 00000000000..28b4f202d72 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2420_config_exit.py @@ -0,0 +1,79 @@ +"""Integration test for LD2420 reports after the last config exit command.""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +EXIT_SENT = re.compile(r"Config exit sent (\d+)") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mode", "expected_sends"), + [ + pytest.param(0, 3, id="silent"), + pytest.param(1, 3, id="nack"), + pytest.param(2, 3, id="short"), + pytest.param(3, 1, id="ack"), + ], +) +async def test_uart_mock_ld2420_config_exit( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + mode: int, + expected_sends: int, +) -> None: + """Test both energy reports arrive after the last config exit, and a failed exit is still sent 3 times.""" + exit_sends: list[int] = [] + + def line_callback(line: str) -> None: + if match := EXIT_SENT.search(line): + exit_sends.append(int(match[1])) + + collector = SensorStateCollector(sensor_names=["moving_distance"]) + yaml_config = yaml_config.replace("EXIT_REPLY_MODE", str(mode)) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + resumed = collector.add_waiter( + lambda: len(collector.sensor_states["moving_distance"]) >= 2 + ) + try: + await asyncio.wait_for(resumed, timeout=5.0) + except TimeoutError: + pytest.fail( + "Energy reports did not resume after the last config exit.\n" + f" exit sends: {exit_sends}\n" + f" sensor_states: {collector.sensor_states}" + ) + + # The sensor skips repeated values, so each distinct distance is one report + assert collector.sensor_states["moving_distance"] == pytest.approx( + [100.0, 101.0] + ) + # A failed exit is resent and then given up; it is never treated as answered + assert exit_sends == list(range(1, expected_sends + 1)) From fd3c1fad45147fd8a6ab2dc798b0752df166f4ce Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:23:07 +0200 Subject: [PATCH 02/43] [ld2410s] Fix parameter writes after long uptime (#21121) --- esphome/components/ld2410s/ld2410s.cpp | 5 +- .../ld2410s/ld2410s_parameter_write.cpp | 179 ++++++++++++++++++ tests/components/ld2410s/number/__init__.py | 16 ++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tests/components/ld2410s/ld2410s_parameter_write.cpp create mode 100644 tests/components/ld2410s/number/__init__.py diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp index 3f71c77a62b..740604e4bc9 100644 --- a/esphome/components/ld2410s/ld2410s.cpp +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -116,7 +116,10 @@ void LD2410SComponent::loop() { #ifdef USE_LD2410S_PARAMETERS } else if (this->parameters_dirty_) { this->parameters_dirty_ = false; - this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_); + // Keep the pause after the last acknowledgement; a start time further ahead than that is long past, + // and after 2^31 ms the run_sequence_() check would read it as the future + const bool in_gap = this->next_send_at_ - now <= COMMAND_GAP_MS; + this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, in_gap ? this->next_send_at_ : now); #endif } } diff --git a/tests/components/ld2410s/ld2410s_parameter_write.cpp b/tests/components/ld2410s/ld2410s_parameter_write.cpp new file mode 100644 index 00000000000..2f32aa47ae8 --- /dev/null +++ b/tests/components/ld2410s/ld2410s_parameter_write.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include +#include "esphome/components/ld2410s/ld2410s.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/application.h" + +#if !defined(USE_LD2410S_PARAMETERS) || !defined(USE_LD2410S_VERSION) +#error "tests/components/ld2410s/number/__init__.py must define the LD2410S features" +#endif + +namespace esphome::ld2410s::testing { + +// Vectors, so EXPECT_EQ prints both command lists on a mismatch +static const std::vector INIT_COMMANDS = {0x00FF, 0x0000, 0x0071, 0x007A, 0x00FE}; +static const std::vector WRITE_COMMANDS = {0x00FF, 0x0070, 0x00FE}; +static const std::vector CONFIG_MODE_ON = {0x00FF}; +static constexpr uint32_t STEP_MS = 300; +// 0x0070 frame: 4 header + 2 length + 2 command + 6 * (2 key + 4 value) + 4 footer +static constexpr size_t WRITE_FRAME_SIZE = 48; +static constexpr size_t MAX_DISTANCE_VALUE_POS = 10; +static constexpr size_t NO_DELAY_VALUE_POS = 22; +// Init far from the wrap, across it, and ending just before it: nine 300 ms passes put the last +// acknowledgement at 0xFFFFFF00, so the gap itself crosses the wrap +static constexpr uint32_t START_FAR_FROM_WRAP = 1000; +static constexpr uint32_t START_ACROSS_WRAP = 0xFFFFFC00; +static constexpr uint32_t START_GAP_ACROSS_WRAP = 0xFFFFF474; + +// Acknowledges every command in the layout ld2410s.cpp parses; a fixture, not a hardware claim +class FakeUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + const uint16_t command = static_cast(data[6] | (data[7] << 8)); + this->commands.push_back(command); + this->frames.emplace_back(data, data + len); + std::vector payload = {data[6], static_cast(data[7] | 0x01), 0x00, 0x00}; + if (command == 0x0071) // six 32-bit values, each within a byte + payload.insert(payload.end(), {8, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0, 0, 5, 0, 0, 0}); + if (command == 0x0000) // device type and three version words + payload.insert(payload.end(), size_t{10}, uint8_t{0}); + std::vector frame = {0xFD, 0xFC, 0xFB, 0xFA, static_cast(payload.size()), 0x00}; + frame.insert(frame.end(), payload.begin(), payload.end()); + frame.insert(frame.end(), {0x04, 0x03, 0x02, 0x01}); + this->queue(frame); + } + void queue(const std::vector &bytes) { this->rx_.insert(this->rx_.end(), bytes.begin(), bytes.end()); } + bool read_array(uint8_t *data, size_t len) override { + if (this->rx_.size() - this->pos_ < len) + return false; + std::copy_n(this->rx_.begin() + this->pos_, len, data); + this->pos_ += len; + return true; + } + size_t available() override { return this->rx_.size() - this->pos_; } + bool peek_byte(uint8_t *data) override { return false; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + std::vector commands; + std::vector> frames; + + protected: + std::vector rx_; + size_t pos_{0}; +}; + +class LD2410SWriteTest : public ::testing::Test { + public: + LD2410SWriteTest() { this->radar_.set_uart_parent(&this->uart_); } + + protected: + void loop_at_(uint32_t now) { + LoopBlockingGuard guard{nullptr, nullptr, now}; + this->radar_.loop(); + } + // Runs init until the configuration-mode-off acknowledgement is taken, then queues one change + void finish_init_(uint32_t now) { + for (int pass = 0; pass < 20; pass++, now += STEP_MS) { + this->loop_at_(now); + if (!this->uart_.commands.empty() && this->uart_.commands.back() == 0x00FE && this->uart_.available() == 0) + break; + } + ASSERT_EQ(this->uart_.commands, INIT_COMMANDS); + ASSERT_EQ(this->uart_.available(), 0u); + ASSERT_TRUE(this->radar_.set_parameter(Parameter::PARAMETER_NO_DELAY, 30)); + this->acked_ = now; + this->sent_ = this->uart_.commands.size(); + } + // Loops every 300 ms with a short data frame before each pass, so the link check never restarts init + std::vector write_from_(uint32_t now, int passes) { + for (int pass = 0; pass < passes; pass++, now += STEP_MS) { + this->uart_.queue({0x6E, 0x02, 0x64, 0x00, 0x62}); + this->loop_at_(now); + } + return {this->uart_.commands.begin() + this->sent_, this->uart_.commands.end()}; + } + void check_waits_for_gap_(uint32_t start, uint32_t elapsed) { + this->finish_init_(start); + if (::testing::Test::HasFatalFailure()) + return; + EXPECT_TRUE(this->write_from_(this->acked_ + elapsed, 1).empty()); + EXPECT_EQ(this->write_from_(this->acked_ + STEP_MS, 1), CONFIG_MODE_ON); + EXPECT_EQ(this->write_from_(this->acked_ + 2 * STEP_MS, 5), WRITE_COMMANDS); + } + void check_starts_on_first_pass_(uint32_t start, uint32_t elapsed) { + this->finish_init_(start); + if (::testing::Test::HasFatalFailure()) + return; + const uint32_t now = this->acked_ + elapsed; + EXPECT_EQ(this->write_from_(now, 1), CONFIG_MODE_ON); + // The write, not a re-init (which would send 0000 next) + ASSERT_EQ(this->write_from_(now + STEP_MS, 5), WRITE_COMMANDS); + const auto &frame = this->uart_.frames[this->sent_ + 1]; + ASSERT_EQ(frame.size(), WRITE_FRAME_SIZE); + EXPECT_EQ(frame[NO_DELAY_VALUE_POS], 30); + EXPECT_EQ(frame[MAX_DISTANCE_VALUE_POS], 8); // the module's own value, read during init + } + + FakeUART uart_; + LD2410SComponent radar_; + uint32_t acked_{0}; + size_t sent_{0}; +}; + +// Time since the last acknowledgement is inside the 300 ms gap: nothing is sent until it ends +class LD2410SGapTest : public LD2410SWriteTest {}; +// Time since the last acknowledgement is at or past the gap end, up to the full 32-bit range +class LD2410SStartTest : public LD2410SWriteTest {}; + +TEST_F(LD2410SGapTest, FarFromWrapElapsed0) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 0); } +TEST_F(LD2410SGapTest, FarFromWrapElapsed100) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 100); } +TEST_F(LD2410SGapTest, FarFromWrapElapsed299) { this->check_waits_for_gap_(START_FAR_FROM_WRAP, 299); } +TEST_F(LD2410SGapTest, AcrossWrapElapsed0) { this->check_waits_for_gap_(START_ACROSS_WRAP, 0); } +TEST_F(LD2410SGapTest, AcrossWrapElapsed100) { this->check_waits_for_gap_(START_ACROSS_WRAP, 100); } +TEST_F(LD2410SGapTest, AcrossWrapElapsed299) { this->check_waits_for_gap_(START_ACROSS_WRAP, 299); } +TEST_F(LD2410SGapTest, GapAcrossWrapElapsed0) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 0); } +TEST_F(LD2410SGapTest, GapAcrossWrapElapsed100) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 100); } +TEST_F(LD2410SGapTest, GapAcrossWrapElapsed299) { this->check_waits_for_gap_(START_GAP_ACROSS_WRAP, 299); } + +TEST_F(LD2410SStartTest, FarFromWrapGapEnd) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 300); } +TEST_F(LD2410SStartTest, FarFromWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 301); } +TEST_F(LD2410SStartTest, FarFromWrapHalfRangeMinus1) { + this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0x8000012B); +} +TEST_F(LD2410SStartTest, FarFromWrapHalfRange) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0x8000012C); } +TEST_F(LD2410SStartTest, FarFromWrapThreeQuarterRange) { + this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0xC0000000); +} +TEST_F(LD2410SStartTest, FarFromWrapFullRange) { this->check_starts_on_first_pass_(START_FAR_FROM_WRAP, 0xFFFFFFFF); } +TEST_F(LD2410SStartTest, AcrossWrapGapEnd) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 300); } +TEST_F(LD2410SStartTest, AcrossWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 301); } +TEST_F(LD2410SStartTest, AcrossWrapHalfRangeMinus1) { + this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0x8000012B); +} +TEST_F(LD2410SStartTest, AcrossWrapHalfRange) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0x8000012C); } +TEST_F(LD2410SStartTest, AcrossWrapThreeQuarterRange) { + this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0xC0000000); +} +TEST_F(LD2410SStartTest, AcrossWrapFullRange) { this->check_starts_on_first_pass_(START_ACROSS_WRAP, 0xFFFFFFFF); } +TEST_F(LD2410SStartTest, GapAcrossWrapGapEnd) { this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 300); } +TEST_F(LD2410SStartTest, GapAcrossWrapGapEndPlus1) { this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 301); } +TEST_F(LD2410SStartTest, GapAcrossWrapHalfRangeMinus1) { + this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0x8000012B); +} +TEST_F(LD2410SStartTest, GapAcrossWrapHalfRange) { + this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0x8000012C); +} +TEST_F(LD2410SStartTest, GapAcrossWrapThreeQuarterRange) { + this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0xC0000000); +} +TEST_F(LD2410SStartTest, GapAcrossWrapFullRange) { + this->check_starts_on_first_pass_(START_GAP_ACROSS_WRAP, 0xFFFFFFFF); +} + +} // namespace esphome::ld2410s::testing diff --git a/tests/components/ld2410s/number/__init__.py b/tests/components/ld2410s/number/__init__.py new file mode 100644 index 00000000000..31446f8d549 --- /dev/null +++ b/tests/components/ld2410s/number/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The hub's to_code emits no feature defines; this platform stub turns on the parameter and + # version code in the real ld2410s.cpp for the host test build + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("text_sensor") + cg.add_define("USE_LD2410S_PARAMETERS") + cg.add_define("USE_LD2410S_VERSION") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["text_sensor"] From a5fbd34a1e1c06a606bb52b1385b413e08d8da09 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:23:31 +0200 Subject: [PATCH 03/43] [ld2460] Preserve buffered frames across overflow (#21123) --- esphome/components/ld2460/ld2460.cpp | 29 +++++++++++++- .../ld2460/sensor/ld2460_readline.cpp | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index c2d469575e7..54b98d123c2 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -37,6 +37,22 @@ static bool matches(const uint8_t *bytes, const uint8_t *pattern) { static constexpr uint8_t FRAME_PREFIX_SIZE = 7; static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); } +// First offset after 0 whose bytes, as far as they go, match a header; len when there is none (len >= 1) +static uint8_t next_header_start(const uint8_t *buffer, uint8_t len) { + uint8_t data_header[HEADER_FOOTER_SIZE]; + uint8_t cmd_header[HEADER_FOOTER_SIZE]; + progmem_memcpy(data_header, DATA_FRAME_HEADER, sizeof(data_header)); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, sizeof(cmd_header)); + uint8_t start = 1; + for (; start < len; start++) { + const size_t n = std::min(len - start, HEADER_FOOTER_SIZE); + if (std::memcmp(&buffer[start], data_header, n) == 0 || std::memcmp(&buffer[start], cmd_header, n) == 0) { + break; + } + } + return start; +} + static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v; void LD2460Component::setup() { this->send_query_(); } @@ -155,8 +171,17 @@ void LD2460Component::handle_periodic_data_() { void LD2460Component::readline_(uint8_t readch) { if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); - this->buffer_pos_ = 0; + // Keep the bytes from the next offset that could start a header, so a frame already partly + // buffered behind stray bytes survives; it is still fully validated once its footer arrives + const uint8_t start = next_header_start(this->buffer_data_, this->buffer_pos_); + this->buffer_pos_ -= start; + std::memmove(this->buffer_data_, &this->buffer_data_[start], this->buffer_pos_); + // Header-shaped noise keeps a whole header; warning only otherwise bounds the warning rate + if (this->buffer_pos_ < HEADER_FOOTER_SIZE) { + ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); + } else { + ESP_LOGV(TAG, "Max frame length exceeded; kept %u bytes", this->buffer_pos_); + } } this->buffer_data_[this->buffer_pos_++] = readch; diff --git a/tests/components/ld2460/sensor/ld2460_readline.cpp b/tests/components/ld2460/sensor/ld2460_readline.cpp index 43144b09f31..86931a4a74e 100644 --- a/tests/components/ld2460/sensor/ld2460_readline.cpp +++ b/tests/components/ld2460/sensor/ld2460_readline.cpp @@ -180,4 +180,43 @@ TEST_F(LD2460ReadlineTest, DataCallbackOncePerValidFrame) { EXPECT_EQ(calls, 2); } +TEST_F(LD2460ReadlineTest, TruncatedPrefixBeforeLargeFrames) { + // Three targets still fit behind the truncated prefix; four and five overflow the buffer + const std::vector targets = {{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}}; + for (int n = 3; n <= MAX_TARGETS; n++) { + SCOPED_TRACE(n); + this->ld2460_.feed({0x00, 0xF4, 0xF3, 0xF2, 0xF1, 0x04}); + this->ld2460_.feed(make_periodic_frame(std::vector(targets.begin(), targets.begin() + n))); + EXPECT_FLOAT_EQ(this->count_.state, n); + EXPECT_NEAR(this->x_[n - 1].state, (2 * n - 1) * 0.1f, 0.001f); + } +} + +TEST_F(LD2460ReadlineTest, LostFooterTwoTargetResync) { + // A two-target frame missing its last byte; the next frame overflows the buffer + auto broken = make_periodic_frame({{10, 10}, {20, 20}}); + broken.pop_back(); + this->ld2460_.feed(broken); + this->ld2460_.feed(make_periodic_frame({{30, 40}, {50, 60}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->x_[1].state, 5.0f, 0.001f); + this->ld2460_.feed(make_periodic_frame({{70, 80}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 7.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, HeaderLikeGarbageThenMaxTargets) { + // Header first bytes and whole command headers in the noise do not hold back a real frame + this->ld2460_.feed(std::vector(70, 0xF4)); + this->ld2460_.feed(make_periodic_frame({{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 5); + std::vector garbage; + for (int i = 0; i < 40; i++) { + garbage.insert(garbage.end(), {0xFD, 0xFC, 0xFB, 0xFA}); + } + this->ld2460_.feed(garbage); + this->ld2460_.feed(make_periodic_frame({{11, 12}, {3, 4}, {5, 6}, {7, 8}, {9, 10}})); + EXPECT_NEAR(this->x_[0].state, 1.1f, 0.001f); +} + } // namespace esphome::ld2460::testing From a3d3dd4a510c7ac4a903981ad5dc4b08f3fc4e03 Mon Sep 17 00:00:00 2001 From: Cat-Ion Date: Mon, 19 Oct 2026 20:48:53 +0200 Subject: [PATCH 04/43] [combination] Remove @Cat-Ion from codeowners (#21133) Co-authored-by: Lynn Ochs Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/combination/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9f9956930d2..44eb9a27738 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -133,7 +133,7 @@ esphome/components/climate/* @esphome/core esphome/components/climate_ir/* @glmnet esphome/components/cm1106/* @andrewjswan esphome/components/color_temperature/* @jesserockz -esphome/components/combination/* @Cat-Ion @kahrendt +esphome/components/combination/* @kahrendt esphome/components/const/* @esphome/core esphome/components/coolix/* @glmnet esphome/components/copy/* @OttoWinter diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index a61d88e126e..180554a16ec 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType -CODEOWNERS = ["@Cat-Ion", "@kahrendt"] +CODEOWNERS = ["@kahrendt"] combination_ns = cg.esphome_ns.namespace("combination") From b537611bfa6408dc47c2de078623124caf29853d Mon Sep 17 00:00:00 2001 From: Daeho Ro <40587651+daeho-ro@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:15:54 +0900 Subject: [PATCH 05/43] [core] Support Python 3.15 (#21052) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfb304ef1b2..561a946cd26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,6 +313,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" os: - ubuntu-latest - macOS-latest diff --git a/pyproject.toml b/pyproject.toml index 3185fe0a9fd..4148571570d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.12.0,<3.15" +requires-python = ">=3.12.0,<3.16" dynamic = ["dependencies", "optional-dependencies", "version"] From 89a222b95a55bd21df0fc9b20239e31d8d81b743 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:32:21 -1000 Subject: [PATCH 06/43] Bump esptool from 5.5.0 to 5.6.0 (#21135) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7d48235966c..b45b36511e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.5.0 +esptool==5.6.0 click==8.5.0 aioesphomeapi==46.14.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 0b70f297c2796ebfaa7981b8ccdc6e521d4389df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:32:35 -1000 Subject: [PATCH 07/43] Bump github/codeql-action/init from 4.38.3 to 4.39.0 (#21140) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index caf8c6d8c8f..e66e7664ff1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 6d8e36cc97e684f3ede4bcb5530f67894d71d110 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:32:46 -1000 Subject: [PATCH 08/43] Bump esphome/workflows/.github/workflows/stale.yml from 4fbce13026c393d6eb6b247e394dcd9ef1af76c2 to 31d160e5f0ffa0ee65d5b27cb55c153d415f2abe (#21141) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1b0d478bd86..5146dd65eaf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main + uses: esphome/workflows/.github/workflows/stale.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From 0ab57126d8d13b86f91ddc0ae3e655dfc7aa25f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:32:54 -1000 Subject: [PATCH 09/43] Bump github/codeql-action/analyze from 4.38.3 to 4.39.0 (#21139) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e66e7664ff1..639abd37cae 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{matrix.language}}" From bc8bebfaba7c65e96e5be190b1727d34218f597a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:33:00 +0000 Subject: [PATCH 10/43] Bump filelock from 4.1.1 to 4.2.0 (#21138) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b45b36511e2..50c00ed4825 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.4 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.2.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From ee53f8ab1c335227540bc44d265e49acdc8183cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:33:07 -1000 Subject: [PATCH 11/43] Bump platformdirs from 4.13.0 to 4.13.3 (#21136) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 50c00ed4825..ddc1aadd754 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.13.0 # native esp-idf toolchain global cache dir +platformdirs==4.13.3 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From e3a7437bdda52826efe47d85741cf90277cdeb67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:34:30 -1000 Subject: [PATCH 12/43] Bump CodSpeedHQ/action from 5.4.2 to 5.5.0 (#21143) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 561a946cd26..9b4e0e6bc20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -630,7 +630,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 + uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0 with: run: | . venv/bin/activate From 7a88c73fac4e9476b206ac6ed395b5ba3e49031d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:36:22 -0400 Subject: [PATCH 13/43] [espota2] Stop the fake OTA device once the client is done (#21134) --- tests/unit_tests/test_espota2_noise.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index c316b55a9cf..a49eecf3719 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -95,21 +95,32 @@ class FakeEncryptedDevice(threading.Thread): self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) self.listener.listen(1) + # Poll accept() so the thread can stop once the client is done: how + # many connections a deadline-bound client makes depends on timing, + # so ``connections`` is an upper bound, not a requirement. + self.listener.settimeout(0.05) self.port = self.listener.getsockname()[1] + self._client_done = threading.Event() def run(self) -> None: try: - for _ in range(self.connections): - sock, _ = self.listener.accept() + served = 0 + while served < self.connections and not self._client_done.is_set(): + try: + sock, _ = self.listener.accept() + except TimeoutError: + continue sock.settimeout(10) with sock: self._serve(sock) + served += 1 except Exception as err: # noqa: BLE001 - surfaced via join_and_check self.error = err finally: self.listener.close() def join_and_check(self) -> None: + self._client_done.set() self.join(timeout=10) assert not self.is_alive(), "fake device did not finish" if self.error is not None: From 1b6a43a8a11c83573f502cb5c2ba71d72e78d04c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:59:35 -1000 Subject: [PATCH 14/43] Bump esphome/workflows/.github/workflows/lock.yml from 2026.9.0 to 2026.10.2 (#21142) --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 74ac5fe05a3..3f61c9add06 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0 + uses: esphome/workflows/.github/workflows/lock.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # 2026.10.2 From 65ba64b9a6b86b04a59ca50703c4e6e24ea3a8e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:59:49 -1000 Subject: [PATCH 15/43] Bump ruff from 0.17.0 to 0.17.1 (#21137) --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48bb3a69da4..c1ad0eb6948 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.17.0 + rev: v0.17.1 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index f0dd218adf2..9757d7310e6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.3 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.7.0 # used by script/sync_dependency_versions.py From 01524a2ffde8ddfab9b8d6d1e892408292227769 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:29:39 +0200 Subject: [PATCH 16/43] [ld2410] Wait for complete query parameter acknowledgements (#21145) --- esphome/components/ld2410/ld2410.cpp | 4 + .../fixtures/uart_mock_ld2410_query_ack.yaml | 124 +++++++++++++ .../test_uart_mock_ld2410_query_ack.py | 165 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml create mode 100644 tests/integration/test_uart_mock_ld2410_query_ack.py diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 7461a2ade40..74660d7f2e9 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -549,6 +549,10 @@ bool LD2410Component::handle_ack_data_() { break; case CMD_QUERY: { // Query parameters response + // Config values can contain the footer bytes, so wait for the whole 38 byte frame + if (this->buffer_pos_ < 38) { + return false; + } if (this->buffer_data_[10] != HEADER) return true; // value head=0xAA #ifdef USE_NUMBER diff --git a/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml b/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml new file mode 100644 index 00000000000..6776c0fde9e --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410_query_ack.yaml @@ -0,0 +1,124 @@ +esphome: + name: uart-mock-ld2410-query-ack + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Synthetic query parameters ACKs (command 0x61), built from the layout the component parses. +# They are not device captures. Layout (38 bytes): +# [0-3] header, [4-5] length 28, [6-7] 61 01, [8-9] 00 00, [10] AA, [11] gate count, +# [12] max move gate, [13] max still gate, [14-22] move thresholds, [23-31] still thresholds, +# [32-33] timeout (low, high), [34-37] footer +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + injections: + # Ordinary normal mode data frame, as in uart_mock_ld2410.yaml Phase 1 + # Moving 100cm energy 50, still 120cm energy 25, detection 300cm + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x0D, 0x00, + 0x02, 0xAA, + 0x03, + 0x64, 0x00, + 0x32, + 0x78, 0x00, + 0x19, + 0x2C, 0x01, + 0x00, + 0x55, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Seed: a whole ACK, so every byte the next ACK is read from holds a known value + # Max gates 8/8, move 50 50 40 30 20 15 15 15 15, still 0 0 40 40 40 40 15 15 15, timeout 5 + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x61, 0x01, 0x00, 0x00, + 0xAA, 0x08, 0x08, 0x08, + 0x32, 0x32, 0x28, 0x1E, 0x14, 0x0F, 0x0F, 0x0F, 0x0F, + 0x00, 0x00, 0x28, 0x28, 0x28, 0x28, 0x0F, 0x0F, 0x0F, + 0x05, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # Move thresholds of gates 0 to 3 are the footer bytes 04 03 02 01 + # Max gates 6/5, move 4 3 2 1 60 45 46 47 48, still 20 to 28, timeout 300 + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x61, 0x01, 0x00, 0x00, + 0xAA, 0x08, 0x06, 0x05, + 0x04, 0x03, 0x02, 0x01, 0x3C, 0x2D, 0x2E, 0x2F, 0x30, + 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x2C, 0x01, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2410: + id: ld2410_dev + uart_id: mock_uart + +sensor: + - platform: ld2410 + ld2410_id: ld2410_dev + moving_distance: + name: "Moving Distance" + still_distance: + name: "Still Distance" + moving_energy: + name: "Moving Energy" + still_energy: + name: "Still Energy" + detection_distance: + name: "Detection Distance" + +number: + - platform: ld2410 + ld2410_id: ld2410_dev + timeout: + name: "Timeout" + max_move_distance_gate: + name: "Max Move Distance Gate" + max_still_distance_gate: + name: "Max Still Distance Gate" + g0: + move_threshold: + name: "G0 Move Threshold" + still_threshold: + name: "G0 Still Threshold" + g4: + move_threshold: + name: "G4 Move Threshold" + still_threshold: + name: "G4 Still Threshold" + g8: + move_threshold: + name: "G8 Move Threshold" + still_threshold: + name: "G8 Still Threshold" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2410_query_ack.py b/tests/integration/test_uart_mock_ld2410_query_ack.py new file mode 100644 index 00000000000..a077b9aa16a --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410_query_ack.py @@ -0,0 +1,165 @@ +"""Integration test for the LD2410 query parameters ACK with mock UART. + +An ordinary data frame comes first, then two query ACKs. Move thresholds 4, 3, 2, 1 +put the command footer inside the ACK values. Every value must come from the whole +38 byte frame, not from bytes left by the previous frame. +The ACKs are synthetic, built from the layout the component parses, not device captures. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + SensorInfo, + SensorState, +) +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Number object_id: (value from the seed ACK, value from the ACK under test) +EXPECTED: dict[str, tuple[float, float]] = { + "max_move_distance_gate": (8.0, 6.0), + "max_still_distance_gate": (8.0, 5.0), + "timeout": (5.0, 300.0), + "g0_move_threshold": (50.0, 4.0), + "g0_still_threshold": (0.0, 20.0), + "g4_move_threshold": (20.0, 60.0), + "g4_still_threshold": (40.0, 24.0), + "g8_move_threshold": (15.0, 48.0), + "g8_still_threshold": (15.0, 28.0), +} + +# Sensor object_id: value from the data frame +SENSOR_EXPECTED: dict[str, float] = { + "moving_distance": 100.0, + "still_distance": 120.0, + "moving_energy": 50.0, + "still_energy": 25.0, + "detection_distance": 300.0, +} + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410_query_ack( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a data frame, then a query ACK with the footer bytes in its values.""" + 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() + ack_errors: list[str] = [] + waited_for_frame_end = loop.create_future() + + def line_callback(line: str) -> None: + if "[ld2410" not in line: + return + if "Invalid " in line: + ack_errors.append(line) + if "Ack Data incomplete" in line and not waited_for_frame_end.done(): + waited_for_frame_end.set_result(True) + + number_states: dict[str, list[float]] = {name: [] for name in EXPECTED} + sensor_states: dict[str, list[float]] = {name: [] for name in SENSOR_EXPECTED} + key_to_name: dict[int, str] = {} + sensor_key_to_name: dict[int, str] = {} + data_received = loop.create_future() + final_received = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, SensorState) and not state.missing_state: + if (name := sensor_key_to_name.get(state.key)) is None: + return + sensor_states[name].append(state.state) + if not data_received.done() and all(sensor_states.values()): + data_received.set_result(True) + return + if not isinstance(state, NumberState) or state.missing_state: + return + if (name := key_to_name.get(state.key)) is None: + return + number_states[name].append(state.state) + if not final_received.done() and all( + values and values[-1] == pytest.approx(EXPECTED[n][1]) + for n, values in number_states.items() + ): + final_received.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + # Map each kind separately; sensor names are substrings of number names + key_to_name.update( + build_key_to_entity_mapping( + [e for e in entities if isinstance(e, NumberInfo)], list(EXPECTED) + ) + ) + sensor_key_to_name.update( + build_key_to_entity_mapping( + [e for e in entities if isinstance(e, SensorInfo)], + list(SENSOR_EXPECTED), + ) + ) + assert set(key_to_name.values()) == set(EXPECTED), ( + f"Number entities missing, found: {sorted(key_to_name.values())}" + ) + assert set(sensor_key_to_name.values()) == set(SENSOR_EXPECTED), ( + f"Sensor entities missing, found: {sorted(sensor_key_to_name.values())}" + ) + + 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(data_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the data frame values. Received:\n" + f" sensor_states: {sensor_states}" + ) + + try: + await asyncio.wait_for(final_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the query ACK values. Received:\n" + f" number_states: {number_states}\n" + f" ack_errors: {ack_errors}" + ) + + # One data frame, so each sensor is published once + for name, value in SENSOR_EXPECTED.items(): + assert sensor_states[name] == pytest.approx([value]), name + # Each number is published once per ACK: the seed value, then the value under test + for name, (seed, value) in EXPECTED.items(): + assert number_states[name] == pytest.approx([seed, value]), name + assert waited_for_frame_end.done(), ( + "Expected the parser to wait past the footer bytes in the values" + ) + assert not ack_errors, f"Unexpected ACK errors: {ack_errors}" From c6eeeac7e857e78e17e3f30b73a3aa65a8cba832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:01:26 -1000 Subject: [PATCH 17/43] [copy] Use an Apply button for the copy button (#21162) --- esphome/components/copy/button/__init__.py | 27 +++++-------------- .../components/copy/button/copy_button.cpp | 12 --------- esphome/components/copy/button/copy_button.h | 19 ------------- tests/components/copy/common.yaml | 8 ++++++ 4 files changed, 15 insertions(+), 51 deletions(-) delete mode 100644 esphome/components/copy/button/copy_button.cpp delete mode 100644 esphome/components/copy/button/copy_button.h diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 17155b2f744..d374e234e6f 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -1,29 +1,18 @@ -import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ICON, - CONF_ID, CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType -from .. import copy_ns - -CopyButton = copy_ns.class_("CopyButton", button.Button, cg.Component) - - -CONFIG_SCHEMA = ( - button.button_schema(CopyButton) - .extend( - { - cv.Required(CONF_SOURCE_ID): cv.use_id(button.Button), - } - ) - .extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = button.button_schema(button.ApplyButton).extend( + { + cv.Required(CONF_SOURCE_ID): cv.use_id(button.Button), + } ) FINAL_VALIDATE_SCHEMA = cv.All( @@ -34,8 +23,6 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await button.register_button(var, config) - await cg.register_component(var, config) - - await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) + await button.sub_buttons(config, parent=config[CONF_SOURCE_ID]).create( + config, None, apply="press()" + ) diff --git a/esphome/components/copy/button/copy_button.cpp b/esphome/components/copy/button/copy_button.cpp deleted file mode 100644 index c9441500a96..00000000000 --- a/esphome/components/copy/button/copy_button.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "copy_button.h" -#include "esphome/core/log.h" - -namespace esphome::copy { - -ESPHOME_LOG_TAG(TAG, "copy.button"); - -void CopyButton::dump_config() { LOG_BUTTON("", "Copy Button", this); } - -void CopyButton::press_action() { source_->press(); } - -} // namespace esphome::copy diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h deleted file mode 100644 index bdefcc512a6..00000000000 --- a/esphome/components/copy/button/copy_button.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/button/button.h" - -namespace esphome::copy { - -class CopyButton final : public button::Button, public Component { - public: - void set_source(button::Button *source) { source_ = source; } - void dump_config() override; - - protected: - void press_action() override; - - button::Button *source_; -}; - -} // namespace esphome::copy diff --git a/tests/components/copy/common.yaml b/tests/components/copy/common.yaml index cbd056f0700..a3e7f7f13db 100644 --- a/tests/components/copy/common.yaml +++ b/tests/components/copy/common.yaml @@ -24,3 +24,11 @@ select: - platform: copy source_id: test_select name: Test Select Copy + +button: + - platform: template + id: copy_source_button + name: Source Button + - platform: copy + source_id: copy_source_button + name: Button Copy From 7a1ed1ce341d766a4e68143d32ce3be6ad52ba47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:01:51 -1000 Subject: [PATCH 18/43] [atm90e32] Use Apply buttons for the calibration buttons (#21163) --- esphome/components/atm90e32/atm90e32.cpp | 12 ++ esphome/components/atm90e32/atm90e32.h | 3 + .../components/atm90e32/button/__init__.py | 132 ++++++++---------- .../atm90e32/button/atm90e32_button.cpp | 77 ---------- .../atm90e32/button/atm90e32_button.h | 57 -------- 5 files changed, 73 insertions(+), 208 deletions(-) delete mode 100644 esphome/components/atm90e32/button/atm90e32_button.cpp delete mode 100644 esphome/components/atm90e32/button/atm90e32_button.h diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 9fb32e2f021..ab027645bc5 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -637,6 +637,18 @@ float ATM90E32Component::get_chip_temperature_() { return (float) ctemp; } +void ATM90E32Component::press_calibration_button(EntityBase *entity, void (ATM90E32Component::*action)(), + const LogString *hint, const LogString *hint2) { + ESP_LOGI(TAG, "%s", entity->get_name().c_str()); + if (hint != nullptr) { + ESP_LOGI(TAG, "%s", LOG_STR_ARG(hint)); + } + if (hint2 != nullptr) { + ESP_LOGI(TAG, "%s", LOG_STR_ARG(hint2)); + } + (this->*action)(); +} + void ATM90E32Component::run_gain_calibrations() { const char *cs = this->get_calibration_id_(); if (!this->enable_gain_calibration_) { diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index fe7d903962f..388d4e3ac47 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -139,6 +139,9 @@ class ATM90E32Component final : public PollingComponent, int16_t calibrate_offset(uint8_t phase, bool voltage); int16_t calibrate_power_offset(uint8_t phase, bool reactive); void run_gain_calibrations(); + // Body of the calibration buttons: logs the button name and the hint lines, then runs action. + void press_calibration_button(EntityBase *entity, void (ATM90E32Component::*action)(), + const LogString *hint = nullptr, const LogString *hint2 = nullptr); #ifdef USE_NUMBER void set_reference_voltage(uint8_t phase, number::Number *ref_voltage) { ref_voltages_[phase] = ref_voltage; } void set_reference_current(uint8_t phase, number::Number *ref_current) { ref_currents_[phase] = ref_current; } diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button/__init__.py index 274cce6adbb..cd252f3bf84 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button/__init__.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE from esphome.types import ConfigType -from .. import atm90e32_ns from ..sensor import ATM90E32Component CONF_RUN_GAIN_CALIBRATION = "run_gain_calibration" @@ -14,83 +13,68 @@ CONF_CLEAR_OFFSET_CALIBRATION = "clear_offset_calibration" CONF_RUN_POWER_OFFSET_CALIBRATION = "run_power_offset_calibration" CONF_CLEAR_POWER_OFFSET_CALIBRATION = "clear_power_offset_calibration" -ATM90E32GainCalibrationButton = atm90e32_ns.class_( - "ATM90E32GainCalibrationButton", button.Button -) -ATM90E32ClearGainCalibrationButton = atm90e32_ns.class_( - "ATM90E32ClearGainCalibrationButton", button.Button -) -ATM90E32OffsetCalibrationButton = atm90e32_ns.class_( - "ATM90E32OffsetCalibrationButton", button.Button -) -ATM90E32ClearOffsetCalibrationButton = atm90e32_ns.class_( - "ATM90E32ClearOffsetCalibrationButton", button.Button -) -ATM90E32PowerOffsetCalibrationButton = atm90e32_ns.class_( - "ATM90E32PowerOffsetCalibrationButton", button.Button -) -ATM90E32ClearPowerOffsetCalibrationButton = atm90e32_ns.class_( - "ATM90E32ClearPowerOffsetCalibrationButton", button.Button -) + +def _calibration_button(icon: str) -> cv.Schema: + return button.button_schema( + button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG, icon=icon + ) + CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.use_id(ATM90E32Component), - cv.Optional(CONF_RUN_GAIN_CALIBRATION): button.button_schema( - ATM90E32GainCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon="mdi:scale-balance", - ), - cv.Optional(CONF_CLEAR_GAIN_CALIBRATION): button.button_schema( - ATM90E32ClearGainCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon="mdi:delete", - ), - cv.Optional(CONF_RUN_OFFSET_CALIBRATION): button.button_schema( - ATM90E32OffsetCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon=ICON_SCALE, - ), - cv.Optional(CONF_CLEAR_OFFSET_CALIBRATION): button.button_schema( - ATM90E32ClearOffsetCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon="mdi:delete", - ), - cv.Optional(CONF_RUN_POWER_OFFSET_CALIBRATION): button.button_schema( - ATM90E32PowerOffsetCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon=ICON_SCALE, - ), - cv.Optional(CONF_CLEAR_POWER_OFFSET_CALIBRATION): button.button_schema( - ATM90E32ClearPowerOffsetCalibrationButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon="mdi:delete", - ), + cv.Optional(CONF_RUN_GAIN_CALIBRATION): _calibration_button("mdi:scale-balance"), + cv.Optional(CONF_CLEAR_GAIN_CALIBRATION): _calibration_button("mdi:delete"), + cv.Optional(CONF_RUN_OFFSET_CALIBRATION): _calibration_button(ICON_SCALE), + cv.Optional(CONF_CLEAR_OFFSET_CALIBRATION): _calibration_button("mdi:delete"), + cv.Optional(CONF_RUN_POWER_OFFSET_CALIBRATION): _calibration_button(ICON_SCALE), + cv.Optional(CONF_CLEAR_POWER_OFFSET_CALIBRATION): _calibration_button("mdi:delete"), } +def _press(action: str, hint: str | None = None, hint2: str | None = None) -> str: + """press_calibration_button() logs at most two hint lines.""" + args = "".join( + f", {cg.LogStringLiteral(h)}" for h in (hint, hint2) if h is not None + ) + return f"press_calibration_button(entity, &{ATM90E32Component}::{action}{args})" + + async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) - - if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION): - b = await button.new_button(run_gain) - await cg.register_parented(b, parent) - - if clear_gain := config.get(CONF_CLEAR_GAIN_CALIBRATION): - b = await button.new_button(clear_gain) - await cg.register_parented(b, parent) - - if run_offset := config.get(CONF_RUN_OFFSET_CALIBRATION): - b = await button.new_button(run_offset) - await cg.register_parented(b, parent) - - if clear_offset := config.get(CONF_CLEAR_OFFSET_CALIBRATION): - b = await button.new_button(clear_offset) - await cg.register_parented(b, parent) - - if run_power := config.get(CONF_RUN_POWER_OFFSET_CALIBRATION): - b = await button.new_button(run_power) - await cg.register_parented(b, parent) - - if clear_power := config.get(CONF_CLEAR_POWER_OFFSET_CALIBRATION): - b = await button.new_button(clear_power) - await cg.register_parented(b, parent) + buttons = button.sub_buttons(config, parent=config[CONF_ID]) + await buttons( + CONF_RUN_GAIN_CALIBRATION, + None, + apply=_press( + "run_gain_calibrations", + "[CALIBRATION] Use gain_ct: & gain_voltage: under each phase_x: in your config file to save these values", + ), + ) + await buttons( + CONF_CLEAR_GAIN_CALIBRATION, None, apply=_press("clear_gain_calibrations") + ) + await buttons( + CONF_RUN_OFFSET_CALIBRATION, + None, + apply=_press( + "run_offset_calibrations", + "[CALIBRATION] **NOTE: CTs and ACVs must be 0 during this process. USB power only**", + "[CALIBRATION] Use offset_voltage: & offset_current: under each phase_x: in your config file to save these values", + ), + ) + await buttons( + CONF_CLEAR_OFFSET_CALIBRATION, None, apply=_press("clear_offset_calibrations") + ) + await buttons( + CONF_RUN_POWER_OFFSET_CALIBRATION, + None, + apply=_press( + "run_power_offset_calibrations", + "[CALIBRATION] **NOTE: CTs must be 0 during this process. Voltage reference should be present**", + "[CALIBRATION] Use offset_active_power: & offset_reactive_power: under each phase_x: in your config file to save these values", + ), + ) + await buttons( + CONF_CLEAR_POWER_OFFSET_CALIBRATION, + None, + apply=_press("clear_power_offset_calibrations"), + ) diff --git a/esphome/components/atm90e32/button/atm90e32_button.cpp b/esphome/components/atm90e32/button/atm90e32_button.cpp deleted file mode 100644 index 03871495214..00000000000 --- a/esphome/components/atm90e32/button/atm90e32_button.cpp +++ /dev/null @@ -1,77 +0,0 @@ -#include "atm90e32_button.h" -#include "esphome/core/component.h" -#include "esphome/core/log.h" - -namespace esphome::atm90e32 { - -ESPHOME_LOG_TAG(TAG, "atm90e32.button"); - -void ATM90E32GainCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Gain Calibration button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - ESP_LOGI(TAG, - "[CALIBRATION] Use gain_ct: & gain_voltage: under each phase_x: in your config file to save these values"); - this->parent_->run_gain_calibrations(); -} - -void ATM90E32ClearGainCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Gain button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - this->parent_->clear_gain_calibrations(); -} - -void ATM90E32OffsetCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Offset Calibration button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - ESP_LOGI(TAG, "[CALIBRATION] **NOTE: CTs and ACVs must be 0 during this process. USB power only**"); - ESP_LOGI(TAG, "[CALIBRATION] Use offset_voltage: & offset_current: under each phase_x: in your config file to save " - "these values"); - this->parent_->run_offset_calibrations(); -} - -void ATM90E32ClearOffsetCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Offset button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - this->parent_->clear_offset_calibrations(); -} - -void ATM90E32PowerOffsetCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Power Calibration button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - ESP_LOGI(TAG, "[CALIBRATION] **NOTE: CTs must be 0 during this process. Voltage reference should be present**"); - ESP_LOGI(TAG, "[CALIBRATION] Use offset_active_power: & offset_reactive_power: under each phase_x: in your config " - "file to save these values"); - this->parent_->run_power_offset_calibrations(); -} - -void ATM90E32ClearPowerOffsetCalibrationButton::press_action() { - if (this->parent_ == nullptr) { - ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Power button [%s]", this->get_name().c_str()); - return; - } - - ESP_LOGI(TAG, "%s", this->get_name().c_str()); - this->parent_->clear_power_offset_calibrations(); -} - -} // namespace esphome::atm90e32 diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h deleted file mode 100644 index 988c6d5c167..00000000000 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/atm90e32/atm90e32.h" -#include "esphome/components/button/button.h" - -namespace esphome::atm90e32 { - -class ATM90E32GainCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32GainCalibrationButton() = default; - - protected: - void press_action() override; -}; - -class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32ClearGainCalibrationButton() = default; - - protected: - void press_action() override; -}; - -class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32OffsetCalibrationButton() = default; - - protected: - void press_action() override; -}; - -class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32ClearOffsetCalibrationButton() = default; - - protected: - void press_action() override; -}; - -class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32PowerOffsetCalibrationButton() = default; - - protected: - void press_action() override; -}; - -class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { - public: - ATM90E32ClearPowerOffsetCalibrationButton() = default; - - protected: - void press_action() override; -}; - -} // namespace esphome::atm90e32 From 984ecc9f3c36335823856030c0fe67a500f4316f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:02:19 -1000 Subject: [PATCH 19/43] [tests] Add wait_or_fail for integration test timeouts (#21152) --- tests/integration/README.md | 13 ++- tests/integration/wait_utils.py | 61 ++++++++++++ .../unit_tests/test_integration_wait_utils.py | 96 +++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 tests/integration/wait_utils.py create mode 100644 tests/unit_tests/test_integration_wait_utils.py diff --git a/tests/integration/README.md b/tests/integration/README.md index bee20409e80..553cb980942 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -405,12 +405,15 @@ async with run_compiled(yaml_config, line_callback=check_output): **Note**: Tests that monitor log messages typically have fewer race conditions compared to state-based testing, making them more reliable. However, be aware that the host platform currently does not have a thread-safe logger, so logging from threads will not work correctly. ##### Timeout Handling +Always use timeouts for async operations. `wait_or_fail` (`wait_utils.py`) awaits a future, +coroutine or `asyncio.Event` and fails the test with a message on timeout. Keyword arguments +are added to the message only on failure, so pass live collections; lists, tuples, deques and +mappings show their last 20 items. ```python -# Always use timeouts for async operations -try: - result = await asyncio.wait_for(some_future, timeout=5.0) -except asyncio.TimeoutError: - pytest.fail("Operation timed out - check test expectations") +from .wait_utils import wait_or_fail + +result = await wait_or_fail(some_future, 5.0, "no reply to the service call") +await wait_or_fail(done_event, 5.0, "sensor never updated", log_lines=log_lines) ``` #### 6. Common Assertions diff --git a/tests/integration/wait_utils.py b/tests/integration/wait_utils.py new file mode 100644 index 00000000000..6e3441a3c78 --- /dev/null +++ b/tests/integration/wait_utils.py @@ -0,0 +1,61 @@ +"""Helpers for waiting on futures and events in integration tests.""" + +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import Awaitable, Mapping + +import pytest + +# How many trailing items of a collection are shown in a failure message. +TAIL_ITEMS = 20 + + +def _format_context(context: dict[str, object]) -> str: + """Render context values for a failure message, keeping only the tail of collections.""" + parts: list[str] = [] + for name, value in context.items(): + if isinstance(value, Mapping): + items = [f"{key}: {item!r}" for key, item in value.items()] + elif isinstance(value, (list, tuple, deque)): + items = [str(item) for item in value] + else: + parts.append(f"{name}: {value!r}") + continue + tail = items[-TAIL_ITEMS:] + header = f"{name} (last {len(tail)} of {len(items)}):" + parts.append("\n ".join([header, *tail])) + return "".join(f"\n{part}" for part in parts) + + +async def wait_or_fail[T]( + aw: Awaitable[T] | asyncio.Event, + timeout: float = 5.0, + msg: str = "timed out", + **context: object, +) -> T: + """Await ``aw``, or wait for an ``asyncio.Event`` to be set, and fail the test on timeout. + + Behaves like ``asyncio.wait_for``, so a timed out future is cancelled; a TimeoutError raised + by ``aw`` itself propagates unchanged, and ``timeout`` must be positive. ``context`` values + are formatted only on failure, so pass live collections; lists, tuples, deques and mappings + show their last 20 items. + """ + if timeout <= 0: + # asyncio.wait_for treats this as already timed out; a test should never ask for it + if asyncio.iscoroutine(aw): + aw.close() + raise ValueError(f"wait_or_fail timeout must be positive, got {timeout}") + if isinstance(aw, asyncio.Event): + aw = aw.wait() + deadline = asyncio.timeout(timeout) + try: + async with deadline: + return await aw + except TimeoutError: + # A TimeoutError raised by aw itself is not this wait running out + if not deadline.expired(): + raise + # Failing outside the except block keeps the TimeoutError traceback out of the report. + pytest.fail(f"{msg} (after {timeout}s){_format_context(context)}") diff --git a/tests/unit_tests/test_integration_wait_utils.py b/tests/unit_tests/test_integration_wait_utils.py new file mode 100644 index 00000000000..694ae87aa9d --- /dev/null +++ b/tests/unit_tests/test_integration_wait_utils.py @@ -0,0 +1,96 @@ +"""Tests for the integration test wait helpers.""" + +from __future__ import annotations + +import asyncio +from collections import deque + +import pytest + +from tests.integration.wait_utils import wait_or_fail + + +@pytest.mark.asyncio +async def test_wait_or_fail_returns_result() -> None: + future: asyncio.Future[int] = asyncio.get_running_loop().create_future() + future.set_result(42) + assert await wait_or_fail(future) == 42 + + +@pytest.mark.asyncio +async def test_wait_or_fail_accepts_coroutine_and_event() -> None: + async def produce() -> str: + return "done" + + assert await wait_or_fail(produce()) == "done" + event = asyncio.Event() + asyncio.get_running_loop().call_soon(event.set) + assert await wait_or_fail(event) is True + + +@pytest.mark.asyncio +async def test_wait_or_fail_timeout_cancels_and_reports_context() -> None: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + lines = [f"line {i}" for i in range(25)] + seen: dict[str, int] = {} + waiting = asyncio.ensure_future( + wait_or_fail(future, 0.01, "no reply", lines=lines, seen=seen) + ) + # Context is formatted on failure, so later changes to live objects show up. + lines.append("late line") + seen["a"] = 1 + with pytest.raises(pytest.fail.Exception) as err: + await waiting + message = str(err.value) + assert message.startswith("no reply (after 0.01s)") + assert "lines (last 20 of 26):" in message + assert "line 5\n" not in message + assert "line 6\n" in message + assert message.endswith("late line\nseen (last 1 of 1):\n a: 1") + assert future.cancelled() + + +@pytest.mark.asyncio +async def test_wait_or_fail_trims_deques_and_mappings() -> None: + lines = deque(f"line {i}" for i in range(30)) + states = {f"key{i}": i for i in range(25)} + pairs = tuple(range(25)) + with pytest.raises(pytest.fail.Exception) as err: + await wait_or_fail( + asyncio.Event(), + 0.01, + "no state", + lines=lines, + states=states, + pairs=pairs, + ) + message = str(err.value) + assert "lines (last 20 of 30):\n line 10\n" in message + assert "states (last 20 of 25):\n key5: 5\n" in message + assert "key4: 4" not in message + assert "pairs (last 20 of 25):\n 5\n" in message + + +@pytest.mark.asyncio +async def test_wait_or_fail_reraises_a_timeout_from_the_awaitable() -> None: + async def inner() -> None: + raise TimeoutError("api call timed out") + + with pytest.raises(TimeoutError, match="api call timed out"): + await wait_or_fail(inner(), 5.0, "no reply") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [0, -1.0]) +async def test_wait_or_fail_rejects_a_non_positive_timeout(timeout: float) -> None: + event = asyncio.Event() + event.set() + done: asyncio.Future[int] = asyncio.get_running_loop().create_future() + done.set_result(1) + + async def finished() -> int: + return 1 + + for aw in (finished(), event, done): + with pytest.raises(ValueError, match="must be positive"): + await wait_or_fail(aw, timeout) From da0fed051766c0790df00dfed3bd1ac1862ee310 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:02:53 -1000 Subject: [PATCH 20/43] [dfrobot_sen0609][airton] Use Apply selects and switches for hub forwarding entities (#21147) --- esphome/components/airton/switch/__init__.py | 11 ++++++----- .../components/airton/switch/airton_switch.h | 19 ------------------- .../dfrobot_sen0609/select/__init__.py | 11 ++++++----- .../select/dfrobot_sen0609_select.h | 17 ----------------- .../dfrobot_sen0609/switch/__init__.py | 9 +++++---- .../switch/dfrobot_sen0609_switch.h | 18 ------------------ 6 files changed, 17 insertions(+), 68 deletions(-) delete mode 100644 esphome/components/airton/switch/airton_switch.h delete mode 100644 esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h delete mode 100644 esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h diff --git a/esphome/components/airton/switch/__init__.py b/esphome/components/airton/switch/__init__.py index 94cbb5c8c4a..88200891f26 100644 --- a/esphome/components/airton/switch/__init__.py +++ b/esphome/components/airton/switch/__init__.py @@ -8,19 +8,18 @@ from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns CODEOWNERS = ["@Procsiab"] -AirtonSwitch = airton_ns.class_("AirtonSwitch", switch.Switch) AirtonSetting = airton_ns.enum("AirtonSetting", is_class=True) CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), cv.Optional(CONF_DISPLAY): switch.switch_schema( - AirtonSwitch, + switch.ApplySwitch, entity_category=ENTITY_CATEGORY_CONFIG, default_restore_mode="DISABLED", ), cv.Optional(CONF_SLEEP_MODE): switch.switch_schema( - AirtonSwitch, + switch.ApplySwitch, entity_category=ENTITY_CATEGORY_CONFIG, default_restore_mode="DISABLED", ), @@ -32,10 +31,12 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AIRTON_ID]) switches = switch.sub_switches(config, parent=hub) await switches( - CONF_DISPLAY, hub.set_display_switch, AirtonSetting.AIRTON_SETTING_DISPLAY + CONF_DISPLAY, + hub.set_display_switch, + apply=f"set_setting({AirtonSetting.AIRTON_SETTING_DISPLAY}, state)", ) await switches( CONF_SLEEP_MODE, hub.set_sleep_mode_switch, - AirtonSetting.AIRTON_SETTING_SLEEP_MODE, + apply=f"set_setting({AirtonSetting.AIRTON_SETTING_SLEEP_MODE}, state)", ) diff --git a/esphome/components/airton/switch/airton_switch.h b/esphome/components/airton/switch/airton_switch.h deleted file mode 100644 index d887bacad64..00000000000 --- a/esphome/components/airton/switch/airton_switch.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/components/switch/switch.h" -#include "../airton.h" - -namespace esphome::airton { - -// One of the unit's on/off settings; the parent sends the state and publishes it back -class AirtonSwitch : public switch_::Switch, public Parented { - public: - explicit AirtonSwitch(AirtonSetting setting) : setting_(setting) {} - - protected: - void write_state(bool state) override { this->parent_->set_setting(this->setting_, state); } - - AirtonSetting setting_; -}; - -} // namespace esphome::airton diff --git a/esphome/components/dfrobot_sen0609/select/__init__.py b/esphome/components/dfrobot_sen0609/select/__init__.py index a68ed8c8729..6f1ff32888f 100644 --- a/esphome/components/dfrobot_sen0609/select/__init__.py +++ b/esphome/components/dfrobot_sen0609/select/__init__.py @@ -5,15 +5,13 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE from esphome.types import ConfigType -from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns - -OutPinLevelSelect = dfrobot_sen0609_ns.class_("OutPinLevelSelect", select.Select) +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema( - OutPinLevelSelect, + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_SCALE, ), @@ -25,5 +23,8 @@ async def to_code(config: ConfigType) -> None: selects = select.sub_selects(config, parent=hub) # The level the OUT pin has while a target is present; the index is the module's value await selects( - CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + apply="control_out_pin_level(index)", ) diff --git a/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h deleted file mode 100644 index 0ea2fdd8974..00000000000 --- a/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/select/select.h" -#include "../dfrobot_sen0609.h" - -namespace esphome::dfrobot_sen0609 { - -class OutPinLevelSelect final : public select::Select, public Parented { - public: - // User provided, not "= default": `new(p) OutPinLevelSelect()` would zero-fill .bss that is already zero. - OutPinLevelSelect() {} - - protected: - void control(size_t index) override { this->parent_->control_out_pin_level(index); } -}; - -} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/switch/__init__.py b/esphome/components/dfrobot_sen0609/switch/__init__.py index 3baa9656f4c..c89a9703ef1 100644 --- a/esphome/components/dfrobot_sen0609/switch/__init__.py +++ b/esphome/components/dfrobot_sen0609/switch/__init__.py @@ -18,7 +18,6 @@ from .. import ( final_validate_mode, ) -DFRobotSEN0609Switch = dfrobot_sen0609_ns.class_("DFRobotSEN0609Switch", switch.Switch) SwitchSetting = dfrobot_sen0609_ns.enum("SwitchSetting") CONF_MICRO_MOTION = "micro_motion" @@ -29,7 +28,7 @@ CONF_RUN_LED = "run_led" def _led() -> cv.Schema: # The module forgets the LED state at power off, so it is restored from here return switch.switch_schema( - DFRobotSEN0609Switch, + switch.ApplySwitch, default_restore_mode="RESTORE_DEFAULT_ON", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, @@ -41,7 +40,7 @@ CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), cv.Optional(CONF_MICRO_MOTION): switch.switch_schema( - DFRobotSEN0609Switch, + switch.ApplySwitch, device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, @@ -64,5 +63,7 @@ async def to_code(config: ConfigType) -> None: (CONF_OUT_LED, SwitchSetting.SWITCH_OUT_LED), ): await switches( - key, lambda s, setting=setting: hub.set_switch(setting, s), setting + key, + lambda s, setting=setting: hub.set_switch(setting, s), + apply=f"control_switch({setting}, state)", ) diff --git a/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h deleted file mode 100644 index dc2def0d809..00000000000 --- a/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/components/switch/switch.h" -#include "../dfrobot_sen0609.h" - -namespace esphome::dfrobot_sen0609 { - -class DFRobotSEN0609Switch final : public switch_::Switch, public Parented { - public: - explicit DFRobotSEN0609Switch(SwitchSetting setting) : setting_(setting) {} - - protected: - void write_state(bool state) override { this->parent_->control_switch(this->setting_, state); } - - SwitchSetting setting_; -}; - -} // namespace esphome::dfrobot_sen0609 From b65395174c93ec2490c23cce02f1a19632c674b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:04:05 -1000 Subject: [PATCH 21/43] [core] Log the build time at boot straight from flash (#21118) --- esphome/core/application.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d9f29259d03..56138535a38 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -159,9 +159,8 @@ void Application::setup() { void Application::process_dump_config_() { if (this->dump_config_at_ == 0) { - char build_time_str[Application::BUILD_TIME_STR_SIZE]; - this->get_build_time_string(build_time_str); - ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", build_time_str); + // %s reads flash on ESP8266, so the build time needs no RAM copy + ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", ESPHOME_BUILD_TIME_STR); #ifdef ESPHOME_PROJECT_NAME ESP_LOGI(TAG, "Project " ESPHOME_PROJECT_NAME " version " ESPHOME_PROJECT_VERSION); #endif From 0a501b7ef5816b318d1a47f810af48cd66c3a435 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 14:04:39 -1000 Subject: [PATCH 22/43] [core] Pass the LOG_PIN prefix to the log straight from flash (#21117) --- esphome/core/gpio.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/core/gpio.cpp b/esphome/core/gpio.cpp index 21e88b5b6d8..ae1b69517c8 100644 --- a/esphome/core/gpio.cpp +++ b/esphome/core/gpio.cpp @@ -7,11 +7,8 @@ namespace esphome { void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin) { if (pin == nullptr) return; - static constexpr size_t LOG_PIN_PREFIX_MAX_LEN = 32; - char prefix_buf[LOG_PIN_PREFIX_MAX_LEN]; - strncpy_P(prefix_buf, reinterpret_cast(prefix), sizeof(prefix_buf) - 1); - prefix_buf[sizeof(prefix_buf) - 1] = '\0'; - log_pin_with_prefix(tag, prefix_buf, pin); + // %s reads flash on ESP8266, so the prefix needs no RAM copy + log_pin_with_prefix(tag, reinterpret_cast(prefix), pin); } #else void log_pin(const char *tag, const char *prefix, GPIOPin *pin) { From 68df9248ed156720a671c7d35601698f04f94fd3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:11:27 +1300 Subject: [PATCH 23/43] [sx126x][sx127x] Move CONF_RST_PIN to the shared component constants (#21174) --- esphome/components/const/__init__.py | 1 + esphome/components/sx126x/__init__.py | 3 +-- esphome/components/sx127x/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 06e8d80103f..b4d07ef1999 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -65,6 +65,7 @@ CONF_REMOTE_ID = "remote_id" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" +CONF_RST_PIN = "rst_pin" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 2c14ea0bd56..79798a73cce 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -3,7 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi -from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET, CONF_RST_PIN import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod @@ -34,7 +34,6 @@ CONF_PA_RAMP = "pa_ramp" CONF_PAYLOAD_LENGTH = "payload_length" CONF_PREAMBLE_DETECT = "preamble_detect" CONF_PREAMBLE_SIZE = "preamble_size" -CONF_RST_PIN = "rst_pin" CONF_RX_START = "rx_start" CONF_RF_SWITCH = "rf_switch" CONF_SHAPING = "shaping" diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 49e68c61844..65d3ada470b 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -3,7 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi -from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET, CONF_RST_PIN import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID @@ -34,7 +34,6 @@ CONF_PREAMBLE_DETECT = "preamble_detect" CONF_PREAMBLE_ERRORS = "preamble_errors" CONF_PREAMBLE_POLARITY = "preamble_polarity" CONF_PREAMBLE_SIZE = "preamble_size" -CONF_RST_PIN = "rst_pin" CONF_RX_FLOOR = "rx_floor" CONF_RX_START = "rx_start" CONF_SHAPING = "shaping" From 5bccdab01d8c16dcc139abc23e33d01d8336f0a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 17:29:11 -1000 Subject: [PATCH 24/43] [remote_base] Add helpers to send and read IR frame bytes bit by bit (#21167) --- esphome/components/remote_base/remote_base.h | 38 ++++++ .../remote_base/byte_items_test.cpp | 126 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/components/remote_base/byte_items_test.cpp diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 783343c5a20..6417a0c806a 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -19,6 +19,19 @@ enum ToleranceMode : uint8_t { using RawTimings = std::vector; +enum class BitOrder : uint8_t { + BIT_ORDER_LSB_FIRST, + BIT_ORDER_MSB_FIRST, +}; + +/// A bit is bit_mark followed by one_space or zero_space. +struct BitTimings { + uint32_t bit_mark; + uint32_t one_space; + uint32_t zero_space; + BitOrder bit_order{BitOrder::BIT_ORDER_LSB_FIRST}; +}; + class RemoteTransmitData { public: void mark(uint32_t length) { this->data_.push_back(length); } @@ -28,6 +41,15 @@ class RemoteTransmitData { this->space(space); } void reserve(uint32_t len) { this->data_.reserve(len); } + /// One item per bit of each byte. + template void item_bytes(const uint8_t *bytes, size_t len) { + for (size_t i = 0; i < len; i++) { + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); + this->item(T.bit_mark, (bytes[i] & mask) ? T.one_space : T.zero_space); + } + } + } void set_carrier_frequency(uint32_t carrier_frequency) { this->carrier_frequency_ = carrier_frequency; } uint32_t get_carrier_frequency() const { return this->carrier_frequency_; } const RawTimings &get_data() const { return this->data_; } @@ -77,6 +99,22 @@ class RemoteReceiveData { bool expect_space(uint32_t length); bool expect_item(uint32_t mark, uint32_t space); bool expect_pulse_with_gap(uint32_t mark, uint32_t space); + /// False at the first item matching neither space, leaving it unread. + template bool expect_bytes(uint8_t *out, size_t len) { + for (size_t i = 0; i < len; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); + if (this->expect_item(T.bit_mark, T.one_space)) { + byte |= mask; + } else if (!this->expect_item(T.bit_mark, T.zero_space)) { + return false; + } + } + out[i] = byte; + } + return true; + } void advance(uint32_t amount = 1) { this->index_ += amount; } void reset() { this->index_ = 0; } diff --git a/tests/components/remote_base/byte_items_test.cpp b/tests/components/remote_base/byte_items_test.cpp new file mode 100644 index 00000000000..b4f40638478 --- /dev/null +++ b/tests/components/remote_base/byte_items_test.cpp @@ -0,0 +1,126 @@ +#include +#include +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_MARK = 500; +constexpr uint32_t ONE_SPACE = 1500; +constexpr uint32_t ZERO_SPACE = 400; +constexpr BitTimings TIMINGS{.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE}; +constexpr BitTimings MSB_TIMINGS{ + .bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE, .bit_order = BitOrder::BIT_ORDER_MSB_FIRST}; +constexpr std::array FRAME = {0xA5, 0x01, 0x80, 0xFF}; + +RemoteReceiveData receive(const RawTimings &timings) { + return RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE); +} + +RawTimings expected_items(uint8_t byte, bool msb_first) { + RawTimings out; + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = msb_first ? (0x80 >> bit) : (1 << bit); + out.push_back(BIT_MARK); + out.push_back(-static_cast((byte & mask) ? ONE_SPACE : ZERO_SPACE)); + } + return out; +} + +} // namespace + +TEST(RemoteBaseByteItemsTest, LsbItemSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x4B; + tx.item_bytes(&byte, 1); + EXPECT_EQ(tx.get_data(), expected_items(byte, false)); +} + +TEST(RemoteBaseByteItemsTest, MsbItemSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x4B; + tx.item_bytes(&byte, 1); + EXPECT_EQ(tx.get_data(), expected_items(byte, true)); +} + +TEST(RemoteBaseByteItemsTest, AppendsAfterExistingItems) { + RemoteTransmitData tx; + tx.item(9000, 4500); + tx.item_bytes(FRAME.data(), FRAME.size()); + tx.mark(BIT_MARK); + ASSERT_EQ(tx.get_data().size(), 2 + FRAME.size() * 16 + 1); + EXPECT_EQ(tx.get_data()[0], 9000); + EXPECT_EQ(tx.get_data()[1], -4500); + EXPECT_EQ(tx.get_data().back(), static_cast(BIT_MARK)); +} + +TEST(RemoteBaseByteItemsTest, LsbRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); + EXPECT_FALSE(rx.is_valid()); +} + +TEST(RemoteBaseByteItemsTest, MsbRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); +} + +TEST(RemoteBaseByteItemsTest, BitOrderMismatchReversesBits) { + RemoteTransmitData tx; + const uint8_t byte = 0x01; + tx.item_bytes(&byte, 1); + auto rx = receive(tx.get_data()); + uint8_t out = 0; + ASSERT_TRUE(rx.expect_bytes(&out, 1)); + EXPECT_EQ(out, 0x80); +} + +TEST(RemoteBaseByteItemsTest, FailsMidByteAndStopsThere) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + RawTimings timings = tx.get_data(); + // Bit 3 of byte 1 gets a space that matches neither length + const size_t bad = (8 + 3) * 2 + 1; + timings[bad] = -3000; + auto rx = receive(timings); + std::array out = {0x11, 0x22, 0x33, 0x44}; + EXPECT_FALSE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(rx.get_index(), bad - 1); + // Byte 0 was read in full, the partial byte and the rest are untouched + EXPECT_EQ(out[0], FRAME[0]); + EXPECT_EQ(out[1], 0x22); + EXPECT_EQ(out[2], 0x33); +} + +TEST(RemoteBaseByteItemsTest, FailsWhenDataRunsOut) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), 1); + auto rx = receive(tx.get_data()); + std::array out{}; + EXPECT_FALSE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out[0], FRAME[0]); +} + +TEST(RemoteBaseByteItemsTest, ZeroLength) { + RemoteTransmitData tx; + tx.item_bytes(nullptr, 0); + tx.item_bytes(nullptr, 0); + EXPECT_TRUE(tx.get_data().empty()); + + const RawTimings empty; + auto rx = receive(empty); + EXPECT_TRUE(rx.expect_bytes(nullptr, 0)); + EXPECT_TRUE(rx.expect_bytes(nullptr, 0)); + EXPECT_EQ(rx.get_index(), 0u); +} + +} // namespace esphome::remote_base::testing From 91f7b0d1475a7856b8929bfe4048737f215d6cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 17:34:00 -1000 Subject: [PATCH 25/43] [tests] Use wait_or_fail in the sensor, text, loop and host integration tests (#21164) --- .../test_host_logger_thread_safety.py | 14 +-- .../integration/test_host_mode_batch_delay.py | 14 +-- .../test_host_mode_climate_control.py | 8 +- .../test_host_mode_empty_string_options.py | 15 +-- .../test_host_mode_entity_fields.py | 6 +- tests/integration/test_host_mode_sensor.py | 14 +-- tests/integration/test_host_preferences.py | 29 +++-- .../test_host_preferences_suspend_resume.py | 40 +++---- .../test_loop_interval_decoupling.py | 10 +- ...oop_interval_default_not_pulled_forward.py | 8 +- .../integration/test_sensor_filters_delta.py | 58 +++++----- .../test_sensor_filters_ring_buffer.py | 18 ++- .../test_sensor_filters_sliding_window.py | 59 +++++----- .../test_sensor_filters_value_list.py | 85 ++++++++------- tests/integration/test_sensor_raw_state.py | 6 +- .../integration/test_sensor_timeout_filter.py | 29 +++-- .../test_sensor_timeout_throttle_filter.py | 8 +- .../integration/test_text_sensor_raw_state.py | 103 ++++++++---------- 18 files changed, 241 insertions(+), 283 deletions(-) diff --git a/tests/integration/test_host_logger_thread_safety.py b/tests/integration/test_host_logger_thread_safety.py index 922ce001559..d0ff19d6385 100644 --- a/tests/integration/test_host_logger_thread_safety.py +++ b/tests/integration/test_host_logger_thread_safety.py @@ -12,6 +12,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail # Expected pattern for log messages from threads # Format: THREADn_MSGnnn_LEVEL_MESSAGE_WITH_DATA_xxxxxxxx @@ -72,13 +73,12 @@ async def test_host_logger_thread_safety( client.button_command(button_key) # Wait for test to complete (with timeout) - try: - await asyncio.wait_for(test_complete_event.wait(), timeout=30.0) - except TimeoutError: - pytest.fail( - "Test did not complete within timeout. " - f"Collected {len(collected_lines)} lines." - ) + await wait_or_fail( + test_complete_event, + 30.0, + "Test did not complete within timeout", + collected_lines=collected_lines, + ) # Give a bit more time for any remaining buffered messages await asyncio.sleep(0.5) diff --git a/tests/integration/test_host_mode_batch_delay.py b/tests/integration/test_host_mode_batch_delay.py index a3f666fa218..0cccd8504ee 100644 --- a/tests/integration/test_host_mode_batch_delay.py +++ b/tests/integration/test_host_mode_batch_delay.py @@ -9,6 +9,7 @@ from aioesphomeapi import EntityState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -42,13 +43,12 @@ async def test_host_mode_batch_delay( client.subscribe_states(on_state) # Wait for states from all entities with timeout - try: - entity_count = await asyncio.wait_for(entity_count_future, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Did not receive states from at least 7 entities within 5 seconds. " - f"Received {len(states)} states" - ) + entity_count = await wait_or_fail( + entity_count_future, + 5.0, + "Did not receive states from at least 7 entities within 5 seconds", + states=states, + ) # Verify we received all states assert entity_count >= 7, f"Expected at least 7 entities, got {entity_count}" diff --git a/tests/integration/test_host_mode_climate_control.py b/tests/integration/test_host_mode_climate_control.py index 96d15dfae03..5f0c29080b6 100644 --- a/tests/integration/test_host_mode_climate_control.py +++ b/tests/integration/test_host_mode_climate_control.py @@ -10,6 +10,7 @@ import pytest from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -65,10 +66,9 @@ async def test_host_mode_climate_control( target_temperature_high=26.5, ) - try: - climate_state = await asyncio.wait_for(climate_future, timeout=5.0) - except TimeoutError: - pytest.fail("Climate state not received within 5 seconds") + climate_state = await wait_or_fail( + climate_future, 5.0, "Climate state not received within 5 seconds" + ) assert isinstance(climate_state, aioesphomeapi.ClimateState) assert climate_state.mode == ClimateMode.HEAT diff --git a/tests/integration/test_host_mode_empty_string_options.py b/tests/integration/test_host_mode_empty_string_options.py index 1180ce75fca..93ae630ebfe 100644 --- a/tests/integration/test_host_mode_empty_string_options.py +++ b/tests/integration/test_host_mode_empty_string_options.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityState, SelectInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -111,13 +112,13 @@ async def test_host_mode_empty_string_options( client.subscribe_states(on_state) # Wait for initial states with timeout - try: - await asyncio.wait_for(states_received_future, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Did not receive states for all select entities. " - f"Expected keys: {expected_select_keys}, Received: {received_select_keys}" - ) + await wait_or_fail( + states_received_future, + 5.0, + "Did not receive states for all select entities", + expected_select_keys=expected_select_keys, + received_select_keys=received_select_keys, + ) # Verify we received states for all select entities assert empty_first.key in states diff --git a/tests/integration/test_host_mode_entity_fields.py b/tests/integration/test_host_mode_entity_fields.py index 5ec1b64a994..8e531461c0c 100644 --- a/tests/integration/test_host_mode_entity_fields.py +++ b/tests/integration/test_host_mode_entity_fields.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityCategory, EntityState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -84,10 +85,7 @@ async def test_host_mode_entity_fields( client.subscribe_states(on_state) # Wait for at least one state - try: - await asyncio.wait_for(state_received.wait(), timeout=5.0) - except TimeoutError: - pytest.fail("No states received within 5 seconds") + await wait_or_fail(state_received, 5.0, "No states received within 5 seconds") # Verify we received states (which means has_state flag is working) assert len(states) > 0, "No states received - has_state flag may not be working" diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index e28d3419e67..a338407b679 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -9,6 +9,7 @@ from aioesphomeapi import EntityState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -38,13 +39,12 @@ async def test_host_mode_with_sensor( client.subscribe_states(on_state) # Wait for sensor with specific value (42.0) with timeout - try: - test_sensor_state = await asyncio.wait_for(sensor_future, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Sensor with value 42.0 not received within 5 seconds. " - f"Received states: {list(states.values())}" - ) + test_sensor_state = await wait_or_fail( + sensor_future, + 5.0, + "Sensor with value 42.0 not received within 5 seconds", + states=states, + ) # Verify the sensor state assert test_sensor_state.state == 42.0 diff --git a/tests/integration/test_host_preferences.py b/tests/integration/test_host_preferences.py index 38c6460cf1b..7f19ed69459 100644 --- a/tests/integration/test_host_preferences.py +++ b/tests/integration/test_host_preferences.py @@ -10,6 +10,7 @@ from aioesphomeapi import ButtonInfo, EntityInfo, NumberInfo, SwitchInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail def find_entity_by_name( @@ -104,10 +105,9 @@ async def test_host_preferences_save_load( client.button_command(save_button.key) # Wait for save to complete - try: - await asyncio.wait_for(preferences_saved, timeout=5.0) - except TimeoutError: - pytest.fail("Preferences not saved within timeout") + await wait_or_fail( + preferences_saved, 5.0, "Preferences not saved within timeout" + ) # Verify we saved the expected values assert "switch" in saved_values, f"Switch preference not saved: {saved_values}" @@ -127,10 +127,9 @@ async def test_host_preferences_save_load( client.button_command(load_button.key) # Wait for load to complete - try: - await asyncio.wait_for(preferences_loaded, timeout=5.0) - except TimeoutError: - pytest.fail("Preferences not loaded within timeout") + await wait_or_fail( + preferences_loaded, 5.0, "Preferences not loaded within timeout" + ) # Verify loaded values match saved values assert "switch" in loaded_values, ( @@ -150,10 +149,9 @@ async def test_host_preferences_save_load( client.button_command(verify_button.key) # Wait for verification - try: - await asyncio.wait_for(values_match, timeout=5.0) - except TimeoutError: - pytest.fail("Preference verification failed within timeout") + await wait_or_fail( + values_match, 5.0, "Preference verification failed within timeout" + ) # Test that non-existent preferences don't crash (tests our fix) # This will trigger load attempts for keys that don't exist @@ -161,7 +159,6 @@ async def test_host_preferences_save_load( client.button_command(load_button.key) # Wait for the final load test to complete - try: - await asyncio.wait_for(final_load_complete, timeout=5.0) - except TimeoutError: - pytest.fail("Final load test did not complete within timeout") + await wait_or_fail( + final_load_complete, 5.0, "Final load test did not complete within timeout" + ) diff --git a/tests/integration/test_host_preferences_suspend_resume.py b/tests/integration/test_host_preferences_suspend_resume.py index 5f08d5519ed..94224bf4787 100644 --- a/tests/integration/test_host_preferences_suspend_resume.py +++ b/tests/integration/test_host_preferences_suspend_resume.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable from pathlib import Path import re from typing import Any @@ -12,6 +11,7 @@ from aioesphomeapi import ButtonInfo, EntityInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail DEVICE_NAME = "test_suspend_resume_device" @@ -25,16 +25,6 @@ def find_entity_by_name( ) -async def _wait_for( - awaitable: Awaitable[Any], message: str, timeout: float = 5.0 -) -> None: - """Await a future or coroutine, failing the test with a clear message on timeout.""" - try: - await asyncio.wait_for(awaitable, timeout=timeout) - except TimeoutError: - pytest.fail(message) - - async def _poll_until_exists(path: Path) -> None: """Poll for a file to appear, rather than guessing a sleep duration.""" while not path.exists(): @@ -84,28 +74,30 @@ async def test_host_preferences_suspend_resume( # --- Positive control: a running syncer flushes to disk. Without this, # the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). --- client.button_command(save_button.key) - await _wait_for( - saved_in_memory, "Preference was not saved to memory within timeout" + await wait_or_fail( + saved_in_memory, 5.0, "Preference was not saved to memory within timeout" ) - await _wait_for( + await wait_or_fail( _poll_until_exists(pref_file), + 10.0, "Running syncer never flushed to disk; positive control failed", - timeout=10.0, ) saved_in_memory = loop.create_future() # --- Suspend: a suspended syncer must not flush. --- client.button_command(suspend_button.key) - await _wait_for( - syncer_suspended, "Syncer suspend command was not processed within timeout" + await wait_or_fail( + syncer_suspended, + 5.0, + "Syncer suspend command was not processed within timeout", ) # Delete only after suspend is confirmed: the poller is now stopped, so # nothing can recreate the file before the negative assertion below. pref_file.unlink() client.button_command(save_button.key) - await _wait_for( - saved_in_memory, "Preference was not saved to memory within timeout" + await wait_or_fail( + saved_in_memory, 5.0, "Preference was not saved to memory within timeout" ) # Wait well past flash_write_interval (1s): a running syncer would @@ -118,11 +110,13 @@ async def test_host_preferences_suspend_resume( # --- Resume: flushing must restart. --- client.button_command(resume_button.key) - await _wait_for( - syncer_resumed, "Syncer resume command was not processed within timeout" + await wait_or_fail( + syncer_resumed, + 5.0, + "Syncer resume command was not processed within timeout", ) - await _wait_for( + await wait_or_fail( _poll_until_exists(pref_file), + 10.0, "Resumed syncer never flushed to disk; component.resume did not restart the poller", - timeout=10.0, ) diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py index 6c34aed4588..725119fefad 100644 --- a/tests/integration/test_loop_interval_decoupling.py +++ b/tests/integration/test_loop_interval_decoupling.py @@ -22,6 +22,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -47,12 +48,9 @@ async def test_loop_interval_decoupling( assert device_info is not None assert device_info.name == "loop-interval-decouple" - try: - loop_delta, sched_delta = await asyncio.wait_for( - measurement_done, timeout=10.0 - ) - except TimeoutError: - pytest.fail("MEASUREMENT_DONE marker never appeared") + loop_delta, sched_delta = await wait_or_fail( + measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared" + ) # Observation window = 2s, loop_interval_ = 500ms. # Component phase should fire ~4 times in 2s. The upper bound must be diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py index 17a7070436e..cb6d22473f8 100644 --- a/tests/integration/test_loop_interval_default_not_pulled_forward.py +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -22,6 +22,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -47,10 +48,9 @@ async def test_loop_interval_default_not_pulled_forward( assert device_info is not None assert device_info.name == "loop-default-not-pulled" - try: - loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0) - except TimeoutError: - pytest.fail("MEASUREMENT_DONE marker never appeared") + loop_delta = await wait_or_fail( + measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared" + ) # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → # ~125 component-phase iterations expected. diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index af8f314f49d..35f9669665a 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -10,6 +10,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -136,10 +137,9 @@ async def test_sensor_filters_delta( # Test 1: Min sensor_values["filter_min"].clear() client.button_command(buttons["filter_min"]) - try: - await asyncio.wait_for(filter_min_done, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 1 timed out. Values: {sensor_values['filter_min']}") + await wait_or_fail( + filter_min_done, 2.0, "Test 1 timed out", values=sensor_values["filter_min"] + ) expected = [1.0, 12.0, -2.0] assert sensor_values["filter_min"] == pytest.approx(expected), ( @@ -149,10 +149,9 @@ async def test_sensor_filters_delta( # Test 2: Max sensor_values["filter_max"].clear() client.button_command(buttons["filter_max"]) - try: - await asyncio.wait_for(filter_max_done, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 2 timed out. Values: {sensor_values['filter_max']}") + await wait_or_fail( + filter_max_done, 2.0, "Test 2 timed out", values=sensor_values["filter_max"] + ) expected = [1.0, 5.0, 10.0] assert sensor_values["filter_max"] == pytest.approx(expected), ( @@ -162,12 +161,12 @@ async def test_sensor_filters_delta( # Test 3: Baseline Max sensor_values["filter_baseline_max"].clear() client.button_command(buttons["filter_baseline_max"]) - try: - await asyncio.wait_for(filter_baseline_max_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 3 timed out. Values: {sensor_values['filter_baseline_max']}" - ) + await wait_or_fail( + filter_baseline_max_done, + 2.0, + "Test 3 timed out", + values=sensor_values["filter_baseline_max"], + ) expected = [1.0, 2.0, 3.0, 20.0] assert sensor_values["filter_baseline_max"] == pytest.approx(expected), ( @@ -177,12 +176,12 @@ async def test_sensor_filters_delta( # Test 4: Zero Delta sensor_values["filter_zero_delta"].clear() client.button_command(buttons["filter_zero_delta"]) - try: - await asyncio.wait_for(filter_zero_delta_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 4 timed out. Values: {sensor_values['filter_zero_delta']}" - ) + await wait_or_fail( + filter_zero_delta_done, + 2.0, + "Test 4 timed out", + values=sensor_values["filter_zero_delta"], + ) expected = [1.0, 2.0] assert sensor_values["filter_zero_delta"] == pytest.approx(expected), ( @@ -192,12 +191,12 @@ async def test_sensor_filters_delta( # Test 5: Percentage (delta: 50%) sensor_values["filter_percentage"].clear() client.button_command(buttons["filter_percentage"]) - try: - await asyncio.wait_for(filter_percentage_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 5 timed out. Values: {sensor_values['filter_percentage']}" - ) + await wait_or_fail( + filter_percentage_done, + 2.0, + "Test 5 timed out", + values=sensor_values["filter_percentage"], + ) expected = [100.0, 160.0, 250.0] assert sensor_values["filter_percentage"] == pytest.approx(expected), ( @@ -207,10 +206,9 @@ async def test_sensor_filters_delta( # Test 6: NaN passes through once, then is suppressed sensor_values["filter_nan"].clear() client.button_command(buttons["filter_nan"]) - try: - await asyncio.wait_for(filter_nan_done, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + await wait_or_fail( + filter_nan_done, 2.0, "Test 6 timed out", values=sensor_values["filter_nan"] + ) values = sensor_values["filter_nan"] assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" diff --git a/tests/integration/test_sensor_filters_ring_buffer.py b/tests/integration/test_sensor_filters_ring_buffer.py index c8be8edce0f..5f0fac6623e 100644 --- a/tests/integration/test_sensor_filters_ring_buffer.py +++ b/tests/integration/test_sensor_filters_ring_buffer.py @@ -9,6 +9,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -99,17 +100,12 @@ async def test_sensor_filters_ring_buffer( client.button_command(publish_button.key) # Wait for all sensors to receive their values - try: - await asyncio.wait_for(all_updates_received, timeout=10.0) - except TimeoutError: - # Provide detailed failure info - pytest.fail( - f"Timeout waiting for updates. Received states:\n" - f" min: {sensor_states['sliding_min']}\n" - f" max: {sensor_states['sliding_max']}\n" - f" median: {sensor_states['sliding_median']}\n" - f" moving_avg: {sensor_states['sliding_moving_avg']}" - ) + await wait_or_fail( + all_updates_received, + 10.0, + "Timeout waiting for updates", + sensor_states=sensor_states, + ) # Verify we got 5 outputs per sensor (positions 1, 3, 5, 7, 9) assert len(sensor_states["sliding_min"]) == 5, ( diff --git a/tests/integration/test_sensor_filters_sliding_window.py b/tests/integration/test_sensor_filters_sliding_window.py index b0688a6536c..d873898c120 100644 --- a/tests/integration/test_sensor_filters_sliding_window.py +++ b/tests/integration/test_sensor_filters_sliding_window.py @@ -9,6 +9,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -131,27 +132,18 @@ async def test_sensor_filters_sliding_window( client.button_command(publish_button.key) # Wait for all sensors to receive their final values - try: - await asyncio.wait_for( - asyncio.gather( - min_received, - max_received, - median_received, - quantile_received, - moving_avg_received, - ), - timeout=10.0, - ) - except TimeoutError: - # Provide detailed failure info - pytest.fail( - f"Timeout waiting for expected values. Received states:\n" - f" min: {sensor_states['min_sensor']}\n" - f" max: {sensor_states['max_sensor']}\n" - f" median: {sensor_states['median_sensor']}\n" - f" quantile: {sensor_states['quantile_sensor']}\n" - f" moving_avg: {sensor_states['moving_avg_sensor']}" - ) + await wait_or_fail( + asyncio.gather( + min_received, + max_received, + median_received, + quantile_received, + moving_avg_received, + ), + 10.0, + "Timeout waiting for expected values", + sensor_states=sensor_states, + ) # Verify we got the expected values # With batch_delay: 0ms, we should receive all outputs @@ -269,14 +261,13 @@ async def test_sensor_filters_nan_handling( client.button_command(publish_button.key) # Wait for filters to process - try: - await asyncio.wait_for(filters_completed, timeout=10.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for NaN handling. Received:\n" - f" min_states: {min_states}\n" - f" max_states: {max_states}" - ) + await wait_or_fail( + filters_completed, + 10.0, + "Timeout waiting for NaN handling", + min_states=min_states, + max_states=max_states, + ) # Verify NaN values were ignored # With batch_delay: 0ms, we should receive both outputs (at positions 1 and 6) @@ -370,10 +361,12 @@ async def test_sensor_filters_ring_buffer_wraparound( client.button_command(publish_button.key) # Wait for completion - try: - await asyncio.wait_for(test_completed, timeout=10.0) - except TimeoutError: - pytest.fail(f"Timeout waiting for wraparound test. Received: {min_states}") + await wait_or_fail( + test_completed, + 10.0, + "Timeout waiting for wraparound test", + min_states=min_states, + ) # Verify outputs # With window_size=3, send_every=3, we get outputs at positions 1, 4, 7 diff --git a/tests/integration/test_sensor_filters_value_list.py b/tests/integration/test_sensor_filters_value_list.py index 87323fc7302..d542f5ff36d 100644 --- a/tests/integration/test_sensor_filters_value_list.py +++ b/tests/integration/test_sensor_filters_value_list.py @@ -10,6 +10,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -144,12 +145,12 @@ async def test_sensor_filters_value_list( # Test 1: FilterOutValueFilter - single value sensor_values["filter_out_single"].clear() client.button_command(buttons["filter_out_single"]) - try: - await asyncio.wait_for(filter_out_single_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 1 timed out. Values: {sensor_values['filter_out_single']}" - ) + await wait_or_fail( + filter_out_single_done, + 2.0, + "Test 1 timed out", + values=sensor_values["filter_out_single"], + ) expected = [1.0, 2.0, 3.0] assert sensor_values["filter_out_single"] == pytest.approx(expected), ( @@ -160,12 +161,12 @@ async def test_sensor_filters_value_list( sensor_values["filter_out_multiple"].clear() filter_out_multiple_done = loop.create_future() client.button_command(buttons["filter_out_multiple"]) - try: - await asyncio.wait_for(filter_out_multiple_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 2 timed out. Values: {sensor_values['filter_out_multiple']}" - ) + await wait_or_fail( + filter_out_multiple_done, + 2.0, + "Test 2 timed out", + values=sensor_values["filter_out_multiple"], + ) expected = [1.0, 2.0, 50.0] assert sensor_values["filter_out_multiple"] == pytest.approx(expected), ( @@ -176,12 +177,12 @@ async def test_sensor_filters_value_list( sensor_values["throttle_priority_single"].clear() throttle_single_done = loop.create_future() client.button_command(buttons["throttle_priority_single"]) - try: - await asyncio.wait_for(throttle_single_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 3 timed out. Values: {sensor_values['throttle_priority_single']}" - ) + await wait_or_fail( + throttle_single_done, + 2.0, + "Test 3 timed out", + values=sensor_values["throttle_priority_single"], + ) expected = [1.0, 42.0, 4.0] assert sensor_values["throttle_priority_single"] == pytest.approx(expected), ( @@ -192,12 +193,12 @@ async def test_sensor_filters_value_list( sensor_values["throttle_priority_multiple"].clear() throttle_multiple_done = loop.create_future() client.button_command(buttons["throttle_priority_multiple"]) - try: - await asyncio.wait_for(throttle_multiple_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 4 timed out. Values: {sensor_values['throttle_priority_multiple']}" - ) + await wait_or_fail( + throttle_multiple_done, + 2.0, + "Test 4 timed out", + values=sensor_values["throttle_priority_multiple"], + ) expected = [1.0, 0.0, 42.0, 100.0] assert sensor_values["throttle_priority_multiple"] == pytest.approx(expected), ( @@ -208,12 +209,12 @@ async def test_sensor_filters_value_list( sensor_values["filter_out_nan_test"].clear() filter_out_nan_done = loop.create_future() client.button_command(buttons["filter_out_nan"]) - try: - await asyncio.wait_for(filter_out_nan_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 5 timed out. Values: {sensor_values['filter_out_nan_test']}" - ) + await wait_or_fail( + filter_out_nan_done, + 2.0, + "Test 5 timed out", + values=sensor_values["filter_out_nan_test"], + ) expected = [1.0, 2.0, 3.0] assert sensor_values["filter_out_nan_test"] == pytest.approx(expected), ( @@ -224,12 +225,12 @@ async def test_sensor_filters_value_list( sensor_values["filter_out_accuracy_2"].clear() filter_out_accuracy_2_done = loop.create_future() client.button_command(buttons["filter_out_accuracy_2"]) - try: - await asyncio.wait_for(filter_out_accuracy_2_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 6 timed out. Values: {sensor_values['filter_out_accuracy_2']}" - ) + await wait_or_fail( + filter_out_accuracy_2_done, + 2.0, + "Test 6 timed out", + values=sensor_values["filter_out_accuracy_2"], + ) expected = [42.01, 42.15] assert sensor_values["filter_out_accuracy_2"] == pytest.approx(expected), ( @@ -240,12 +241,12 @@ async def test_sensor_filters_value_list( sensor_values["throttle_priority_nan"].clear() throttle_nan_done = loop.create_future() client.button_command(buttons["throttle_priority_nan"]) - try: - await asyncio.wait_for(throttle_nan_done, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Test 7 timed out. Values: {sensor_values['throttle_priority_nan']}" - ) + await wait_or_fail( + throttle_nan_done, + 2.0, + "Test 7 timed out", + values=sensor_values["throttle_priority_nan"], + ) # First value (1.0) + two NaN priority values # NaN values will be compared using math.isnan diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py index a178ebf7d4c..f354690fcee 100644 --- a/tests/integration/test_sensor_raw_state.py +++ b/tests/integration/test_sensor_raw_state.py @@ -14,6 +14,7 @@ from aioesphomeapi import APIClient, EntityInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)") WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)") @@ -31,10 +32,7 @@ async def _press_and_read( ) assert button is not None, f"{button_object_id} not found" client.button_command(button.key) - try: - return await asyncio.wait_for(future, timeout=5.0) - except TimeoutError: - pytest.fail(f"Timeout waiting for {label} log message") + return await wait_or_fail(future, 5.0, f"Timeout waiting for {label} log message") @pytest.mark.asyncio diff --git a/tests/integration/test_sensor_timeout_filter.py b/tests/integration/test_sensor_timeout_filter.py index 9b4704bb7b7..4c5a95daac2 100644 --- a/tests/integration/test_sensor_timeout_filter.py +++ b/tests/integration/test_sensor_timeout_filter.py @@ -9,6 +9,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -112,10 +113,9 @@ async def test_sensor_timeout_filter( # === Test 1: TimeoutFilter - last mode === client.button_command(test1_button_key) - try: - await asyncio.wait_for(test1_complete, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 1 timeout. Received states: {timeout_last_states}") + await wait_or_fail( + test1_complete, 2.0, "Test 1 timeout", received_states=timeout_last_states + ) assert len(timeout_last_states) == 2, ( f"Test 1: Should have 2 states, got {len(timeout_last_states)}: {timeout_last_states}" @@ -129,10 +129,9 @@ async def test_sensor_timeout_filter( # === Test 2: TimeoutFilter - reset behavior === client.button_command(test2_button_key) - try: - await asyncio.wait_for(test2_complete, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 2 timeout. Received states: {timeout_reset_states}") + await wait_or_fail( + test2_complete, 2.0, "Test 2 timeout", received_states=timeout_reset_states + ) assert len(timeout_reset_states) == 4, ( f"Test 2: Should have 4 states, got {len(timeout_reset_states)}: {timeout_reset_states}" @@ -152,10 +151,9 @@ async def test_sensor_timeout_filter( # === Test 3: TimeoutFilterConfigured - static value === client.button_command(test3_button_key) - try: - await asyncio.wait_for(test3_complete, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 3 timeout. Received states: {timeout_static_states}") + await wait_or_fail( + test3_complete, 2.0, "Test 3 timeout", received_states=timeout_static_states + ) assert len(timeout_static_states) == 2, ( f"Test 3: Should have 2 states, got {len(timeout_static_states)}: {timeout_static_states}" @@ -169,10 +167,9 @@ async def test_sensor_timeout_filter( # === Test 4: TimeoutFilterConfigured - lambda === client.button_command(test4_button_key) - try: - await asyncio.wait_for(test4_complete, timeout=2.0) - except TimeoutError: - pytest.fail(f"Test 4 timeout. Received states: {timeout_lambda_states}") + await wait_or_fail( + test4_complete, 2.0, "Test 4 timeout", received_states=timeout_lambda_states + ) assert len(timeout_lambda_states) == 2, ( f"Test 4: Should have 2 states, got {len(timeout_lambda_states)}: {timeout_lambda_states}" diff --git a/tests/integration/test_sensor_timeout_throttle_filter.py b/tests/integration/test_sensor_timeout_throttle_filter.py index 739421ca3aa..2496869443f 100644 --- a/tests/integration/test_sensor_timeout_throttle_filter.py +++ b/tests/integration/test_sensor_timeout_throttle_filter.py @@ -10,6 +10,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -46,10 +47,9 @@ async def test_sensor_timeout_throttle_filter( e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive" ) client.button_command(button) - try: - await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0) - except TimeoutError: - pytest.fail(f"Timed out: {values}") + await wait_or_fail( + asyncio.gather(*done.values()), 5.0, "Timed out", values=values + ) merged, reference = values["merged"], values["reference"] assert len(merged) == len(reference), (merged, reference) diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py index 476dd2713e2..290dbee145f 100644 --- a/tests/integration/test_text_sensor_raw_state.py +++ b/tests/integration/test_text_sensor_raw_state.py @@ -15,6 +15,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -154,10 +155,9 @@ async def test_text_sensor_raw_state( # get_raw_state() should return the same as state client.button_command(no_filter_button.key) - try: - state, raw_state = await asyncio.wait_for(no_filter_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for NO_FILTER log message") + state, raw_state = await wait_or_fail( + no_filter_future, 5.0, "Timeout waiting for NO_FILTER log message" + ) assert state == "hello world", f"Expected state='hello world', got '{state}'" assert raw_state == "hello world", ( @@ -172,10 +172,9 @@ async def test_text_sensor_raw_state( # state should be filtered (uppercase), raw_state should be original client.button_command(with_filter_button.key) - try: - state, raw_state = await asyncio.wait_for(with_filter_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for WITH_FILTER log message") + state, raw_state = await wait_or_fail( + with_filter_future, 5.0, "Timeout waiting for WITH_FILTER log message" + ) assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" assert raw_state == "hello world", ( @@ -195,10 +194,9 @@ async def test_text_sensor_raw_state( assert append_button is not None, "Test Append Button not found" client.button_command(append_button.key) - try: - state = await asyncio.wait_for(append_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for APPEND log message") + state = await wait_or_fail( + append_future, 5.0, "Timeout waiting for APPEND log message" + ) assert state == "test suffix", ( f"Append failed: expected 'test suffix', got '{state}'" @@ -213,10 +211,9 @@ async def test_text_sensor_raw_state( assert prepend_button is not None, "Test Prepend Button not found" client.button_command(prepend_button.key) - try: - state = await asyncio.wait_for(prepend_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for PREPEND log message") + state = await wait_or_fail( + prepend_future, 5.0, "Timeout waiting for PREPEND log message" + ) assert state == "prefix test", ( f"Prepend failed: expected 'prefix test', got '{state}'" @@ -231,10 +228,9 @@ async def test_text_sensor_raw_state( assert substitute_button is not None, "Test Substitute Button not found" client.button_command(substitute_button.key) - try: - state = await asyncio.wait_for(substitute_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for SUBSTITUTE log message") + state = await wait_or_fail( + substitute_future, 5.0, "Timeout waiting for SUBSTITUTE log message" + ) assert state == "bar says world", ( f"Substitute failed: expected 'bar says world', got '{state}'" @@ -248,10 +244,9 @@ async def test_text_sensor_raw_state( assert map_on_button is not None, "Test Map ON Button not found" client.button_command(map_on_button.key) - try: - state = await asyncio.wait_for(map_on_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for MAP_ON log message") + state = await wait_or_fail( + map_on_future, 5.0, "Timeout waiting for MAP_ON log message" + ) assert state == "Active", f"Map ON failed: expected 'Active', got '{state}'" @@ -263,10 +258,9 @@ async def test_text_sensor_raw_state( assert map_off_button is not None, "Test Map OFF Button not found" client.button_command(map_off_button.key) - try: - state = await asyncio.wait_for(map_off_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for MAP_OFF log message") + state = await wait_or_fail( + map_off_future, 5.0, "Timeout waiting for MAP_OFF log message" + ) assert state == "Inactive", ( f"Map OFF failed: expected 'Inactive', got '{state}'" @@ -281,10 +275,9 @@ async def test_text_sensor_raw_state( assert map_unknown_button is not None, "Test Map Unknown Button not found" client.button_command(map_unknown_button.key) - try: - state = await asyncio.wait_for(map_unknown_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for MAP_UNKNOWN log message") + state = await wait_or_fail( + map_unknown_future, 5.0, "Timeout waiting for MAP_UNKNOWN log message" + ) assert state == "UNKNOWN", ( f"Map passthrough failed: expected 'UNKNOWN', got '{state}'" @@ -299,10 +292,9 @@ async def test_text_sensor_raw_state( assert chained_button is not None, "Test Chained Button not found" client.button_command(chained_button.key) - try: - state = await asyncio.wait_for(chained_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for CHAINED log message") + state = await wait_or_fail( + chained_future, 5.0, "Timeout waiting for CHAINED log message" + ) assert state == "[value]", f"Chained failed: expected '[value]', got '{state}'" @@ -315,10 +307,9 @@ async def test_text_sensor_raw_state( assert to_lower_button is not None, "Test To Lower Button not found" client.button_command(to_lower_button.key) - try: - state = await asyncio.wait_for(to_lower_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for TO_LOWER log message") + state = await wait_or_fail( + to_lower_future, 5.0, "Timeout waiting for TO_LOWER log message" + ) assert state == "hello world", ( f"to_lower failed: expected 'hello world', got '{state}'" @@ -333,10 +324,9 @@ async def test_text_sensor_raw_state( assert lambda_button is not None, "Test Lambda Button not found" client.button_command(lambda_button.key) - try: - state = await asyncio.wait_for(lambda_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for LAMBDA log message") + state = await wait_or_fail( + lambda_future, 5.0, "Timeout waiting for LAMBDA log message" + ) assert state == "[test]", f"Lambda failed: expected '[test]', got '{state}'" @@ -349,10 +339,9 @@ async def test_text_sensor_raw_state( assert lambda_pass_button is not None, "Test Lambda Pass Button not found" client.button_command(lambda_pass_button.key) - try: - state = await asyncio.wait_for(lambda_pass_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for LAMBDA_PASS log message") + state = await wait_or_fail( + lambda_pass_future, 5.0, "Timeout waiting for LAMBDA_PASS log message" + ) assert state == "value passed", ( f"Lambda pass failed: expected 'value passed', got '{state}'" @@ -367,10 +356,9 @@ async def test_text_sensor_raw_state( assert lambda_skip_button is not None, "Test Lambda Skip Button not found" client.button_command(lambda_skip_button.key) - try: - state = await asyncio.wait_for(lambda_skip_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for LAMBDA_SKIP log message") + state = await wait_or_fail( + lambda_skip_future, 5.0, "Timeout waiting for LAMBDA_SKIP log message" + ) # When lambda returns {}, value should NOT be published # State remains from previous successful publish ("value passed") @@ -394,12 +382,11 @@ async def test_text_sensor_raw_state( ) client.button_command(lambda_raw_state_button.key) - try: - state, raw_state = await asyncio.wait_for( - lambda_raw_state_future, timeout=5.0 - ) - except TimeoutError: - pytest.fail("Timeout waiting for LAMBDA_RAW_STATE log message") + state, raw_state = await wait_or_fail( + lambda_raw_state_future, + 5.0, + "Timeout waiting for LAMBDA_RAW_STATE log message", + ) assert state == "original MODIFIED", ( f"Lambda raw_state test failed: expected state='original MODIFIED', " From 1430428f70be41f57db25d8885d800cac38a4b17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 17:34:25 -1000 Subject: [PATCH 26/43] [tests] Use wait_or_fail in the api, light, script and other integration tests (#21165) --- .../test_action_concurrent_reentry.py | 6 +- ...t_alarm_control_panel_state_transitions.py | 67 +++++++++---------- ...meassistant_binary_sensor_initial_state.py | 12 ++-- .../test_api_homeassistant_entities.py | 12 ++-- tests/integration/test_api_reboot_timeout.py | 8 +-- tests/integration/test_api_string_lambda.py | 25 +++---- tests/integration/test_areas_and_devices.py | 14 ++-- .../test_binary_sensor_autorepeat_filter.py | 20 +++--- .../test_binary_sensor_invalidate_state.py | 26 +++---- tests/integration/test_build_info.py | 10 ++- .../integration/test_climate_custom_modes.py | 19 +++--- tests/integration/test_entity_icon.py | 6 +- .../integration/test_entity_missing_state.py | 10 +-- tests/integration/test_fnv1_hash_object_id.py | 8 +-- tests/integration/test_fnv1a_hash.py | 8 +-- tests/integration/test_light_automations.py | 25 +++---- tests/integration/test_lock_automations.py | 11 +-- tests/integration/test_multi_click_trigger.py | 34 +++++----- .../test_multi_device_preferences.py | 8 +-- tests/integration/test_oversized_payloads.py | 47 ++++++------- tests/integration/test_runtime_stats.py | 15 ++--- tests/integration/test_safe_mode_loop_runs.py | 31 +++++---- tests/integration/test_script_array_params.py | 8 +-- tests/integration/test_script_delay_params.py | 15 +++-- tests/integration/test_socket_ipv4_resolve.py | 6 +- .../test_socket_tcp_client_link.py | 11 +-- .../integration/test_socket_wake_gate_tcp.py | 13 ++-- .../integration/test_socket_wake_gate_udp.py | 11 +-- tests/integration/test_strftime_to.py | 10 ++- tests/integration/test_tcp_uart_bridge.py | 18 +++-- tests/integration/test_template_time.py | 8 +-- tests/integration/test_udp.py | 14 ++-- tests/integration/test_udp_ipv6.py | 12 ++-- tests/integration/test_udp_multicast.py | 18 ++--- tests/integration/test_use_address_runtime.py | 13 ++-- .../test_varint_five_byte_device_id.py | 42 ++++++------ tests/integration/test_wait_until_ordering.py | 14 ++-- .../test_wait_until_reentrant_restart.py | 18 ++--- 38 files changed, 304 insertions(+), 349 deletions(-) diff --git a/tests/integration/test_action_concurrent_reentry.py b/tests/integration/test_action_concurrent_reentry.py index aa5801ca2b7..0be5299079f 100644 --- a/tests/integration/test_action_concurrent_reentry.py +++ b/tests/integration/test_action_concurrent_reentry.py @@ -9,6 +9,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -70,10 +71,7 @@ async def test_action_concurrent_reentry( assert device_info.name == "action-concurrent-reentry" # Wait for tests to complete with timeout - try: - await asyncio.wait_for(test_complete.wait(), timeout=8.0) - except TimeoutError: - pytest.fail("test timed out") + await wait_or_fail(test_complete, 8.0, "test timed out") # order may change, but all args must be present for args in in_repeat_args.values(): diff --git a/tests/integration/test_alarm_control_panel_state_transitions.py b/tests/integration/test_alarm_control_panel_state_transitions.py index 0b07710961e..e632a7a242f 100644 --- a/tests/integration/test_alarm_control_panel_state_transitions.py +++ b/tests/integration/test_alarm_control_panel_state_transitions.py @@ -17,6 +17,7 @@ import pytest from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -249,10 +250,9 @@ async def test_alarm_control_panel_state_transitions( await wait_for_state(AlarmControlPanelState.ARMED_AWAY, timeout=2.0) # Verify on_cleared was logged - try: - await asyncio.wait_for(cleared_future, timeout=1.0) - except TimeoutError: - pytest.fail(f"on_cleared callback not fired. Log lines: {log_lines[-20:]}") + await wait_or_fail( + cleared_future, 1.0, "on_cleared callback not fired", log_lines=log_lines + ) # Disarm client.alarm_control_panel_command( @@ -282,19 +282,17 @@ async def test_alarm_control_panel_state_transitions( client.switch_command(chime_switch_info.key, True) # Wait for chime callback to be logged - try: - await asyncio.wait_for(chime_future, timeout=2.0) - except TimeoutError: - pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}") + await wait_or_fail( + chime_future, 2.0, "on_chime callback not fired", log_lines=log_lines + ) # Wait for the on_ready from the chime sensor opening - try: - await asyncio.wait_for(ready_after_chime_open, timeout=2.0) - except TimeoutError: - pytest.fail( - f"on_ready callback not fired when chime sensor opened. " - f"Log lines: {log_lines[-20:]}" - ) + await wait_or_fail( + ready_after_chime_open, + 2.0, + "on_ready callback not fired when chime sensor opened", + log_lines=log_lines, + ) # Now create the future for the close event and close the sensor. # Since we waited for the open event above, the close event's @@ -305,13 +303,12 @@ async def test_alarm_control_panel_state_transitions( client.switch_command(chime_switch_info.key, False) # Wait for alarm to become ready again (chime sensor closed) - try: - await asyncio.wait_for(ready_after_chime_close, timeout=2.0) - except TimeoutError: - pytest.fail( - f"on_ready callback not fired when chime sensor closed. " - f"Log lines: {log_lines[-20:]}" - ) + await wait_or_fail( + ready_after_chime_close, + 2.0, + "on_ready callback not fired when chime sensor closed", + log_lines=log_lines, + ) # ===== Test ready state changes ===== # Now the alarm is confirmed ready. Opening/closing door sensor @@ -326,25 +323,23 @@ async def test_alarm_control_panel_state_transitions( client.switch_command(door_switch_info.key, True) # Wait for first on_ready callback (not ready) - try: - await asyncio.wait_for(ready_future_1, timeout=2.0) - except TimeoutError: - pytest.fail( - f"on_ready callback not fired when sensor opened. " - f"Log lines: {log_lines[-20:]}" - ) + await wait_or_fail( + ready_future_1, + 2.0, + "on_ready callback not fired when sensor opened", + log_lines=log_lines, + ) # Close door sensor (makes alarm ready again) client.switch_command(door_switch_info.key, False) # Wait for second on_ready callback (ready) - try: - await asyncio.wait_for(ready_future_2, timeout=2.0) - except TimeoutError: - pytest.fail( - f"on_ready callback not fired when sensor closed. " - f"Log lines: {log_lines[-20:]}" - ) + await wait_or_fail( + ready_future_2, + 2.0, + "on_ready callback not fired when sensor closed", + log_lines=log_lines, + ) # Final state should still be DISARMED assert states_received[-1] == AlarmControlPanelState.DISARMED diff --git a/tests/integration/test_api_homeassistant_binary_sensor_initial_state.py b/tests/integration/test_api_homeassistant_binary_sensor_initial_state.py index 4f7dda6eee9..2b108ac15f0 100644 --- a/tests/integration/test_api_homeassistant_binary_sensor_initial_state.py +++ b/tests/integration/test_api_homeassistant_binary_sensor_initial_state.py @@ -8,6 +8,7 @@ import pytest from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail ENTITIES = ( "binary_sensor.initial_on", @@ -40,10 +41,13 @@ async def test_api_homeassistant_binary_sensor_initial_state( api_client_connected() as client, ): client.subscribe_home_assistant_states(on_state_sub) - try: - await asyncio.wait_for(all_subscribed, timeout=5.0) - except TimeoutError: - pytest.fail(f"never subscribed: {set(ENTITIES) - subscribed}") + await wait_or_fail( + all_subscribed, + 5.0, + "never subscribed", + expected=ENTITIES, + subscribed=subscribed, + ) # First state from HA client.send_home_assistant_state("binary_sensor.initial_on", "", "on") diff --git a/tests/integration/test_api_homeassistant_entities.py b/tests/integration/test_api_homeassistant_entities.py index a45454f614e..fb3a271c0a7 100644 --- a/tests/integration/test_api_homeassistant_entities.py +++ b/tests/integration/test_api_homeassistant_entities.py @@ -10,6 +10,7 @@ import pytest from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail SUBSCRIPTIONS = { ("select.mode", "options"), @@ -75,10 +76,13 @@ async def test_api_homeassistant_entities( dump_select = next(s for s in services if s.name == "dump_select") client.subscribe_service_calls(calls.put_nowait) client.subscribe_home_assistant_states(on_state_sub) - try: - await asyncio.wait_for(all_subscribed, timeout=5.0) - except TimeoutError: - pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}") + await wait_or_fail( + all_subscribed, + 5.0, + "never subscribed", + expected=SUBSCRIPTIONS, + subscribed=subscribed, + ) await _check_select(client, waiter, keys, dump_select, next_call) await _check_text(client, waiter, keys, next_call) diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 9cada0a296e..91633661261 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -6,6 +6,7 @@ import re import pytest from .types import RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -27,9 +28,8 @@ async def test_api_reboot_timeout( async with run_compiled(yaml_config, line_callback=check_output): # Wait for reboot with timeout # (0.5s reboot timeout + some margin for processing) - try: - await asyncio.wait_for(reboot_future, timeout=2.0) - except TimeoutError: - pytest.fail("Device did not reboot within expected timeout") + await wait_or_fail( + reboot_future, 2.0, "Device did not reboot within expected timeout" + ) # Test passes if we get here - reboot was detected diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py index 373bd3c20b8..2b1df0d4f5e 100644 --- a/tests/integration/test_api_string_lambda.py +++ b/tests/integration/test_api_string_lambda.py @@ -8,6 +8,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -86,17 +87,13 @@ async def test_api_string_lambda( # Wait for all service log messages # This confirms the lambdas compiled successfully and executed - try: - await asyncio.wait_for( - asyncio.gather( - string_called_future, - int_called_future, - float_called_future, - char_ptr_called_future, - ), - timeout=5.0, - ) - except TimeoutError: - pytest.fail( - "One or more service log messages not received - lambda may have failed to compile or execute" - ) + await wait_or_fail( + asyncio.gather( + string_called_future, + int_called_future, + float_called_future, + char_ptr_called_future, + ), + 5.0, + "One or more service log messages not received - lambda may have failed to compile or execute", + ) diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 93326de0a97..7455ec7428d 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityState, SwitchInfo, SwitchState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -118,13 +119,12 @@ async def test_areas_and_devices( client.subscribe_states(on_state) # Wait for sensor states - try: - await asyncio.wait_for(initial_states_future, timeout=10.0) - except TimeoutError: - pytest.fail( - f"Did not receive all states within 10 seconds. " - f"Received {len(states)} states" - ) + await wait_or_fail( + initial_states_future, + 10.0, + "Did not receive all states within 10 seconds", + states=states, + ) # Verify we have sensor entities with proper device_id assignments device_id_mapping = { diff --git a/tests/integration/test_binary_sensor_autorepeat_filter.py b/tests/integration/test_binary_sensor_autorepeat_filter.py index 443d5293f23..45301586f20 100644 --- a/tests/integration/test_binary_sensor_autorepeat_filter.py +++ b/tests/integration/test_binary_sensor_autorepeat_filter.py @@ -19,6 +19,7 @@ import pytest from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -71,12 +72,12 @@ async def test_binary_sensor_autorepeat_filter( # Wait for at least 5 transitions to verify the oscillation pattern. oscillation_seen = collector.add_waiter(lambda: len(autorepeat_states) >= 5) - try: - await asyncio.wait_for(oscillation_seen, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Expected at least 5 autorepeat transitions, got {autorepeat_states}" - ) + await wait_or_fail( + oscillation_seen, + 2.0, + "Expected at least 5 autorepeat transitions", + autorepeat_states=autorepeat_states, + ) assert autorepeat_states[0] is True, ( f"First transition should be the pass-through true, got {autorepeat_states}" @@ -104,10 +105,9 @@ async def test_binary_sensor_autorepeat_filter( settle_seen = collector.add_waiter( lambda: len(autorepeat_states) > before_count ) - try: - await asyncio.wait_for(settle_seen, timeout=2.0) - except TimeoutError: - pytest.fail("Timeout waiting for autorepeat to settle to false") + await wait_or_fail( + settle_seen, 2.0, "Timeout waiting for autorepeat to settle to false" + ) assert autorepeat_states[-1] is False, ( f"After release, final state should be False, got {autorepeat_states}" ) diff --git a/tests/integration/test_binary_sensor_invalidate_state.py b/tests/integration/test_binary_sensor_invalidate_state.py index ee9e57319cd..f62f449c14e 100644 --- a/tests/integration/test_binary_sensor_invalidate_state.py +++ b/tests/integration/test_binary_sensor_invalidate_state.py @@ -16,6 +16,7 @@ import pytest from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -92,10 +93,7 @@ async def test_binary_sensor_invalidate_state( state_future = loop.create_future() client.button_command(set_true_button.key) - try: - state = await asyncio.wait_for(state_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for state=true") + state = await wait_or_fail(state_future, 5.0, "Timeout waiting for state=true") assert state.missing_state is False, ( f"After setting true, missing_state should be False, got {state}" @@ -107,10 +105,7 @@ async def test_binary_sensor_invalidate_state( state_future = loop.create_future() client.button_command(set_false_button.key) - try: - state = await asyncio.wait_for(state_future, timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for state=false") + state = await wait_or_fail(state_future, 5.0, "Timeout waiting for state=false") assert state.missing_state is False, ( f"After setting false, missing_state should be False, got {state}" @@ -123,14 +118,13 @@ async def test_binary_sensor_invalidate_state( state_future = loop.create_future() client.button_command(invalidate_button.key) - try: - state = await asyncio.wait_for(state_future, timeout=5.0) - except TimeoutError: - pytest.fail( - "Timeout waiting for invalidated state - " - "binary_sensor.invalidate_state() may not be reporting to the API. " - "See issue #12252." - ) + state = await wait_or_fail( + state_future, + 5.0, + "Timeout waiting for invalidated state - " + "binary_sensor.invalidate_state() may not be reporting to the API. " + "See issue #12252.", + ) assert state.missing_state is True, ( f"After invalidate_state(), missing_state should be True (unknown), " diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index 7079594471f..38c3be1c4f7 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -11,6 +11,7 @@ from aioesphomeapi import EntityState, TextSensorState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -70,12 +71,9 @@ async def test_build_info( client.subscribe_states(on_state) - try: - await asyncio.wait_for(all_received, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for text sensor states. Got: {list(states.keys())}" - ) + await wait_or_fail( + all_received, 5.0, "Timeout waiting for text sensor states", states=states + ) config_hash_state = states[config_hash_entity.key] build_time_state = states[build_time_entity.key] diff --git a/tests/integration/test_climate_custom_modes.py b/tests/integration/test_climate_custom_modes.py index 67a7b0581a4..ff9d7476195 100644 --- a/tests/integration/test_climate_custom_modes.py +++ b/tests/integration/test_climate_custom_modes.py @@ -10,6 +10,7 @@ import pytest from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -97,10 +98,11 @@ async def test_climate_custom_fan_modes_and_presets( # Test changing to "Super Saver" custom preset client.climate_command(test_climate.key, custom_preset="Super Saver") - try: - super_saver_state = await asyncio.wait_for(super_saver_future, timeout=5.0) - except TimeoutError: - pytest.fail("Super Saver preset change not received within 5 seconds") + super_saver_state = await wait_or_fail( + super_saver_future, + 5.0, + "Super Saver preset change not received within 5 seconds", + ) assert isinstance(super_saver_state, aioesphomeapi.ClimateState) assert super_saver_state.custom_preset == "Super Saver" @@ -110,10 +112,11 @@ async def test_climate_custom_fan_modes_and_presets( # Test changing to "Vacation Mode" custom preset client.climate_command(test_climate.key, custom_preset="Vacation Mode") - try: - vacation_state = await asyncio.wait_for(vacation_future, timeout=5.0) - except TimeoutError: - pytest.fail("Vacation Mode preset change not received within 5 seconds") + vacation_state = await wait_or_fail( + vacation_future, + 5.0, + "Vacation Mode preset change not received within 5 seconds", + ) assert isinstance(vacation_state, aioesphomeapi.ClimateState) assert vacation_state.custom_preset == "Vacation Mode" diff --git a/tests/integration/test_entity_icon.py b/tests/integration/test_entity_icon.py index a634ae385e6..2de83907af2 100644 --- a/tests/integration/test_entity_icon.py +++ b/tests/integration/test_entity_icon.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -80,10 +81,7 @@ async def test_entity_icon( client.subscribe_states(on_state) # Wait for states - try: - await asyncio.wait_for(state_received.wait(), timeout=5.0) - except TimeoutError: - pytest.fail("No states received within 5 seconds") + await wait_or_fail(state_received, 5.0, "No states received within 5 seconds") # Verify we received states assert len(states) > 0, ( diff --git a/tests/integration/test_entity_missing_state.py b/tests/integration/test_entity_missing_state.py index f87ee213462..379c6a78fe3 100644 --- a/tests/integration/test_entity_missing_state.py +++ b/tests/integration/test_entity_missing_state.py @@ -26,6 +26,7 @@ import pytest from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -104,10 +105,11 @@ async def test_entity_missing_state( futures = {info.key: loop.create_future() for info in stateful} client.button_command(publish_button.key) - try: - await asyncio.wait_for(asyncio.gather(*futures.values()), timeout=5.0) - except TimeoutError: - pytest.fail("Timeout waiting for published states") + await wait_or_fail( + asyncio.gather(*futures.values()), + 5.0, + "Timeout waiting for published states", + ) switch_state = futures[switch_info.key].result() assert isinstance(switch_state, SwitchState) diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py index 23e8ca04c23..737d28ba434 100644 --- a/tests/integration/test_fnv1_hash_object_id.py +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -16,6 +16,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -62,10 +63,9 @@ async def test_fnv1_hash_object_id( assert device_info.name == "fnv1-hash-object-id-test" # Wait for all tests to complete or timeout - try: - await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0) - except TimeoutError: - pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}") + await wait_or_fail( + all_tests_complete, 2.0, "Tests timed out", test_results=test_results + ) # Verify all tests passed for test_name in expected_tests: diff --git a/tests/integration/test_fnv1a_hash.py b/tests/integration/test_fnv1a_hash.py index 366ea42cda4..3c19adf55a1 100644 --- a/tests/integration/test_fnv1a_hash.py +++ b/tests/integration/test_fnv1a_hash.py @@ -8,6 +8,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -45,10 +46,9 @@ async def test_fnv1a_hash( assert device_info.name == "fnv1a-hash-test" # Wait for all tests to complete or timeout - try: - await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0) - except TimeoutError: - pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}") + await wait_or_fail( + all_tests_complete, 2.0, "Tests timed out", test_results=test_results + ) # Verify all tests passed assert "empty" in test_results, "empty string test not found" diff --git a/tests/integration/test_light_automations.py b/tests/integration/test_light_automations.py index 9ff334548a7..86376df9b88 100644 --- a/tests/integration/test_light_automations.py +++ b/tests/integration/test_light_automations.py @@ -9,6 +9,7 @@ import asyncio import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -68,32 +69,24 @@ async def test_light_automations( client.light_command(key=light.key, state=True) # Wait for on_turn_on trigger - try: - await asyncio.wait_for(on_turn_on_future, timeout=5.0) - except TimeoutError: - pytest.fail("on_turn_on trigger did not fire") + await wait_or_fail(on_turn_on_future, 5.0, "on_turn_on trigger did not fire") # Wait for on_state trigger - try: - await asyncio.wait_for(on_state_future_1, timeout=5.0) - except TimeoutError: - pytest.fail("on_state trigger did not fire after turn on") + await wait_or_fail( + on_state_future_1, 5.0, "on_state trigger did not fire after turn on" + ) # Test 2: Turn light off - should trigger on_turn_off and on_state on_state_future_2 = create_on_state_future() client.light_command(key=light.key, state=False) # Wait for on_turn_off trigger - try: - await asyncio.wait_for(on_turn_off_future, timeout=5.0) - except TimeoutError: - pytest.fail("on_turn_off trigger did not fire") + await wait_or_fail(on_turn_off_future, 5.0, "on_turn_off trigger did not fire") # Wait for on_state trigger - try: - await asyncio.wait_for(on_state_future_2, timeout=5.0) - except TimeoutError: - pytest.fail("on_state trigger did not fire after turn off") + await wait_or_fail( + on_state_future_2, 5.0, "on_state trigger did not fire after turn off" + ) # Verify on_state fired exactly twice (once for on, once for off) assert on_state_count == 2, ( diff --git a/tests/integration/test_lock_automations.py b/tests/integration/test_lock_automations.py index e200a2eacdf..a1098164d7c 100644 --- a/tests/integration/test_lock_automations.py +++ b/tests/integration/test_lock_automations.py @@ -8,6 +8,7 @@ import asyncio import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -44,15 +45,9 @@ async def test_lock_automations( # Test 1: Lock - should trigger on_lock client.lock_command(key=lock.key, command=LockCommand.LOCK) - try: - await asyncio.wait_for(on_lock_future, timeout=5.0) - except TimeoutError: - pytest.fail("on_lock trigger did not fire") + await wait_or_fail(on_lock_future, 5.0, "on_lock trigger did not fire") # Test 2: Unlock - should trigger on_unlock client.lock_command(key=lock.key, command=LockCommand.UNLOCK) - try: - await asyncio.wait_for(on_unlock_future, timeout=5.0) - except TimeoutError: - pytest.fail("on_unlock trigger did not fire") + await wait_or_fail(on_unlock_future, 5.0, "on_unlock trigger did not fire") diff --git a/tests/integration/test_multi_click_trigger.py b/tests/integration/test_multi_click_trigger.py index 8a020dd18b3..51d433092cd 100644 --- a/tests/integration/test_multi_click_trigger.py +++ b/tests/integration/test_multi_click_trigger.py @@ -14,6 +14,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -57,26 +58,23 @@ async def test_multi_click_trigger( 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." - ) + count = await wait_or_fail( + single_click_future, + 5.0, + "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." - ) + count = await wait_or_fail( + double_click_future, + 5.0, + "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." - ) + count = await wait_or_fail( + long_press_future, + 5.0, + "Timeout waiting for LONG_PRESS - on_multi_click did not trigger.", + ) assert count == 1, f"Expected long press count=1, got {count}" diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py index 625f83f16ec..dcbe2bcb053 100644 --- a/tests/integration/test_multi_device_preferences.py +++ b/tests/integration/test_multi_device_preferences.py @@ -9,6 +9,7 @@ from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -104,10 +105,9 @@ async def test_multi_device_preferences( client.button_command(test_button.key) # Wait for preference hashes to be logged - try: - await asyncio.wait_for(preferences_logged, timeout=5.0) - except TimeoutError: - pytest.fail("Preference hashes not logged within timeout") + await wait_or_fail( + preferences_logged, 5.0, "Preference hashes not logged within timeout" + ) # Verify all switch preference hashes are unique assert len(switch_hashes) == 3, ( diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index be488347aab..0adcc7a61f2 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -7,6 +7,7 @@ import asyncio import pytest from .types import APIClientConnectedWithDisconnectFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -55,12 +56,11 @@ async def test_oversized_payload_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) - try: - await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) - except TimeoutError: - pytest.fail( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + await wait_or_fail( + helper_log_event, + 2.0, + "Expected to see HELPER_LOG about message size exceeding maximum", + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -118,12 +118,11 @@ async def test_oversized_protobuf_message_id_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) - try: - await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) - except TimeoutError: - pytest.fail( - "Expected to see HELPER_LOG about message type exceeding maximum" - ) + await wait_or_fail( + helper_log_event, + 2.0, + "Expected to see HELPER_LOG about message type exceeding maximum", + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -184,12 +183,11 @@ async def test_oversized_payload_noise( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) - try: - await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) - except TimeoutError: - pytest.fail( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + await wait_or_fail( + helper_log_event, + 2.0, + "Expected to see HELPER_LOG about message size exceeding maximum", + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -336,13 +334,12 @@ async def test_noise_corrupt_encrypted_frame( "ESPHome process should not crash on corrupt encrypted frames" ) # Wait for the expected log message (may arrive after disconnect event) - try: - await asyncio.wait_for(cipherstate_event.wait(), timeout=2.0) - except TimeoutError: - pytest.fail( - "Expected to see log about noise_cipherstate_decrypt failure" - " or CIPHERSTATE_DECRYPT_FAILED" - ) + await wait_or_fail( + cipherstate_event, + 2.0, + "Expected to see log about noise_cipherstate_decrypt failure" + " or CIPHERSTATE_DECRYPT_FAILED", + ) # Verify we can still reconnect after handling the corrupt frame async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index bd7f36341d2..a6a3250e131 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -8,6 +8,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -77,16 +78,14 @@ async def test_runtime_stats( assert device_info is not None # Wait for first "Total stats" log (should happen at 1s) - try: - await asyncio.wait_for(first_stats_future, timeout=5.0) - except TimeoutError: - pytest.fail("First 'Total stats' log not seen within 5 seconds") + await wait_or_fail( + first_stats_future, 5.0, "First 'Total stats' log not seen within 5 seconds" + ) # Wait for second "Total stats" log (should happen at 2s) - try: - await asyncio.wait_for(second_stats_future, timeout=5.0) - except TimeoutError: - pytest.fail(f"Second 'Total stats' log not seen. Total seen: {stats_count}") + await wait_or_fail( + second_stats_future, 5.0, "Second 'Total stats' log not seen" + ) # Verify we got at least 2 stats logs assert stats_count >= 2, ( diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 55756fca13d..3c5942b5797 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -31,6 +31,7 @@ import pytest from .conftest import run_binary from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter +from .wait_utils import wait_or_fail DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -66,21 +67,19 @@ async def test_safe_mode_loop_runs( thread_log_seen.set_result(True) async with run_binary(binary_path, line_callback=on_log): - try: - await asyncio.wait_for(safe_mode_active, timeout=15.0) - except TimeoutError: - pytest.fail( - "Did not observe 'SAFE MODE IS ACTIVE' -- safe mode " - "didn't trigger, so this test isn't exercising the bug." - ) - try: - await asyncio.wait_for(thread_log_seen, timeout=10.0) - except TimeoutError: - pytest.fail( - f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} " - "within timeout. Logger::loop() never drained the task " - "log buffer, meaning App.looping_components_ was never " - "sized -- this is the regression #16269 fixed." - ) + await wait_or_fail( + safe_mode_active, + 15.0, + "Did not observe 'SAFE MODE IS ACTIVE' -- safe mode " + "didn't trigger, so this test isn't exercising the bug.", + ) + await wait_or_fail( + thread_log_seen, + 10.0, + f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} " + "within timeout. Logger::loop() never drained the task " + "log buffer, meaning App.looping_components_ was never " + "sized -- this is the regression #16269 fixed.", + ) finally: clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_script_array_params.py b/tests/integration/test_script_array_params.py index 2fba8a58fe2..849ff0a39ab 100644 --- a/tests/integration/test_script_array_params.py +++ b/tests/integration/test_script_array_params.py @@ -15,6 +15,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -53,10 +54,9 @@ async def test_script_array_params( assert service is not None, "run_array_script service not found" await client.execute_service(service, {}) - try: - await asyncio.wait_for(done, timeout=5.0) - except TimeoutError: - pytest.fail(f"Did not receive all expected log lines. Saw: {seen}") + await wait_or_fail( + done, 5.0, "Did not receive all expected log lines", seen=seen + ) assert (m := patterns["ints"].search(seen["ints"])) assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100" diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py index 37c72f0f7df..919f091b15f 100644 --- a/tests/integration/test_script_delay_params.py +++ b/tests/integration/test_script_delay_params.py @@ -21,6 +21,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -93,13 +94,13 @@ async def test_script_delay_with_params( await client.execute_service(test_service, {}) # Wait for test to complete (10 iterations * ~100ms each + margin) - try: - await asyncio.wait_for(test_complete.wait(), timeout=5.0) - except TimeoutError: - pytest.fail( - f"Test timed out. Completed iterations: {sorted(wait_returned)}. " - f"This likely indicates the script became a zombie (issue #12044)." - ) + await wait_or_fail( + test_complete, + 5.0, + "Test timed out. " + "This likely indicates the script became a zombie (issue #12044).", + wait_returned=wait_returned, + ) # Verify all 10 iterations completed successfully expected_iterations = set(range(10)) diff --git a/tests/integration/test_socket_ipv4_resolve.py b/tests/integration/test_socket_ipv4_resolve.py index 5d7bd5a100f..646125d4373 100644 --- a/tests/integration/test_socket_ipv4_resolve.py +++ b/tests/integration/test_socket_ipv4_resolve.py @@ -7,6 +7,7 @@ import asyncio import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail CHECKS = ( "Literal resolve", @@ -42,10 +43,7 @@ async def test_socket_ipv4_resolve( assert device_info is not None assert device_info.name == "socket-ipv4-resolve-test" - try: - await asyncio.wait_for(test_complete.wait(), timeout=10.0) - except TimeoutError: - pytest.fail("IPv4 resolve test timed out") + await wait_or_fail(test_complete, 10.0, "IPv4 resolve test timed out") for check in CHECKS: assert results.get(check), f"{check} check failed or never ran" diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 89a6ff42347..da937e546b3 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -13,6 +13,7 @@ import contextlib import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail PAYLOAD = b"hello link" SECOND_PAYLOAD = b"second session" @@ -81,16 +82,10 @@ async def test_socket_tcp_client_link( assert device_info is not None assert device_info.name == "socket-tcp-client-link-test" - try: - await asyncio.wait_for(echo_done.wait(), timeout=15.0) - except TimeoutError: - pytest.fail("Link never connected or echoed") + await wait_or_fail(echo_done, 15.0, "Link never connected or echoed") assert echoed and echoed[0] == PAYLOAD, "Echo payload mismatch" - try: - await asyncio.wait_for(link_down.wait(), timeout=15.0) - except TimeoutError: - pytest.fail("Link never reported the drop") + await wait_or_fail(link_down, 15.0, "Link never reported the drop") try: await asyncio.wait_for(reconnected.wait(), timeout=15.0) await asyncio.wait_for(second_link_up.wait(), timeout=15.0) diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py index 2955d2803a5..6648d591477 100644 --- a/tests/integration/test_socket_wake_gate_tcp.py +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -18,6 +18,7 @@ import time import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -46,10 +47,9 @@ async def test_socket_wake_gate_tcp( assert device_info is not None assert device_info.name == "socket-wake-gate-tcp" - try: - await asyncio.wait_for(boot_done, timeout=15.0) - except TimeoutError: - pytest.fail("BOOT_DONE never appeared — device did not complete boot") + await wait_or_fail( + boot_done, 15.0, "BOOT_DONE never appeared — device did not complete boot" + ) _, services = await client.list_entities_services() ping_service = next((s for s in services if s.name == "ping"), None) @@ -61,10 +61,7 @@ async def test_socket_wake_gate_tcp( t_send = time.monotonic() await client.execute_service(ping_service, {}) - try: - await asyncio.wait_for(pong, timeout=5.0) - except TimeoutError: - pytest.fail("PONG never appeared — service did not execute") + await wait_or_fail(pong, 5.0, "PONG never appeared — service did not execute") elapsed_ms = (time.monotonic() - t_send) * 1000 # Without the fix the gate stays closed for up to loop_interval_=2000ms. diff --git a/tests/integration/test_socket_wake_gate_udp.py b/tests/integration/test_socket_wake_gate_udp.py index 4edd8f14c2c..f6c7941d5f6 100644 --- a/tests/integration/test_socket_wake_gate_udp.py +++ b/tests/integration/test_socket_wake_gate_udp.py @@ -9,6 +9,7 @@ import time import pytest from .types import RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -37,10 +38,7 @@ async def test_socket_wake_gate_udp( try: async with run_compiled(config, line_callback=on_log_line): - try: - await asyncio.wait_for(boot_done, timeout=15.0) - except TimeoutError: - pytest.fail("BOOT_DONE never appeared") + await wait_or_fail(boot_done, 15.0, "BOOT_DONE never appeared") # Datagrams go to the more specific bind, so release the hold before sending hold_sock.close() @@ -50,10 +48,7 @@ async def test_socket_wake_gate_udp( with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as send_sock: send_sock.sendto(b"PING", ("127.0.0.1", listen_port)) - try: - await asyncio.wait_for(pong, timeout=5.0) - except TimeoutError: - pytest.fail("PONG never appeared") + await wait_or_fail(pong, 5.0, "PONG never appeared") elapsed_ms = (time.monotonic() - t_send) * 1000 # Without the wake the receive waits for loop_interval_; 500ms leaves CI headroom diff --git a/tests/integration/test_strftime_to.py b/tests/integration/test_strftime_to.py index 9220da148b4..5398777364f 100644 --- a/tests/integration/test_strftime_to.py +++ b/tests/integration/test_strftime_to.py @@ -9,6 +9,7 @@ import pytest from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -68,12 +69,9 @@ async def test_strftime_to( pytest.fail("Timeout waiting for initial states") # Wait for all expected states - try: - await asyncio.wait_for(all_received, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for text sensor states. Got: {list(states.keys())}" - ) + await wait_or_fail( + all_received, 5.0, "Timeout waiting for text sensor states", states=states + ) # Validate strftime_to with full format # Note: The exact output depends on timezone, but should contain date components diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index 70befc90498..8e0f83ff6fa 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -14,18 +14,12 @@ import pytest from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail GREETING = b"hi" RECONNECT_GREETING = b"yo" -async def _wait(event: asyncio.Event, timeout: float, message: str) -> None: - try: - await asyncio.wait_for(event.wait(), timeout) - except TimeoutError: - pytest.fail(message) - - @pytest.mark.asyncio async def test_tcp_uart_bridge( yaml_config: str, @@ -83,10 +77,14 @@ async def test_tcp_uart_bridge( _, services = await client.list_entities_services() send_byte = next(s for s in services if s.name == "send_byte") - await _wait(echo_ok, 15.0, "UART echo through the bridge never arrived") + await wait_or_fail( + echo_ok, 15.0, "UART echo through the bridge never arrived" + ) await client.execute_service(send_byte, {}) - await _wait(service_byte_ok, 10.0, "Service byte never reached the server") + await wait_or_fail( + service_byte_ok, 10.0, "Service byte never reached the server" + ) await lines.wait_for("Flush result 0") # The server closed session one; a write while down must warn and @@ -96,7 +94,7 @@ async def test_tcp_uart_bridge( await lines.wait_for("Not connected, dropped") await lines.wait_for("Flush result 2") - await _wait( + await wait_or_fail( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: diff --git a/tests/integration/test_template_time.py b/tests/integration/test_template_time.py index c0a03fe2352..2cea4d44992 100644 --- a/tests/integration/test_template_time.py +++ b/tests/integration/test_template_time.py @@ -9,6 +9,7 @@ import pytest from .state_utils import InitialStateHelper, build_key_to_entity_mapping from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail # Timestamps are exposed as text sensors (rather than 32-bit float sensor states, # which cannot represent a UNIX epoch exactly) so the exact value can be checked. @@ -43,7 +44,6 @@ async def test_template_time( await initial_state_helper.wait_for_initial_states() for name, event in events.items(): - try: - await asyncio.wait_for(event.wait(), timeout=3.0) - except TimeoutError: - pytest.fail(f"Timeout waiting for {name} to report its expected value") + await wait_or_fail( + event, 3.0, f"Timeout waiting for {name} to report its expected value" + ) diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 4ee3bba4444..c65e98ea74d 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -12,6 +12,7 @@ import socket import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @dataclass @@ -189,10 +190,9 @@ async def test_udp_send_receive( finally: send_sock.close() - try: - await asyncio.wait_for(receive_event.wait(), timeout=5.0) - except TimeoutError: - pytest.fail( - f"on_receive did not fire. Expected 'Received UDP:' in logs. " - f"Last log lines: {log_lines[-20:]}" - ) + await wait_or_fail( + receive_event, + 5.0, + "on_receive did not fire. Expected 'Received UDP:' in logs", + log_lines=log_lines, + ) diff --git a/tests/integration/test_udp_ipv6.py b/tests/integration/test_udp_ipv6.py index 19b8eb5fe41..6e29e85a5e2 100644 --- a/tests/integration/test_udp_ipv6.py +++ b/tests/integration/test_udp_ipv6.py @@ -8,6 +8,7 @@ import socket import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail def _free_port(family: socket.AddressFamily, host: str) -> int: @@ -52,12 +53,11 @@ async def test_udp_ipv6_unicast( ): with socket.socket(family, socket.SOCK_DGRAM) as send_sock: send_sock.sendto(b"PING", (host, listen_port)) - try: - data = await asyncio.wait_for( - loop.sock_recv(reply_sock, 64), timeout=5.0 - ) - except TimeoutError: - pytest.fail(f"No reply over IPv6 to a PING sent to {host}") + data = await wait_or_fail( + loop.sock_recv(reply_sock, 64), + 5.0, + f"No reply over IPv6 to a PING sent to {host}", + ) assert data == b"PONG" finally: reply_sock.close() diff --git a/tests/integration/test_udp_multicast.py b/tests/integration/test_udp_multicast.py index 98a90591421..5a11b19ae96 100644 --- a/tests/integration/test_udp_multicast.py +++ b/tests/integration/test_udp_multicast.py @@ -8,6 +8,7 @@ import socket import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail MULTICAST_GROUP = "239.1.2.3" @@ -50,10 +51,9 @@ async def test_udp_multicast_join( assert device_info is not None assert device_info.name == "udp-multicast-test" - try: - await asyncio.wait_for(joined_event.wait(), timeout=5.0) - except TimeoutError: - pytest.fail(f"Multicast group was not joined. Log: {log_lines[-20:]}") + await wait_or_fail( + joined_event, 5.0, "Multicast group was not joined", log_lines=log_lines + ) # The poll cancels itself after the join, so a second poll period must not join again await asyncio.sleep(2.5) @@ -69,7 +69,9 @@ async def test_udp_multicast_join( finally: send_sock.close() - try: - await asyncio.wait_for(receive_event.wait(), timeout=5.0) - except TimeoutError: - pytest.fail(f"Multicast datagram was not received. Log: {log_lines[-20:]}") + await wait_or_fail( + receive_event, + 5.0, + "Multicast datagram was not received", + log_lines=log_lines, + ) diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py index a4cbbb9c5f3..d5c1e91b571 100644 --- a/tests/integration/test_use_address_runtime.py +++ b/tests/integration/test_use_address_runtime.py @@ -13,6 +13,7 @@ import asyncio import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" MAC_SUFFIX = "abf679" @@ -39,10 +40,9 @@ async def test_use_address_runtime( assert device_info is not None assert device_info.name == "use-address-runtime" - try: - await asyncio.wait_for(address_seen.wait(), timeout=10.0) - except TimeoutError: - pytest.fail("Did not log 'Address: use-address-runtime.local:'") + await wait_or_fail( + address_seen, 10.0, "Did not log 'Address: use-address-runtime.local:'" + ) @pytest.mark.asyncio @@ -67,7 +67,4 @@ async def test_use_address_runtime_mac_suffix( assert device_info is not None assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" - try: - await asyncio.wait_for(address_seen.wait(), timeout=10.0) - except TimeoutError: - pytest.fail(f"Did not log '{expected}'") + await wait_or_fail(address_seen, 10.0, f"Did not log '{expected}'") diff --git a/tests/integration/test_varint_five_byte_device_id.py b/tests/integration/test_varint_five_byte_device_id.py index d34c2f03d6c..74b2cdb43b0 100644 --- a/tests/integration/test_varint_five_byte_device_id.py +++ b/tests/integration/test_varint_five_byte_device_id.py @@ -15,6 +15,7 @@ from aioesphomeapi import EntityState, SwitchInfo, SwitchState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -70,12 +71,9 @@ async def test_varint_five_byte_device_id( client.subscribe_states(on_state) - try: - await asyncio.wait_for(initial_done, timeout=10.0) - except TimeoutError: - pytest.fail( - f"Timed out waiting for initial states. Got {len(states)} states" - ) + await wait_or_fail( + initial_done, 10.0, "Timed out waiting for initial states", states=states + ) # Verify state responses contain correct large device_id values for device in devices: @@ -96,25 +94,25 @@ async def test_varint_five_byte_device_id( # Turn on switch_futures[state_key] = loop.create_future() client.switch_command(switch.key, True, device_id=switch.device_id) - try: - await asyncio.wait_for(switch_futures[state_key], timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timed out waiting for switch ON state " - f"(device_id={switch.device_id}, key={switch.key}). " - f"This likely means the firmware failed to decode the " - f"5-byte varint device_id in SwitchCommandRequest." - ) + await wait_or_fail( + switch_futures[state_key], + 2.0, + "Timed out waiting for switch ON state. " + "This likely means the firmware failed to decode the " + "5-byte varint device_id in SwitchCommandRequest.", + device_id=switch.device_id, + key=switch.key, + ) assert states[state_key].state is True # Turn off switch_futures[state_key] = loop.create_future() client.switch_command(switch.key, False, device_id=switch.device_id) - try: - await asyncio.wait_for(switch_futures[state_key], timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timed out waiting for switch OFF state " - f"(device_id={switch.device_id}, key={switch.key})" - ) + await wait_or_fail( + switch_futures[state_key], + 2.0, + "Timed out waiting for switch OFF state", + device_id=switch.device_id, + key=switch.key, + ) assert states[state_key].state is False diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py index 96b3a8aed00..8ac72e1d451 100644 --- a/tests/integration/test_wait_until_ordering.py +++ b/tests/integration/test_wait_until_ordering.py @@ -15,6 +15,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -74,13 +75,12 @@ async def test_wait_until_fifo_ordering( await client.execute_service(test_service, {}) # Wait for test to complete - try: - await asyncio.wait_for(test_complete.wait(), timeout=5.0) - except TimeoutError: - pytest.fail( - f"Test timed out. Completed order: {completed_order}. " - f"Expected 5 completions but got {len(completed_order)}." - ) + await wait_or_fail( + test_complete, + 5.0, + "Test timed out. Expected 5 completions", + completed_order=completed_order, + ) # Verify FIFO order expected_order = [0, 1, 2, 3, 4] diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py index 9c733395155..bb68fdba409 100644 --- a/tests/integration/test_wait_until_reentrant_restart.py +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -16,6 +16,7 @@ import re import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -66,10 +67,9 @@ async def test_wait_until_reentrant_restart( # Scenario 1: the wait_until timeout continuation restarts its own # script five times, re-entering the same wait_until each time. await client.execute_service(self_restart_service, {}) - try: - await asyncio.wait_for(retry_complete.wait(), timeout=10.0) - except TimeoutError: - pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + await wait_or_fail( + retry_complete, 10.0, "Self-restart retry did not finish", attempts=attempts + ) assert attempts == [1, 2, 3, 4, 5], attempts # Scenario 2: the first waiter through the gate stops the script while @@ -77,10 +77,12 @@ async def test_wait_until_reentrant_restart( # not-yet-checked waiters (2, 3) and the already-checked still-waiting # blocker (0) must be cancelled, not fired. await client.execute_service(stop_service, {}) - try: - await asyncio.wait_for(stop_complete.wait(), timeout=10.0) - except TimeoutError: - pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + await wait_or_fail( + stop_complete, + 10.0, + "Stop-during-wait did not finish", + gate_passed=gate_passed, + ) assert gate_passed == [1], gate_passed # If the cancelled blocker had been kept, its 1s wait_until timeout From ff1c448ecc0e0f22f80b8eca371eaf08752bf833 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 17:35:09 -1000 Subject: [PATCH 27/43] [core] Document the new codegen, bus and Apply entity helpers in AGENTS.md (#21130) --- AGENTS.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c165ddc350f..ac7975d3b00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,14 @@ file does, and it is the authority when they disagree. The most useful starting * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. - * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`); - pick the helper's polynomial and bit-order arguments instead of writing a CRC loop. + * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`, + `reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend`, `bcd_to_uint8`); pick the helper's + arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use + `time::decode_bcd_calendar`. + * **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with + `LineReader`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` + and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning. + A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`. * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the end of `setup()` when the entities need a state at boot. @@ -429,6 +435,22 @@ file does, and it is the authority when they disagree. The most useful starting and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass `config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`. + - **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub + uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema + and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or + `index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state. + ```python + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after") + ``` + + - **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`, + `spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`) + constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda; + `MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and + `factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in + final validation. + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). From 3da581233a64f1ed9216c417db975a96fa7e76cb Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:08:03 +1100 Subject: [PATCH 28/43] [st7305] Fix position of partial updates on landscape panels (#21172) --- esphome/components/st7305/st7305.cpp | 9 +++++++-- .../components/st7305/display/test_st7305.cpp | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/esphome/components/st7305/st7305.cpp b/esphome/components/st7305/st7305.cpp index 36b58fdb240..2b324666fb4 100644 --- a/esphome/components/st7305/st7305.cpp +++ b/esphome/components/st7305/st7305.cpp @@ -116,6 +116,11 @@ ST7305::Window ST7305::compute_window_() const { window.block_start = static_cast((inv_min >> 2) / 3 * 3); window.block_end = static_cast(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast(window.stride) - 1)); + // The panel mirrors the column address window (MADCTL MX) but still fills it in ascending + // order, so the bands are addressed from the far end of the column range. + const int last_band = window.stride / 3 - 1; + window.caset_start = static_cast(this->column_start_ + last_band - window.block_end / 3); + window.caset_end = static_cast(this->column_start_ + last_band - window.block_start / 3); } else { // RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks. window.stride = static_cast(this->width_ / 4); @@ -125,9 +130,9 @@ ST7305::Window ST7305::compute_window_() const { const int block_max = this->x_high_ >> 2; window.block_start = static_cast(block_min / 3 * 3); window.block_end = static_cast(std::min((block_max / 3) * 3 + 2, static_cast(window.stride) - 1)); + window.caset_start = static_cast(this->column_start_ + window.block_start / 3); + window.caset_end = static_cast(this->column_start_ + window.block_end / 3); } - window.caset_start = static_cast(this->column_start_ + window.block_start / 3); - window.caset_end = static_cast(this->column_start_ + window.block_end / 3); return window; } diff --git a/tests/components/st7305/display/test_st7305.cpp b/tests/components/st7305/display/test_st7305.cpp index e447e09e14d..b357907be76 100644 --- a/tests/components/st7305/display/test_st7305.cpp +++ b/tests/components/st7305/display/test_st7305.cpp @@ -108,10 +108,24 @@ TEST_F(ST7305Test, WindowAlignsToAddressBlocks) { EXPECT_EQ(window.outer_end, 0); EXPECT_EQ(window.block_start, 0); EXPECT_EQ(window.block_end, 2); - EXPECT_EQ(window.caset_start, 0x12); - EXPECT_EQ(window.caset_end, 0x12); + EXPECT_EQ(window.caset_start, 0x2A); + EXPECT_EQ(window.caset_end, 0x2A); EXPECT_EQ(window.data_length(), 3u); + // A window spanning several bands is addressed from the far end of the column range. + this->reset_screen_(); + this->display_.draw_pixel_at(100, 85, Color::WHITE); + this->display_.draw_pixel_at(368, 293, Color::WHITE); + + const auto bands = this->display_.compute_window(); + EXPECT_EQ(bands.outer_start, 50); + EXPECT_EQ(bands.outer_end, 184); + EXPECT_EQ(bands.block_start, 0); + EXPECT_EQ(bands.block_end, 53); + EXPECT_EQ(bands.caset_start, 0x19); + EXPECT_EQ(bands.caset_end, 0x2A); + EXPECT_EQ(bands.data_length(), 135u * 54u); + this->reset_screen_(); this->display_.draw_pixel_at(200, 150, Color::WHITE); From 89536632dc4bc4a67ee53cebd46ef3dadfdc6c11 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:18:55 -1000 Subject: [PATCH 29/43] Bump bundled esphome-device-builder to 1.25.0 (#21175) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 658b8c06302..3c160153cc9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0 RUN \ platformio settings set enable_telemetry No \ From 09b580e4ea898681e756154e0a93377ed8859969 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 20 Oct 2026 09:53:50 +0100 Subject: [PATCH 30/43] [st25r] Add ST25R3916 NFC reader (SPI, ISO14443A) (#15608) --- CODEOWNERS | 2 + esphome/components/st25r/__init__.py | 46 ++ esphome/components/st25r/st25r.cpp | 392 ++++++++++++++++++ esphome/components/st25r/st25r.h | 138 ++++++ esphome/components/st25r_spi/__init__.py | 24 ++ esphome/components/st25r_spi/st25r_spi.cpp | 59 +++ esphome/components/st25r_spi/st25r_spi.h | 24 ++ tests/components/st25r_spi/common.yaml | 21 + .../components/st25r_spi/test.esp32-idf.yaml | 8 + .../st25r_spi/test.esp8266-ard.yaml | 8 + 10 files changed, 722 insertions(+) create mode 100644 esphome/components/st25r/__init__.py create mode 100644 esphome/components/st25r/st25r.cpp create mode 100644 esphome/components/st25r/st25r.h create mode 100644 esphome/components/st25r_spi/__init__.py create mode 100644 esphome/components/st25r_spi/st25r_spi.cpp create mode 100644 esphome/components/st25r_spi/st25r_spi.h create mode 100644 tests/components/st25r_spi/common.yaml create mode 100644 tests/components/st25r_spi/test.esp32-idf.yaml create mode 100644 tests/components/st25r_spi/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 44eb9a27738..54cfa5766f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -601,6 +601,8 @@ esphome/components/ssd1331_base/* @kbx81 esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 +esphome/components/st25r/* @JohnMcLear +esphome/components/st25r_spi/* @JohnMcLear esphome/components/st7123/* @miniskipper esphome/components/st7305/* @clydebarrow esphome/components/st7567_base/* @latonita diff --git a/esphome/components/st25r/__init__.py b/esphome/components/st25r/__init__.py new file mode 100644 index 00000000000..55a2128f820 --- /dev/null +++ b/esphome/components/st25r/__init__.py @@ -0,0 +1,46 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import nfc +import esphome.config_validation as cv +from esphome.const import CONF_IRQ_PIN, CONF_ON_TAG, CONF_ON_TAG_REMOVED, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["nfc"] +CODEOWNERS = ["@JohnMcLear"] +DOMAIN = "st25r" +MULTI_CONF = True + +CONF_RF_POWER = "rf_power" + +st25r_ns = cg.esphome_ns.namespace("st25r") +ST25R = st25r_ns.class_("ST25R", nfc.Nfcc, cg.PollingComponent) + +ST25R_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ST25R), + cv.Optional(CONF_IRQ_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RF_POWER, default=15): cv.int_range(min=0, max=15), + cv.Optional(CONF_ON_TAG): automation.validate_automation({}), + cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation({}), + } +).extend(cv.polling_component_schema("1s")) + +_TAG_ARGS = [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")] + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAG, "add_on_tag_callback", _TAG_ARGS), + automation.CallbackAutomation( + CONF_ON_TAG_REMOVED, "add_on_tag_removed_callback", _TAG_ARGS + ), +) + + +async def setup_st25r(var: MockObj, config: ConfigType) -> None: + await cg.add_pin(var.set_irq_pin, config.get(CONF_IRQ_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + + cg.add(var.set_rf_power(config[CONF_RF_POWER])) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/st25r/st25r.cpp b/esphome/components/st25r/st25r.cpp new file mode 100644 index 00000000000..99b0fd8b750 --- /dev/null +++ b/esphome/components/st25r/st25r.cpp @@ -0,0 +1,392 @@ +#include "st25r.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::st25r { + +ESPHOME_LOG_TAG(TAG, "st25r"); + +// ISO14443A SEL command for each cascade level +static constexpr uint8_t SEL_CMDS[] = {0x93, 0x95, 0x97}; + +// RFAL NFC-A 106 kbps register profile, as {register, value} pairs in write order +static const uint8_t NFCA_PROFILE_RX[][2] PROGMEM = { + {ST25R_REG_MODE, 0x08}, {ST25R_REG_BIT_RATE, 0x00}, {ST25R_REG_RX_CONF1, 0x08}, + {ST25R_REG_RX_CONF2, 0x2D}, {ST25R_REG_RX_CONF3, 0x00}, {ST25R_REG_RX_CONF4, 0x00}, + {ST25R_REG_MASK_MAIN, 0x00}, {ST25R_REG_MASK_TIMER, 0x00}, {ST25R_REG_ISO14443A_CONF, 0x00}, +}; +static const uint8_t NFCA_PROFILE_TX[][2] PROGMEM = { + {ST25R_REG_ANT_TUNE_A, 0x80}, + {ST25R_REG_ANT_TUNE_B, 0x40}, + {ST25R_REG_AUX_MOD, 0x10}, + {ST25R_REG_RES_AM_MOD, 0x80}, + {ST25R_REG_FIELD_THRESHOLD_ACTV, 0x11}, + {ST25R_REG_FIELD_THRESHOLD_DEACTV, 0x00}, + {ST25R_REG_PASSIVE_TARGET, 0x50}, + {ST25R_REG_PT_MOD, 0x51}, + {ST25R_REG_EMD_SUP_CONF, 0x40}, + {ST25R_REG_OVERSHOOT_CONF1, 0x40}, + {ST25R_REG_OVERSHOOT_CONF2, 0x03}, + {ST25R_REG_UNDERSHOOT_CONF1, 0x40}, + {ST25R_REG_UNDERSHOOT_CONF2, 0x03}, +}; +static const uint8_t NFCA_PROFILE_CORR[][2] PROGMEM = { + {ST25R_REG_RX_CONF4, 0x00}, + {ST25R_REG_CORR_CONF1, 0x51}, + {ST25R_REG_CORR_CONF2, 0x00}, +}; + +void IRAM_ATTR ST25R::isr(ST25R *arg) { + arg->irq_fired_ = true; + arg->enable_loop_soon_any_context(); +} + +void ST25R::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->pulse_reset_(); + } + + if (this->irq_pin_ != nullptr) { + this->irq_pin_->setup(); + this->irq_pin_->attach_interrupt(ST25R::isr, this, gpio::INTERRUPT_RISING_EDGE); + } + + if (!this->reset_chip_()) + this->mark_failed(LOG_STR("Failed to reset chip")); +} + +void ST25R::update() { + if (this->is_failed() || this->state_ != STATE_IDLE) + return; + + this->enable_loop(); + // The field was off between scans, so every tag in range restarts in the IDLE state and + // answers the WUPA; a tag left on the reader is found again without needing a HLTA first. + this->write_register(ST25R_REG_OP_CONTROL, 0xC8); // en=1, rx_en=1, tx_en=1 + this->tag_found_this_scan_ = false; + this->state_ = STATE_FIELD_ON; + this->last_state_change_ = App.get_loop_component_start_time(); +} + +void ST25R::loop() { + if (this->state_ == STATE_IDLE) { + this->disable_loop(); + return; + } + // With an IRQ pin the status is only fetched after the chip raised the line; the + // state timeouts still run on every pass + if (this->irq_pin_ == nullptr || this->irq_fired_) { + this->irq_fired_ = false; + this->irq_status_ = this->read_register(ST25R_REG_IRQ_MAIN); + // The line only drops once every pending source is read, or the next edge is missed + this->read_register(ST25R_REG_IRQ_TIMER); + this->read_register(ST25R_REG_IRQ_ERROR); + } else { + this->irq_status_ = 0; + } + this->process_state_(); +} + +void ST25R::pulse_reset_() { + this->reset_pin_->digital_write(true); + delay(10); + this->reset_pin_->digital_write(false); + delay(10); +} + +void ST25R::clear_irqs_() { + this->write_command(ST25R_CMD_CLEAR_FIFO); + this->read_register(ST25R_REG_IRQ_MAIN); + this->read_register(ST25R_REG_IRQ_TIMER); + this->read_register(ST25R_REG_IRQ_ERROR); +} + +void ST25R::write_register_table_(const uint8_t (*table)[2], size_t count) { + for (size_t i = 0; i < count; i++) + this->write_register(progmem_read_byte(&table[i][0]), progmem_read_byte(&table[i][1])); +} + +bool ST25R::transceive_ex_(const uint8_t *data, size_t len, uint8_t *resp, uint8_t resp_cap, uint8_t &resp_len, + uint32_t timeout_ms) { + this->clear_irqs_(); + this->write_command(ST25R_CMD_RESET_RX_GAIN); + + // The byte count spans both registers: ntx[12:5] in the first, ntx[4:0] plus the bit count in the second + this->write_register(ST25R_REG_NUM_TX_BYTES1, (len >> 5) & 0xFF); + this->write_register(ST25R_REG_NUM_TX_BYTES2, (len & 0x1F) << 3); + + this->write_fifo(data, len); + this->write_command(ST25R_CMD_TRANSMIT_WITH_CRC); + + uint32_t start = millis(); + resp_len = 0; + bool tx_done = false; + + while (millis() - start < timeout_ms) { + uint8_t irq = this->read_register(ST25R_REG_IRQ_MAIN); + this->irq_status_ = irq; + + if (irq & IRQ_TXE) + tx_done = true; + + if (tx_done) { + uint8_t f1 = this->read_register(ST25R_REG_FIFO_STATUS1); + if (f1 > 0) { + uint8_t to_read = std::min(resp_cap - resp_len, f1); + this->read_fifo(resp + resp_len, to_read); + resp_len += to_read; + if (resp_len >= resp_cap) + return true; + start = millis(); + } + if (irq & IRQ_RXE) + return resp_len > 0; + } + delay(1); + } + return resp_len > 0; +} + +void ST25R::process_state_() { + const uint32_t now = App.get_loop_component_start_time(); + switch (this->state_) { + case STATE_IDLE: + break; + + case STATE_FIELD_ON: { + // ISO 14443-3 guard time: a tag needs 5 ms of field before the first command + if (now - this->last_state_change_ < FIELD_GUARD_MS) + break; + this->clear_irqs_(); + this->write_command(ST25R_CMD_RESET_RX_GAIN); + this->write_command(ST25R_CMD_TRANSMIT_WUPA); + this->state_ = STATE_WUPA; + this->last_state_change_ = now; + break; + } + + case STATE_WUPA: { + if (this->irq_status_ & (IRQ_RXE | IRQ_COL)) { + this->cascade_level_ = 0; + this->scan_uid_.clear(); + this->send_anticol_frame_(); + this->state_ = STATE_ANTICOL; + this->last_state_change_ = now; + } else if (now - this->last_state_change_ > 100) { + this->finish_scan_(); + } + break; + } + + case STATE_ANTICOL: { + if (!(this->irq_status_ & (IRQ_RXE | IRQ_COL | IRQ_TXE))) { + if (now - this->last_state_change_ > 20) + this->finish_scan_(); + break; + } + + uint8_t f1 = this->read_register(ST25R_REG_FIFO_STATUS1); + + if (this->irq_status_ & IRQ_COL) { + if (f1 > 0) { + uint8_t tmp[8]; + this->read_fifo(tmp, std::min(f1, sizeof(tmp))); + } + this->finish_scan_(); + break; + } + + if (f1 < 5) { + if (now - this->last_state_change_ > 20) + this->finish_scan_(); + break; + } + + uint8_t resp[5]; + this->read_fifo(resp, 5); + uint8_t bcc = xor_bytes(resp, 4); + if (bcc != resp[4]) { + ESP_LOGV(TAG, "Anticollision BCC mismatch"); + this->finish_scan_(); + break; + } + uint8_t sel_pk[7] = {SEL_CMDS[this->cascade_level_], 0x70, resp[0], resp[1], resp[2], resp[3], bcc}; + + // Skip the cascade tag (0x88) + const uint8_t *uid_part = resp[0] == 0x88 ? resp + 1 : resp; + const uint8_t *uid_end = resp + 4; + for (; uid_part < uid_end && this->scan_uid_.size() < nfc::NFC_UID_MAX_LENGTH; uid_part++) + this->scan_uid_.push_back(*uid_part); + + this->write_register(ST25R_REG_ISO14443A_CONF, 0x00); // antcl=0 + + uint8_t sak_buf[3]; + uint8_t sak_len = 0; + if (!this->transceive_ex_(sel_pk, sizeof(sel_pk), sak_buf, sizeof(sak_buf), sak_len, 150)) { + ESP_LOGW(TAG, "SELECT failed (no SAK)"); + this->finish_scan_(); + break; + } + uint8_t sak = sak_buf[0]; + + if (sak & 0x04) { // Cascade bit + this->cascade_level_++; + if (this->cascade_level_ >= std::size(SEL_CMDS)) { + ESP_LOGE(TAG, "Too many cascade levels"); + this->finish_scan_(); + break; + } + this->send_anticol_frame_(); + this->state_ = STATE_ANTICOL; + this->last_state_change_ = now; + break; + } + + size_t uid_len = this->scan_uid_.size(); + if (uid_len != 4 && uid_len != 7 && uid_len != 10) { + ESP_LOGW(TAG, "Discarding invalid UID len=%u", static_cast(uid_len)); + this->finish_scan_(); + break; + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + ESP_LOGV(TAG, "Tag selected: %s (SAK=0x%02X)", nfc::format_uid_to(uid_buf, this->scan_uid_), sak); +#endif + this->tag_found_this_scan_ = true; + this->finish_scan_(); + break; + } + } +} + +void ST25R::finish_scan_() { + this->write_register(ST25R_REG_OP_CONTROL, 0x80); // en=1: field off until the next scan + this->state_ = STATE_IDLE; + this->irq_status_ = 0; + this->finalize_scan_(); + this->disable_loop(); +} + +void ST25R::finalize_scan_() { + if (this->current_tag_ != nullptr) { + const auto &uid = this->current_tag_->get_uid(); + if (this->tag_found_this_scan_ && uid.size() == this->scan_uid_.size() && + std::equal(uid.begin(), uid.end(), this->scan_uid_.begin())) { + this->miss_count_ = 0; + return; + } + if (!this->tag_found_this_scan_ && ++this->miss_count_ < MISS_THRESHOLD) + return; + this->remove_tag_(); + } + + if (!this->tag_found_this_scan_) + return; + + this->current_tag_ = make_unique(this->scan_uid_); + this->miss_count_ = 0; + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + nfc::format_uid_to(uid_buf, this->scan_uid_); + ESP_LOGI(TAG, "Tag detected: %s", uid_buf); + if (!this->on_tag_callback_.empty()) + this->on_tag_callback_.call(std::string(uid_buf), *this->current_tag_); +#ifdef NFC_TAG_LISTENER_COUNT + for (auto *listener : this->tag_listeners_) + listener->tag_on(*this->current_tag_); +#endif +} + +void ST25R::remove_tag_() { + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + nfc::format_uid_to(uid_buf, this->current_tag_->get_uid()); + ESP_LOGI(TAG, "Tag removed: %s", uid_buf); + if (!this->on_tag_removed_callback_.empty()) + this->on_tag_removed_callback_.call(std::string(uid_buf), *this->current_tag_); +#ifdef NFC_TAG_LISTENER_COUNT + for (auto *listener : this->tag_listeners_) + listener->tag_off(*this->current_tag_); +#endif + this->current_tag_.reset(); +} + +bool ST25R::reset_chip_() { + uint8_t ic_identity = this->read_register(ST25R_REG_IC_IDENTITY); + uint8_t chip_type = ic_identity & 0xF8; + if (chip_type != 0x28 && chip_type != 0x30) { + ESP_LOGE(TAG, "IC identity mismatch: expected 0x28/0x30, got 0x%02X", chip_type); + return false; + } + + this->write_command(ST25R_CMD_SET_DEFAULT); + delay(10); + const bool is_b_version = chip_type == 0x30; + ESP_LOGI(TAG, "IC: 0x%02X (ST25R3916%s)", ic_identity, is_b_version ? LOG_STR_LITERAL("B") : LOG_STR_LITERAL("")); + + this->write_register(ST25R_REG_OP_CONTROL, 0x80); // Enable oscillator + delay(10); + + // Measure VDD for supply voltage auto-detection + uint8_t reg_ctrl = this->read_register(ST25R_REG_REGULATOR_CONTROL); + this->write_register(ST25R_REG_REGULATOR_CONTROL, reg_ctrl & ~0x07); + this->write_command(ST25R_CMD_MEASURE_VDD); + delay(5); + uint8_t vdd_raw = this->read_register(ST25R_REG_AD_CONV_RESULT); + bool sup3v; + if (vdd_raw == 0) { + sup3v = true; + } else { + uint16_t vdd_mv = (uint16_t) vdd_raw * 23U + (((uint16_t) vdd_raw * 4U + 5U) / 10U); + sup3v = (vdd_mv < 3600); + ESP_LOGI(TAG, "VDD: %u mV (sup3V=%s)", vdd_mv, sup3v ? LOG_STR_LITERAL("3.3V") : LOG_STR_LITERAL("5V")); + } + + this->write_register(ST25R_REG_IO_CONF1, 0x07); + // SPI pull-downs and the AAT module enabled + this->write_register(ST25R_REG_IO_CONF2, (sup3v ? 0x80 : 0x00) | 0x18 | 0x20); + this->write_register_table_(NFCA_PROFILE_RX, std::size(NFCA_PROFILE_RX)); + this->write_register(ST25R_REG_TX_DRIVER_CONF, (15 - this->rf_power_) & 0x0F); + this->write_register_table_(NFCA_PROFILE_TX, std::size(NFCA_PROFILE_TX)); + uint8_t aux_val = this->read_register(ST25R_REG_AUX); + this->write_register(ST25R_REG_AUX, aux_val & ~0x04); + this->write_register_table_(NFCA_PROFILE_CORR, std::size(NFCA_PROFILE_CORR)); + + this->field_on_(); + delay(10); + return true; +} + +void ST25R::send_anticol_frame_() { + uint8_t frame[2] = {SEL_CMDS[this->cascade_level_], 0x20}; + + this->write_register(ST25R_REG_ISO14443A_CONF, 0x01); // antcl=1 + this->clear_irqs_(); + this->write_fifo(frame, sizeof(frame)); + this->write_register(ST25R_REG_NUM_TX_BYTES1, 0x00); + this->write_register(ST25R_REG_NUM_TX_BYTES2, 0x10); + this->write_command(ST25R_CMD_TRANSMIT_WITHOUT_CRC); +} + +void ST25R::field_on_() { + this->write_register(ST25R_REG_OP_CONTROL, 0x88); // en=1, tx_en=1 + delay(10); + this->write_command(ST25R_CMD_FIELD_ON); + delay(10); + this->write_register(ST25R_REG_OP_CONTROL, 0xC8); // en=1, rx_en=1, tx_en=1 + this->write_command(ST25R_CMD_ADJUST_REGULATORS); +} + +void ST25R::dump_config() { + ESP_LOGCONFIG(TAG, + "ST25R:\n" + " RF Power: %u", + this->rf_power_); + LOG_PIN(" IRQ Pin: ", this->irq_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::st25r diff --git a/esphome/components/st25r/st25r.h b/esphome/components/st25r/st25r.h new file mode 100644 index 00000000000..b16b6503fef --- /dev/null +++ b/esphome/components/st25r/st25r.h @@ -0,0 +1,138 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/nfc/nfc_tag.h" +#include +#include + +namespace esphome::st25r { + +// Space B registers have bit 0x40 set; the transport sends the 0xFB prefix before their address +enum ST25RRegister : uint8_t { + ST25R_REG_IO_CONF1 = 0x00, + ST25R_REG_IO_CONF2 = 0x01, + ST25R_REG_OP_CONTROL = 0x02, + ST25R_REG_MODE = 0x03, + ST25R_REG_BIT_RATE = 0x04, + ST25R_REG_ISO14443A_CONF = 0x05, + ST25R_REG_PASSIVE_TARGET = 0x08, + ST25R_REG_AUX = 0x0A, + ST25R_REG_RX_CONF1 = 0x0B, + ST25R_REG_RX_CONF2 = 0x0C, + ST25R_REG_RX_CONF3 = 0x0D, + ST25R_REG_RX_CONF4 = 0x0E, + ST25R_REG_MASK_MAIN = 0x16, + ST25R_REG_MASK_TIMER = 0x17, + ST25R_REG_IRQ_MAIN = 0x1A, + ST25R_REG_IRQ_TIMER = 0x1B, + ST25R_REG_IRQ_ERROR = 0x1C, + ST25R_REG_FIFO_STATUS1 = 0x1E, + ST25R_REG_NUM_TX_BYTES1 = 0x22, + ST25R_REG_NUM_TX_BYTES2 = 0x23, + ST25R_REG_AD_CONV_RESULT = 0x25, + ST25R_REG_ANT_TUNE_A = 0x26, + ST25R_REG_ANT_TUNE_B = 0x27, + ST25R_REG_TX_DRIVER_CONF = 0x28, + ST25R_REG_PT_MOD = 0x29, + ST25R_REG_FIELD_THRESHOLD_ACTV = 0x2A, + ST25R_REG_FIELD_THRESHOLD_DEACTV = 0x2B, + ST25R_REG_REGULATOR_CONTROL = 0x2C, + ST25R_REG_IC_IDENTITY = 0x3F, + ST25R_REG_EMD_SUP_CONF = 0x45, + ST25R_REG_CORR_CONF1 = 0x4C, + ST25R_REG_CORR_CONF2 = 0x4D, + ST25R_REG_AUX_MOD = 0x68, + ST25R_REG_RES_AM_MOD = 0x6A, + ST25R_REG_OVERSHOOT_CONF1 = 0x70, + ST25R_REG_OVERSHOOT_CONF2 = 0x71, + ST25R_REG_UNDERSHOOT_CONF1 = 0x72, + ST25R_REG_UNDERSHOOT_CONF2 = 0x73, +}; + +enum ST25RCommand : uint8_t { + ST25R_CMD_SET_DEFAULT = 0xC1, + ST25R_CMD_CLEAR_FIFO = 0xC3, + ST25R_CMD_TRANSMIT_WITH_CRC = 0xC4, + ST25R_CMD_TRANSMIT_WITHOUT_CRC = 0xC5, + ST25R_CMD_TRANSMIT_WUPA = 0xC7, + ST25R_CMD_FIELD_ON = 0xC8, + ST25R_CMD_RESET_RX_GAIN = 0xD5, + ST25R_CMD_ADJUST_REGULATORS = 0xD6, + ST25R_CMD_MEASURE_VDD = 0xDF, +}; + +class ST25R : public PollingComponent, public nfc::Nfcc { + public: + enum State : uint8_t { + STATE_IDLE, + STATE_FIELD_ON, + STATE_WUPA, + STATE_ANTICOL, + }; + + void setup() override; + void dump_config() override; + void update() override; + void loop() override; + + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void set_irq_pin(InternalGPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } + void set_rf_power(uint8_t power) { this->rf_power_ = power; } + + template void add_on_tag_callback(F &&callback) { this->on_tag_callback_.add(std::forward(callback)); } + template void add_on_tag_removed_callback(F &&callback) { + this->on_tag_removed_callback_.add(std::forward(callback)); + } + + protected: + virtual uint8_t read_register(uint8_t reg) = 0; + virtual void write_register(uint8_t reg, uint8_t value) = 0; + virtual void write_command(uint8_t command) = 0; + virtual void write_fifo(const uint8_t *data, size_t len) = 0; + virtual void read_fifo(uint8_t *data, size_t len) = 0; + + bool reset_chip_(); + void pulse_reset_(); + void clear_irqs_(); + void write_register_table_(const uint8_t (*table)[2], size_t count); + void field_on_(); + void send_anticol_frame_(); + bool transceive_ex_(const uint8_t *data, size_t len, uint8_t *resp, uint8_t resp_cap, uint8_t &resp_len, + uint32_t timeout_ms); + void process_state_(); + void finish_scan_(); + void finalize_scan_(); + void remove_tag_(); + static void IRAM_ATTR isr(ST25R *arg); + + GPIOPin *reset_pin_{nullptr}; + InternalGPIOPin *irq_pin_{nullptr}; + + // Consecutive empty polls before a tag counts as removed + static constexpr uint8_t MISS_THRESHOLD = 3; + static constexpr uint32_t FIELD_GUARD_MS = 10; + + // IRQ_MAIN bit definitions + static constexpr uint8_t IRQ_RXE = 0x10; + static constexpr uint8_t IRQ_TXE = 0x08; + static constexpr uint8_t IRQ_COL = 0x04; + + std::unique_ptr current_tag_; + nfc::NfcTagUid scan_uid_; + uint32_t last_state_change_{0}; + State state_{STATE_IDLE}; + uint8_t rf_power_{15}; + uint8_t miss_count_{0}; + uint8_t irq_status_{0}; + uint8_t cascade_level_{0}; + bool tag_found_this_scan_{false}; + volatile bool irq_fired_{false}; + + LazyCallbackManager on_tag_callback_; + LazyCallbackManager on_tag_removed_callback_; +}; + +} // namespace esphome::st25r diff --git a/esphome/components/st25r_spi/__init__.py b/esphome/components/st25r_spi/__init__.py new file mode 100644 index 00000000000..388adfaf1a1 --- /dev/null +++ b/esphome/components/st25r_spi/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import spi, st25r +import esphome.config_validation as cv +from esphome.types import ConfigType + +AUTO_LOAD = ["st25r"] +CODEOWNERS = ["@JohnMcLear"] +DEPENDENCIES = ["spi"] +DOMAIN = "st25r_spi" +MULTI_CONF = True + +st25r_spi_ns = cg.esphome_ns.namespace("st25r_spi") +ST25RSpi = st25r_spi_ns.class_("ST25RSpi", st25r.ST25R, spi.SPIDevice) + +CONFIG_SCHEMA = st25r.ST25R_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ST25RSpi), + } +).extend(spi.spi_device_schema(cs_pin_required=True)) + + +async def to_code(config: ConfigType) -> None: + var = await spi.new_spi_component(config) + await st25r.setup_st25r(var, config) diff --git a/esphome/components/st25r_spi/st25r_spi.cpp b/esphome/components/st25r_spi/st25r_spi.cpp new file mode 100644 index 00000000000..58fafaab1c0 --- /dev/null +++ b/esphome/components/st25r_spi/st25r_spi.cpp @@ -0,0 +1,59 @@ +#include "st25r_spi.h" +#include "esphome/core/log.h" + +namespace esphome::st25r_spi { + +ESPHOME_LOG_TAG(TAG, "st25r_spi"); + +void ST25RSpi::setup() { + this->spi_setup(); + st25r::ST25R::setup(); +} + +void ST25RSpi::dump_config() { + st25r::ST25R::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_); +} + +uint8_t ST25RSpi::read_register(uint8_t reg) { + this->enable(); + if (reg & 0x40) { + this->write_byte(0xFB); // Space B prefix + } + this->write_byte(0x40 | (reg & 0x3F)); + uint8_t value = this->read_byte(); + this->disable(); + return value; +} + +void ST25RSpi::write_register(uint8_t reg, uint8_t value) { + this->enable(); + if (reg & 0x40) { + this->write_byte(0xFB); // Space B prefix + } + this->write_byte(reg & 0x3F); + this->write_byte(value); + this->disable(); +} + +void ST25RSpi::write_command(uint8_t command) { + this->enable(); + this->write_byte(command); + this->disable(); +} + +void ST25RSpi::write_fifo(const uint8_t *data, size_t len) { + this->enable(); + this->write_byte(0x80); + this->write_array(data, len); + this->disable(); +} + +void ST25RSpi::read_fifo(uint8_t *data, size_t len) { + this->enable(); + this->write_byte(0x9F); + this->read_array(data, len); + this->disable(); +} + +} // namespace esphome::st25r_spi diff --git a/esphome/components/st25r_spi/st25r_spi.h b/esphome/components/st25r_spi/st25r_spi.h new file mode 100644 index 00000000000..7cf5b40699f --- /dev/null +++ b/esphome/components/st25r_spi/st25r_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/st25r/st25r.h" + +namespace esphome::st25r_spi { + +class ST25RSpi : public st25r::ST25R, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + protected: + uint8_t read_register(uint8_t reg) override; + void write_register(uint8_t reg, uint8_t value) override; + void write_command(uint8_t command) override; + void write_fifo(const uint8_t *data, size_t len) override; + void read_fifo(uint8_t *data, size_t len) override; +}; + +} // namespace esphome::st25r_spi diff --git a/tests/components/st25r_spi/common.yaml b/tests/components/st25r_spi/common.yaml new file mode 100644 index 00000000000..c1621873653 --- /dev/null +++ b/tests/components/st25r_spi/common.yaml @@ -0,0 +1,21 @@ +st25r_spi: + id: st25r_nfcc_spi + cs_pin: ${cs_pin} + irq_pin: ${irq_pin} + reset_pin: ${reset_pin} + on_tag: + then: + - logger.log: + format: "Tag %s (%s)" + args: [x.c_str(), tag.get_tag_type().c_str()] + on_tag_removed: + then: + - logger.log: + format: "Tag removed %s" + args: [x.c_str()] + +binary_sensor: + - platform: nfc + nfcc_id: st25r_nfcc_spi + name: ST25R NFC Tag + uid: 74-10-37-94 diff --git a/tests/components/st25r_spi/test.esp32-idf.yaml b/tests/components/st25r_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..f9d45c7abf8 --- /dev/null +++ b/tests/components/st25r_spi/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO12 + irq_pin: GPIO13 + reset_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + st25r_spi: !include common.yaml diff --git a/tests/components/st25r_spi/test.esp8266-ard.yaml b/tests/components/st25r_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e89227bfde6 --- /dev/null +++ b/tests/components/st25r_spi/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO15 + irq_pin: GPIO4 + reset_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + st25r_spi: !include common.yaml From fd902a646ac2b0cfa5650edc1b2d16740135f009 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 01:09:13 -1000 Subject: [PATCH 31/43] [ota] Accept an unknown image size in the RP2040 backend so web_server OTA works (#21178) --- esphome/components/ota/ota_backend.h | 5 +++-- .../components/ota/ota_backend_arduino_rp2.cpp | 15 ++++++++++++--- tests/components/web_server/test.rp2040-ard.yaml | 3 +++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 840d1ac11c7..3e8b5954906 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -122,8 +122,9 @@ class UpdateLock { // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: -// - begin: prepare for an image of the given size; ota_type defaults to an -// app update, so both call forms must be accepted. +// - begin: prepare for an image of the given size; 0 means the size is unknown +// (web_server upload) and the backend allows the largest image it can take. +// ota_type defaults to an app update, so both call forms must be accepted. // - set_update_md5: expected digest of the incoming image, hex string. // - write: consume the next chunk; end: finalize and mark bootable. // - abort: safe to call in any state, including after end(). diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index d4d94be2363..6c56819c281 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -9,6 +9,12 @@ #include +// LittleFS region bounds, provided by the arduino-pico linker script. +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +extern "C" uint8_t _FS_start; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +extern "C" uint8_t _FS_end; + namespace esphome::ota { ESPHOME_LOG_TAG(TAG, "ota"); @@ -21,8 +27,11 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } - // OTA size of 0 is not currently handled, but - // web_server is not supported for RP2040, so this is not an issue. + // 0 means the size is unknown (web_server upload). The updater only checks the size + // against the LittleFS region it stages the image in, so ask for all of it. + if (image_size == 0) { + image_size = reinterpret_cast(&_FS_end) - reinterpret_cast(&_FS_start); + } bool ret = Update.begin(image_size, U_FLASH); if (ret) { rp2::preferences_prevent_write(true); @@ -36,7 +45,7 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG; if (error == UPDATE_ERROR_FLASH_CONFIG) return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG; - if (error == UPDATE_ERROR_SPACE) + if (error == UPDATE_ERROR_SPACE || error == UPDATE_ERROR_SIZE) return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE; ESP_LOGE(TAG, "Begin error: %d", error); diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 6a01180892a..56bd155717f 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -6,3 +6,6 @@ web_server: username: admin password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic + +ota: + - platform: web_server From 6304411341ee40aeff18760e8dd0a4aa87b34290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:38:02 -1000 Subject: [PATCH 32/43] [core] Add cg.add_optional for optional plain config values (#21180) --- AGENTS.md | 6 ++++-- esphome/codegen.py | 1 + esphome/cpp_helpers.py | 7 +++++++ tests/unit_tests/test_cpp_helpers.py | 23 +++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac7975d3b00..ba0b68723ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,13 +80,15 @@ file does, and it is the authority when they disagree. The most useful starting ```python # Bad - looks up CONF_BLAH twice if CONF_BLAH in config: - cg.add(var.set_blah(config[CONF_BLAH])) + cg.add(var.set_blah_ms(config[CONF_BLAH].total_milliseconds)) # Good - single lookup, value bound inline if (blah := config.get(CONF_BLAH)) is not None: - cg.add(var.set_blah(blah)) + cg.add(var.set_blah_ms(blah.total_milliseconds)) ``` The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + When the setter takes the value unchanged, use `cg.add_optional(var.set_blah, config.get(CONF_BLAH))` + instead; it is not awaited, and it skips only `None`, so `0` and `False` are still set. * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. diff --git a/esphome/codegen.py b/esphome/codegen.py index cf569b7bff7..1fd9379212e 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -53,6 +53,7 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + add_optional, add_pin, add_variable, build_registry_entry, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index ba26d14bc98..91f7e67989f 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -251,6 +251,13 @@ async def add_variable( return var +def add_optional[T](setter: Callable[[T], Expression], value: T | None) -> T | None: + """Pass ``value`` to ``setter`` and return it; skip None.""" + if value is not None: + add(setter(value)) + return value + + def set_setup_priority(var, priority: float) -> None: """Emit a setup-priority override for the given component. diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 4c3a425ae3e..cda5ea89ad0 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -339,3 +339,26 @@ async def test_add_helpers_pass_value_to_setter( lookup_mock.assert_awaited_once_with(arg) setter.assert_called_once_with(value) add_mock.assert_called_once_with(setter.return_value) + + +@pytest.mark.parametrize("value", [0, False, "", 5]) +def test_add_optional_passes_value_to_setter( + monkeypatch: pytest.MonkeyPatch, value: Any +) -> None: + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + + assert ch.add_optional(setter, value) is value + setter.assert_called_once_with(value) + add_mock.assert_called_once_with(setter.return_value) + + +def test_add_optional_skips_none(monkeypatch: pytest.MonkeyPatch) -> None: + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + + assert ch.add_optional(setter, None) is None + setter.assert_not_called() + add_mock.assert_not_called() From 74e105726860f834d6ef3d76573d3d132ae0adef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:39:49 -1000 Subject: [PATCH 33/43] [yashima][remote_base] Send IR frame bytes with item_bytes, including most significant bit first (#21171) --- .../components/remote_base/midea_protocol.cpp | 34 +++++++------------ esphome/components/yashima/yashima.cpp | 13 +++---- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/esphome/components/remote_base/midea_protocol.cpp b/esphome/components/remote_base/midea_protocol.cpp index 57aeda2e90a..74372717112 100644 --- a/esphome/components/remote_base/midea_protocol.cpp +++ b/esphome/components/remote_base/midea_protocol.cpp @@ -13,6 +13,15 @@ static const int32_t BIT_ONE_SPACE_US = 3 * TICK_US; static const int32_t BIT_ZERO_SPACE_US = 1 * TICK_US; static const int32_t FOOTER_MARK_US = 1 * TICK_US; static const int32_t FOOTER_SPACE_US = 10 * TICK_US; +static constexpr BitTimings BIT_TIMINGS{.bit_mark = BIT_MARK_US, + .one_space = BIT_ONE_SPACE_US, + .zero_space = BIT_ZERO_SPACE_US, + .bit_order = BitOrder::BIT_ORDER_MSB_FIRST}; +// The second copy of the frame is sent inverted +static constexpr BitTimings INVERTED_BIT_TIMINGS{.bit_mark = BIT_MARK_US, + .one_space = BIT_ZERO_SPACE_US, + .zero_space = BIT_ONE_SPACE_US, + .bit_order = BitOrder::BIT_ORDER_MSB_FIRST}; uint8_t MideaData::calc_cs_() const { uint8_t cs = 0; @@ -30,34 +39,15 @@ void MideaProtocol::encode(RemoteTransmitData *dst, const MideaData &src) { dst->set_carrier_frequency(38000); dst->reserve(2 + 48 * 2 + 2 + 2 + 48 * 2 + 1); dst->item(HEADER_MARK_US, HEADER_SPACE_US); - for (unsigned idx = 0; idx < 6; idx++) { - for (uint8_t mask = 1 << 7; mask; mask >>= 1) - dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); - } + dst->item_bytes(src.data(), src.size()); dst->item(FOOTER_MARK_US, FOOTER_SPACE_US); dst->item(HEADER_MARK_US, HEADER_SPACE_US); - for (unsigned idx = 0; idx < 6; idx++) { - for (uint8_t mask = 1 << 7; mask; mask >>= 1) - dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ZERO_SPACE_US : BIT_ONE_SPACE_US); - } + dst->item_bytes(src.data(), src.size()); dst->mark(FOOTER_MARK_US); } static bool decode_data(RemoteReceiveData &src, MideaData &dst) { - for (unsigned idx = 0; idx < 6; idx++) { - uint8_t data = 0; - for (uint8_t mask = 1 << 7; mask; mask >>= 1) { - if (!src.expect_mark(BIT_MARK_US)) - return false; - if (src.expect_space(BIT_ONE_SPACE_US)) { - data |= mask; - } else if (!src.expect_space(BIT_ZERO_SPACE_US)) { - return false; - } - } - dst[idx] = data; - } - return true; + return src.expect_bytes(dst.data(), dst.size()); } optional MideaProtocol::decode(RemoteReceiveData src) { diff --git a/esphome/components/yashima/yashima.cpp b/esphome/components/yashima/yashima.cpp index 986237b2d69..6383a3c4a6f 100644 --- a/esphome/components/yashima/yashima.cpp +++ b/esphome/components/yashima/yashima.cpp @@ -75,6 +75,10 @@ const uint16_t YASHIMA_BIT_MARK = 667; const uint16_t YASHIMA_ONE_SPACE = 517; const uint16_t YASHIMA_ZERO_SPACE = 1543; const uint32_t YASHIMA_GAP = YASHIMA_HEADER_SPACE; +static constexpr remote_base::BitTimings YASHIMA_BIT_TIMINGS{.bit_mark = YASHIMA_BIT_MARK, + .one_space = YASHIMA_ONE_SPACE, + .zero_space = YASHIMA_ZERO_SPACE, + .bit_order = remote_base::BitOrder::BIT_ORDER_MSB_FIRST}; const uint32_t YASHIMA_CARRIER_FREQUENCY = 38000; @@ -181,14 +185,7 @@ void YashimaClimate::transmit_state_() { // Header data->mark(YASHIMA_HEADER_MARK); data->space(YASHIMA_HEADER_SPACE); - // Data (sent from the MSB to the LSB) - for (uint8_t i : remote_state) { - for (int8_t j = 7; j >= 0; j--) { - data->mark(YASHIMA_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? YASHIMA_ONE_SPACE : YASHIMA_ZERO_SPACE); - } - } + data->item_bytes(remote_state, YASHIMA_STATE_LENGTH); // Footer data->mark(YASHIMA_BIT_MARK); data->space(YASHIMA_GAP); From 7719179dcdc27087b75fbb10520fb8764c75dbf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:44:13 -1000 Subject: [PATCH 34/43] [tcl112][zhlt01][heatpumpir][fujitsu_general] Send and read IR frame bytes with the remote_base helpers (#21168) --- .../fujitsu_general/fujitsu_general.cpp | 42 +++++-------------- .../heatpumpir/receiver_mitsubishi_heavy.cpp | 17 ++------ esphome/components/tcl112/tcl112.cpp | 28 ++++--------- esphome/components/zhlt01/zhlt01.cpp | 33 +++++---------- esphome/components/zhlt01/zhlt01.h | 4 +- 5 files changed, 34 insertions(+), 90 deletions(-) diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index af411d133a8..559e6ae831a 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -66,9 +66,8 @@ constexpr uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; constexpr uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; constexpr uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; -constexpr uint16_t FUJITSU_GENERAL_BIT_MARK = 420; -constexpr uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; -constexpr uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; +static constexpr remote_base::BitTimings FUJITSU_GENERAL_BIT_TIMINGS{ + .bit_mark = 420, .one_space = 1200, .zero_space = 420}; constexpr uint16_t FUJITSU_GENERAL_TRL_MARK = 420; constexpr uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; @@ -207,14 +206,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { data->space(FUJITSU_GENERAL_HEADER_SPACE); // Data - for (uint8_t i = 0; i < length; ++i) { - const uint8_t byte = message[i]; - for (uint8_t mask = 0b00000001; mask > 0; mask <<= 1) { // write from right to left - data->mark(FUJITSU_GENERAL_BIT_MARK); - bool bit = byte & mask; - data->space(bit ? FUJITSU_GENERAL_ONE_SPACE : FUJITSU_GENERAL_ZERO_SPACE); - } - } + data->item_bytes(message, length); // Footer data->mark(FUJITSU_GENERAL_TRL_MARK); @@ -284,19 +276,12 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t recv_message[FUJITSU_GENERAL_STATE_MESSAGE_LENGTH] = {0}; + uint8_t recv_message[FUJITSU_GENERAL_STATE_MESSAGE_LENGTH]; // Read header - for (uint8_t byte = 0; byte < FUJITSU_GENERAL_COMMON_LENGTH; ++byte) { - // Read bit - for (uint8_t bit = 0; bit < 8; ++bit) { - if (data.expect_item(FUJITSU_GENERAL_BIT_MARK, FUJITSU_GENERAL_ONE_SPACE)) { - recv_message[byte] |= 1 << bit; // read from right to left - } else if (!data.expect_item(FUJITSU_GENERAL_BIT_MARK, FUJITSU_GENERAL_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", byte, bit); - return false; - } - } + if (!data.expect_bytes(recv_message, FUJITSU_GENERAL_COMMON_LENGTH)) { + ESP_LOGV(TAG, "Header bytes fail"); + return false; } const uint8_t recv_message_type = recv_message[FUJITSU_GENERAL_MESSAGE_TYPE_BYTE]; @@ -319,15 +304,10 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { } // Read message body - for (uint8_t byte = FUJITSU_GENERAL_COMMON_LENGTH; byte < recv_message_length; ++byte) { - for (uint8_t bit = 0; bit < 8; ++bit) { - if (data.expect_item(FUJITSU_GENERAL_BIT_MARK, FUJITSU_GENERAL_ONE_SPACE)) { - recv_message[byte] |= 1 << bit; // read from right to left - } else if (!data.expect_item(FUJITSU_GENERAL_BIT_MARK, FUJITSU_GENERAL_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", byte, bit); - return false; - } - } + if (!data.expect_bytes(recv_message + FUJITSU_GENERAL_COMMON_LENGTH, + recv_message_length - FUJITSU_GENERAL_COMMON_LENGTH)) { + ESP_LOGV(TAG, "Body bytes fail"); + return false; } for (uint8_t byte = 0; byte < recv_message_length; ++byte) { diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp index 004eb46c007..68d5ecfaca2 100644 --- a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp @@ -15,24 +15,15 @@ static constexpr uint8_t MODE_MASK = 0x07; static constexpr uint8_t TEMP_MASK = 0x0F; static constexpr uint8_t TEMPERATURE_BASE = 17; static constexpr uint8_t TEMPERATURE_SHIFT = 4; +static constexpr remote_base::BitTimings MITSUBISHI_HEAVY_BIT_TIMINGS{.bit_mark = MITSUBISHI_HEAVY_BIT_MARK, + .one_space = MITSUBISHI_HEAVY_ONE_SPACE, + .zero_space = MITSUBISHI_HEAVY_ZERO_SPACE}; static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE)) return false; - for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) { - uint8_t byte = 0; - for (uint8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) { - return false; - } - } - frame[i] = byte; - } - - return true; + return data.expect_bytes(frame, MITSUBISHI_HEAVY_FRAME_LENGTH); } static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index 15111338b24..d1647a522b5 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -27,8 +27,8 @@ const uint8_t TCL112_HALF_DEGREE = 0b00100000; const uint16_t TCL112_HEADER_MARK = 3100; const uint16_t TCL112_HEADER_SPACE = 1650; const uint16_t TCL112_BIT_MARK = 500; -const uint16_t TCL112_ONE_SPACE = 1100; -const uint16_t TCL112_ZERO_SPACE = 350; +static constexpr remote_base::BitTimings TCL112_BIT_TIMINGS{ + .bit_mark = TCL112_BIT_MARK, .one_space = 1100, .zero_space = 350}; const uint32_t TCL112_GAP = TCL112_HEADER_SPACE; void Tcl112Climate::transmit_state() { @@ -128,13 +128,7 @@ void Tcl112Climate::transmit_state() { data->mark(TCL112_HEADER_MARK); data->space(TCL112_HEADER_SPACE); // Data - for (uint8_t i : remote_state) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(TCL112_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? TCL112_ONE_SPACE : TCL112_ZERO_SPACE); - } - } + data->item_bytes(remote_state, TCL112_STATE_LENGTH); // Footer data->mark(TCL112_BIT_MARK); data->space(TCL112_GAP); @@ -149,18 +143,10 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t remote_state[TCL112_STATE_LENGTH] = {0}; - // Read all bytes. - for (int i = 0; i < TCL112_STATE_LENGTH; i++) { - // Read bit - for (int j = 0; j < 8; j++) { - if (data.expect_item(TCL112_BIT_MARK, TCL112_ONE_SPACE)) { - remote_state[i] |= 1 << j; - } else if (!data.expect_item(TCL112_BIT_MARK, TCL112_ZERO_SPACE)) { - ESP_LOGVV(TAG, "Byte %d bit %d fail", i, j); - return false; - } - } + uint8_t remote_state[TCL112_STATE_LENGTH]; + if (!data.expect_bytes(remote_state, TCL112_STATE_LENGTH)) { + ESP_LOGVV(TAG, "Data fail"); + return false; } // Validate footer if (!data.expect_mark(TCL112_BIT_MARK)) { diff --git a/esphome/components/zhlt01/zhlt01.cpp b/esphome/components/zhlt01/zhlt01.cpp index afca7bf8603..15dff612ea5 100644 --- a/esphome/components/zhlt01/zhlt01.cpp +++ b/esphome/components/zhlt01/zhlt01.cpp @@ -5,8 +5,10 @@ namespace esphome::zhlt01 { ESPHOME_LOG_TAG(TAG, "zhlt01.climate"); +static constexpr uint8_t ZHLT01_MESSAGE_LENGTH = 12; + void ZHLT01Climate::transmit_state() { - uint8_t ir_message[12] = {0}; + uint8_t ir_message[ZHLT01_MESSAGE_LENGTH] = {0}; // Byte 1 : Timer ir_message[1] = 0x00; // Timer off @@ -102,7 +104,7 @@ void ZHLT01Climate::transmit_state() { ir_message[11] = 0xD5; // Set checksum bytes - for (int i = 0; i < 12; i += 2) { + for (int i = 0; i < ZHLT01_MESSAGE_LENGTH; i += 2) { ir_message[i] = ~ir_message[i + 1]; } @@ -117,13 +119,7 @@ void ZHLT01Climate::transmit_state() { data->space(AC1_HDR_SPACE); // Data - for (uint8_t i : ir_message) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(AC1_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? AC1_ONE_SPACE : AC1_ZERO_SPACE); - } - } + data->item_bytes(ir_message, sizeof(ir_message)); // Footer data->mark(AC1_BIT_MARK); @@ -140,19 +136,10 @@ bool ZHLT01Climate::on_receive(remote_base::RemoteReceiveData data) { } // Decode IR message - uint8_t ir_message[12] = {0}; - // Read all bytes - for (int i = 0; i < 12; i++) { - // Read bit - for (int j = 0; j < 8; j++) { - if (data.expect_item(AC1_BIT_MARK, AC1_ONE_SPACE)) { - ir_message[i] |= 1 << j; - } else if (!data.expect_item(AC1_BIT_MARK, AC1_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", i, j); - return false; - } - } - ESP_LOGVV(TAG, "Byte %d %02X", i, ir_message[i]); + uint8_t ir_message[ZHLT01_MESSAGE_LENGTH]; + if (!data.expect_bytes(ir_message, sizeof(ir_message))) { + ESP_LOGV(TAG, "Data fail"); + return false; } // Validate footer @@ -162,7 +149,7 @@ bool ZHLT01Climate::on_receive(remote_base::RemoteReceiveData data) { } // Validate checksum - for (int i = 0; i < 12; i += 2) { + for (int i = 0; i < ZHLT01_MESSAGE_LENGTH; i += 2) { if (ir_message[i] != (uint8_t) (~ir_message[i + 1])) { ESP_LOGV(TAG, "Byte %d checksum incorrect (%02X != %02X)", i, ir_message[i], (uint8_t) (~ir_message[i + 1])); return false; diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index dba9ca8f3da..2e9e999497b 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -100,8 +100,8 @@ namespace esphome::zhlt01 { static const uint32_t AC1_HDR_MARK = 6100; static const uint32_t AC1_HDR_SPACE = 7400; static const uint32_t AC1_BIT_MARK = 500; -static const uint32_t AC1_ZERO_SPACE = 600; -static const uint32_t AC1_ONE_SPACE = 1800; +static constexpr remote_base::BitTimings AC1_BIT_TIMINGS{ + .bit_mark = AC1_BIT_MARK, .one_space = 1800, .zero_space = 600}; /******************************************************************************** * From 18120cc4a894ebb92df99396ec92bd63d147a89b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:45:03 -1000 Subject: [PATCH 35/43] [airton][ballu][delonghi][hitachi_ac424][mitsubishi] Send and read IR frame bytes with the remote_base helpers (#21169) --- esphome/components/airton/airton.cpp | 22 ++------- esphome/components/airton/airton.h | 4 +- esphome/components/ballu/ballu.cpp | 31 +++---------- esphome/components/delonghi/delonghi.cpp | 30 ++---------- esphome/components/delonghi/delonghi.h | 4 +- .../hitachi_ac424/hitachi_ac424.cpp | 24 ++-------- .../components/hitachi_ac424/hitachi_ac424.h | 4 +- esphome/components/mitsubishi/mitsubishi.cpp | 46 ++++++------------- 8 files changed, 42 insertions(+), 123 deletions(-) diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp index a20ddc510c2..dffb4bc4961 100644 --- a/esphome/components/airton/airton.cpp +++ b/esphome/components/airton/airton.cpp @@ -136,13 +136,7 @@ void AirtonClimate::transmit_state() { data->set_carrier_frequency(AIRTON_IR_FREQUENCY); data->mark(AIRTON_HEADER_MARK); data->space(AIRTON_HEADER_SPACE); - for (uint8_t payload_byte : remote_state) { - for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) { - data->mark(AIRTON_BIT_MARK); - bool bit = payload_byte & (1 << payload_bit_cursor); - data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE); - } - } + data->item_bytes(remote_state, AIRTON_STATE_FRAME_SIZE); data->mark(AIRTON_BIT_MARK); data->space(AIRTON_MESSAGE_SPACE); @@ -284,20 +278,14 @@ void AirtonClimate::parse_state_frame_(const uint8_t *frame) { } bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {}; + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE]; if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) { ESP_LOGV(TAG, "Wrong header encoding detected!"); return false; } - for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) { - for (int j = 0; j < 8; j++) { - if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) { - remote_state[i] |= 1 << j; - } else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) { - ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j); - return false; - } - } + if (!data.expect_bytes(remote_state, AIRTON_STATE_FRAME_SIZE)) { + ESP_LOGV(TAG, "Wrong modulation encoding"); + return false; } if (remote_state[0] != AIRTON_HEADER_BYTE0 || remote_state[1] != AIRTON_HEADER_BYTE1) { diff --git a/esphome/components/airton/airton.h b/esphome/components/airton/airton.h index 46762f421a8..25f15372e67 100644 --- a/esphome/components/airton/airton.h +++ b/esphome/components/airton/airton.h @@ -36,8 +36,8 @@ static constexpr uint32_t AIRTON_IR_FREQUENCY = 38000; static constexpr uint32_t AIRTON_HEADER_MARK = 6630; static constexpr uint32_t AIRTON_HEADER_SPACE = 3350; static constexpr uint32_t AIRTON_BIT_MARK = 400; -static constexpr uint32_t AIRTON_ONE_SPACE = 1260; -static constexpr uint32_t AIRTON_ZERO_SPACE = 430; +static constexpr remote_base::BitTimings AIRTON_BIT_TIMINGS{ + .bit_mark = AIRTON_BIT_MARK, .one_space = 1260, .zero_space = 430}; static constexpr uint32_t AIRTON_MESSAGE_SPACE = 100000; static constexpr uint8_t AIRTON_STATE_FRAME_SIZE = 7; diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index e18325303c8..0469ae453ca 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -8,8 +8,8 @@ ESPHOME_LOG_TAG(TAG, "ballu.climate"); const uint16_t BALLU_HEADER_MARK = 9000; const uint16_t BALLU_HEADER_SPACE = 4500; const uint16_t BALLU_BIT_MARK = 575; -const uint16_t BALLU_ONE_SPACE = 1675; -const uint16_t BALLU_ZERO_SPACE = 550; +static constexpr remote_base::BitTimings BALLU_BIT_TIMINGS{ + .bit_mark = BALLU_BIT_MARK, .one_space = 1675, .zero_space = 550}; const uint32_t BALLU_CARRIER_FREQUENCY = 38000; @@ -104,13 +104,7 @@ void BalluClimate::transmit_state() { data->mark(BALLU_HEADER_MARK); data->space(BALLU_HEADER_SPACE); // Data - for (uint8_t i : remote_state) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(BALLU_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? BALLU_ONE_SPACE : BALLU_ZERO_SPACE); - } - } + data->item_bytes(remote_state, BALLU_STATE_LENGTH); // Footer data->mark(BALLU_BIT_MARK); @@ -124,21 +118,10 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t remote_state[BALLU_STATE_LENGTH] = {0}; - // Read all bytes. - for (int i = 0; i < BALLU_STATE_LENGTH; i++) { - // Read bit - for (int j = 0; j < 8; j++) { - if (data.expect_item(BALLU_BIT_MARK, BALLU_ONE_SPACE)) { - remote_state[i] |= 1 << j; - - } else if (!data.expect_item(BALLU_BIT_MARK, BALLU_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", i, j); - return false; - } - } - - ESP_LOGVV(TAG, "Byte %d %02X", i, remote_state[i]); + uint8_t remote_state[BALLU_STATE_LENGTH]; + if (!data.expect_bytes(remote_state, BALLU_STATE_LENGTH)) { + ESP_LOGV(TAG, "Data fail"); + return false; } // Validate footer if (!data.expect_mark(BALLU_BIT_MARK)) { diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index 3ed794daea0..cb2c5127b0c 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -23,13 +23,7 @@ void DelonghiClimate::transmit_state() { data->mark(DELONGHI_HEADER_MARK); data->space(DELONGHI_HEADER_SPACE); - for (unsigned char b : remote_state) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DELONGHI_BIT_MARK); - bool bit = b & mask; - data->space(bit ? DELONGHI_ONE_SPACE : DELONGHI_ZERO_SPACE); - } - } + data->item_bytes(remote_state, DELONGHI_STATE_FRAME_SIZE); data->mark(DELONGHI_BIT_MARK); data->space(0); @@ -155,27 +149,13 @@ bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { } bool DelonghiClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DELONGHI_STATE_FRAME_SIZE] = {}; + uint8_t state_frame[DELONGHI_STATE_FRAME_SIZE]; if (!data.expect_item(DELONGHI_HEADER_MARK, DELONGHI_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DELONGHI_STATE_FRAME_SIZE; pos++) { - uint8_t byte = 0; - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(DELONGHI_BIT_MARK, DELONGHI_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(DELONGHI_BIT_MARK, DELONGHI_ZERO_SPACE)) { - return false; - } - } - state_frame[pos] = byte; - if (pos == 0) { - // frame header - if (byte != DELONGHI_ADDRESS) { - return false; - } - } - } + if (!data.expect_bytes(state_frame, DELONGHI_STATE_FRAME_SIZE) || + state_frame[0] != DELONGHI_ADDRESS) + return false; return this->parse_state_frame_(state_frame); } diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index aee7ceeddaa..ea384a67e71 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -33,8 +33,8 @@ const uint32_t DELONGHI_IR_FREQUENCY = 38000; const uint32_t DELONGHI_HEADER_MARK = 9000; const uint32_t DELONGHI_HEADER_SPACE = 4500; const uint32_t DELONGHI_BIT_MARK = 465; -const uint32_t DELONGHI_ONE_SPACE = 1750; -const uint32_t DELONGHI_ZERO_SPACE = 670; +static constexpr remote_base::BitTimings DELONGHI_BIT_TIMINGS{ + .bit_mark = DELONGHI_BIT_MARK, .one_space = 1750, .zero_space = 670}; // State Frame size const uint8_t DELONGHI_STATE_FRAME_SIZE = 8; diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index d8f7f661799..229cdad6aa8 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -224,13 +224,7 @@ void HitachiClimate::transmit_state() { // Header data->item(HITACHI_AC424_HDR_MARK, HITACHI_AC424_HDR_SPACE); // Data - for (uint8_t i : remote_state_) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(HITACHI_AC424_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? HITACHI_AC424_ONE_SPACE : HITACHI_AC424_ZERO_SPACE); - } - } + data->item_bytes(remote_state_, HITACHI_AC424_STATE_LENGTH); // Footer data->item(HITACHI_AC424_BIT_MARK, HITACHI_AC424_MIN_GAP); } @@ -320,18 +314,10 @@ bool HitachiClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t recv_state[HITACHI_AC424_STATE_LENGTH] = {0}; - // Read all bytes. - for (uint8_t pos = 0; pos < HITACHI_AC424_STATE_LENGTH; pos++) { - // Read bit - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(HITACHI_AC424_BIT_MARK, HITACHI_AC424_ONE_SPACE)) { - recv_state[pos] |= 1 << bit; - } else if (!data.expect_item(HITACHI_AC424_BIT_MARK, HITACHI_AC424_ZERO_SPACE)) { - ESP_LOGVV(TAG, "Byte %d bit %d fail", pos, bit); - return false; - } - } + uint8_t recv_state[HITACHI_AC424_STATE_LENGTH]; + if (!data.expect_bytes(recv_state, HITACHI_AC424_STATE_LENGTH)) { + ESP_LOGVV(TAG, "Data fail"); + return false; } // Validate footer diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index 31efd98c3dd..87ce79cbeb0 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -8,8 +8,8 @@ namespace esphome::hitachi_ac424 { const uint16_t HITACHI_AC424_HDR_MARK = 3416; // ac const uint16_t HITACHI_AC424_HDR_SPACE = 1604; // ac const uint16_t HITACHI_AC424_BIT_MARK = 463; -const uint16_t HITACHI_AC424_ONE_SPACE = 1208; -const uint16_t HITACHI_AC424_ZERO_SPACE = 372; +static constexpr remote_base::BitTimings HITACHI_AC424_BIT_TIMINGS{ + .bit_mark = HITACHI_AC424_BIT_MARK, .one_space = 1208, .zero_space = 372}; const uint32_t HITACHI_AC424_MIN_GAP = 100000; // just a guess. const uint16_t HITACHI_AC424_FREQ = 38000; // Hz. diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 19b8bb3fad3..08b63dd313a 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -40,8 +40,8 @@ const uint8_t MITSUBISHI_NIGHTMODE = 0xC1; // Pulse parameters in usec const uint16_t MITSUBISHI_BIT_MARK = 430; -const uint16_t MITSUBISHI_ONE_SPACE = 1250; -const uint16_t MITSUBISHI_ZERO_SPACE = 390; +static constexpr remote_base::BitTimings MITSUBISHI_BIT_TIMINGS{ + .bit_mark = MITSUBISHI_BIT_MARK, .one_space = 1250, .zero_space = 390}; const uint16_t MITSUBISHI_HEADER_MARK = 3500; const uint16_t MITSUBISHI_HEADER_SPACE = 1700; const uint16_t MITSUBISHI_MIN_GAP = 17500; @@ -102,8 +102,8 @@ void MitsubishiClimate::transmit_state() { // Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00 // Byte 16: Left vane control (for specific models). Constants match right vane controls. // Byte 17: Checksum: SUM[Byte0...Byte16] - uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t remote_state[MITSUBISHI_FRAME_SIZE] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; switch (this->mode) { case climate::CLIMATE_MODE_HEAT: @@ -259,13 +259,7 @@ void MitsubishiClimate::transmit_state() { data->mark(MITSUBISHI_HEADER_MARK); data->space(MITSUBISHI_HEADER_SPACE); // Data - for (uint8_t i : remote_state) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(MITSUBISHI_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? MITSUBISHI_ONE_SPACE : MITSUBISHI_ZERO_SPACE); - } - } + data->item_bytes(remote_state, sizeof(remote_state)); // Footer if (r == 0) { data->mark(MITSUBISHI_BIT_MARK); @@ -280,33 +274,21 @@ void MitsubishiClimate::transmit_state() { bool MitsubishiClimate::parse_state_frame_(const uint8_t frame[]) { return false; } bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[18] = {}; + uint8_t state_frame[MITSUBISHI_FRAME_SIZE]; if (!data.expect_item(MITSUBISHI_HEADER_MARK, MITSUBISHI_HEADER_SPACE)) { ESP_LOGV(TAG, "Header fail"); return false; } - for (uint8_t pos = 0; pos < 18; pos++) { - uint8_t byte = 0; - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(MITSUBISHI_BIT_MARK, MITSUBISHI_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(MITSUBISHI_BIT_MARK, MITSUBISHI_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", pos, bit); - return false; - } - } - state_frame[pos] = byte; - - // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). - if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) || - (pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) || - (pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) || - (pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) { - ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value"); - return false; - } + // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). + if (!data.expect_bytes(state_frame, sizeof(state_frame)) || + state_frame[0] != MITSUBISHI_BYTE00 || state_frame[1] != MITSUBISHI_BYTE01 || + state_frame[2] != MITSUBISHI_BYTE02 || state_frame[3] != MITSUBISHI_BYTE03 || + state_frame[4] != MITSUBISHI_BYTE04 || state_frame[13] != MITSUBISHI_BYTE13 || + (state_frame[16] & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0) { + ESP_LOGV(TAG, "Bit decode or bytes 0,1,2,3,4,13 or 16 fail"); + return false; } // On/Off and Mode From 6c4a42c069c7301dd57d1277110a944492782290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:45:43 -1000 Subject: [PATCH 36/43] [daikin][daikin_brc][daikin_arc][whirlpool][samsung] Send and read IR frame bytes with the remote_base helpers (#21170) --- esphome/components/daikin/daikin.cpp | 80 ++++++-------------- esphome/components/daikin/daikin.h | 2 + esphome/components/daikin_arc/daikin_arc.cpp | 39 ++-------- esphome/components/daikin_arc/daikin_arc.h | 2 + esphome/components/daikin_brc/daikin_brc.cpp | 62 ++++----------- esphome/components/daikin_brc/daikin_brc.h | 4 +- esphome/components/samsung/samsung.cpp | 31 +++----- esphome/components/whirlpool/whirlpool.cpp | 37 +++------ 8 files changed, 76 insertions(+), 181 deletions(-) diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 370c04fbf66..851410403c2 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -9,6 +9,14 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate"); static constexpr uint32_t BOOST_TIMEOUT_ID = 0; +static constexpr uint8_t DAIKIN_HEADER_BYTE0 = 0x11; +static constexpr uint8_t DAIKIN_HEADER_BYTE1 = 0xDA; +static constexpr uint8_t DAIKIN_HEADER_BYTE2 = 0x27; +static constexpr uint8_t DAIKIN_HEADER_BYTE3 = 0x00; +static constexpr uint8_t DAIKIN_FRAME_TYPE_INDEX = 4; +static constexpr uint8_t DAIKIN_FRAME_HEADER_SIZE = DAIKIN_FRAME_TYPE_INDEX + 1; +static constexpr uint8_t DAIKIN_STATE_FRAME_OFFSET = 2 * DAIKIN_COMFORT_FRAME_SIZE; + void DaikinClimate::setup() { climate_ir::ClimateIR::setup(); // A boost preset restored from flash gets its timer too @@ -42,37 +50,19 @@ void DaikinClimate::transmit_state() { data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (int i = 0; i < 8; i++) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = remote_state[i] & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_state, DAIKIN_COMFORT_FRAME_SIZE); data->mark(DAIKIN_BIT_MARK); data->space(DAIKIN_MESSAGE_SPACE); data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (int i = 8; i < 16; i++) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = remote_state[i] & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_state + DAIKIN_COMFORT_FRAME_SIZE, DAIKIN_COMFORT_FRAME_SIZE); data->mark(DAIKIN_BIT_MARK); data->space(DAIKIN_MESSAGE_SPACE); data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (int i = 16; i < 35; i++) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = remote_state[i] & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_state + DAIKIN_STATE_FRAME_OFFSET, DAIKIN_STATE_FRAME_SIZE); data->mark(DAIKIN_BIT_MARK); data->space(0); @@ -284,46 +274,24 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { } bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t frame[DAIKIN_STATE_FRAME_SIZE]; if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { return false; } + // Frame header, then the frame type that sets the frame size + if (!data.expect_bytes(frame, DAIKIN_FRAME_HEADER_SIZE) || frame[0] != DAIKIN_HEADER_BYTE0 || + frame[1] != DAIKIN_HEADER_BYTE1 || frame[2] != DAIKIN_HEADER_BYTE2 || frame[3] != DAIKIN_HEADER_BYTE3) { + return false; + } uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE; - for (uint8_t pos = 0; pos < frame_size; pos++) { - uint8_t byte = 0; - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ZERO_SPACE)) { - return false; - } - } - frame[pos] = byte; - if (pos == 0) { - // frame header - if (byte != 0x11) - return false; - } else if (pos == 1) { - // frame header - if (byte != 0xDA) - return false; - } else if (pos == 2) { - // frame header - if (byte != 0x27) - return false; - } else if (pos == 3) { // NOLINT(bugprone-branch-clone) - // frame header - if (byte != 0x00) - return false; - } else if (pos == 4) { - // frame type - if (byte == DAIKIN_FRAME_TYPE_COMFORT) { - frame_size = DAIKIN_COMFORT_FRAME_SIZE; - } else if (byte != DAIKIN_FRAME_TYPE_STATE) { - return false; - } - } + if (frame[DAIKIN_FRAME_TYPE_INDEX] == DAIKIN_FRAME_TYPE_COMFORT) { + frame_size = DAIKIN_COMFORT_FRAME_SIZE; + } else if (frame[DAIKIN_FRAME_TYPE_INDEX] != DAIKIN_FRAME_TYPE_STATE) { + return false; + } + if (!data.expect_bytes(frame + DAIKIN_FRAME_HEADER_SIZE, frame_size - DAIKIN_FRAME_HEADER_SIZE)) { + return false; } if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) { return false; diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 3124ba0145e..0126b132a99 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -45,6 +45,8 @@ const uint32_t DAIKIN_HEADER_SPACE = 1760; const uint32_t DAIKIN_BIT_MARK = 520; const uint32_t DAIKIN_ONE_SPACE = 1370; const uint32_t DAIKIN_ZERO_SPACE = 360; +static constexpr remote_base::BitTimings DAIKIN_BIT_TIMINGS{ + .bit_mark = DAIKIN_BIT_MARK, .one_space = DAIKIN_ONE_SPACE, .zero_space = DAIKIN_ZERO_SPACE}; const uint32_t DAIKIN_MESSAGE_SPACE = 32300; // Frame types (byte 4) and sizes diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index fa01df73d85..c1b2c8c5a46 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -39,13 +39,7 @@ void DaikinArcClimate::transmit_query_() { data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (uint8_t i : remote_header) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = i & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_header, sizeof(remote_header)); data->mark(DAIKIN_BIT_MARK); data->space(0); @@ -119,26 +113,14 @@ void DaikinArcClimate::transmit_state() { data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (uint8_t i : remote_header) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = i & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_header, sizeof(remote_header)); data->mark(DAIKIN_BIT_MARK); data->space(DAIKIN_MESSAGE_SPACE); data->mark(DAIKIN_HEADER_MARK); data->space(DAIKIN_HEADER_SPACE); - for (uint8_t i : remote_state) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BIT_MARK); - bool bit = i & mask; - data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE); - } - } + data->item_bytes(remote_state, sizeof(remote_state)); data->mark(DAIKIN_BIT_MARK); data->space(0); @@ -339,7 +321,7 @@ bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { } bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE]; bool valid_daikin_frame = false; if (data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { @@ -418,16 +400,11 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { } for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) { - uint8_t byte = 0; - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ZERO_SPACE)) { - ESP_LOGI(TAG, "non daikin_arc expect item pos: %d", pos); - return false; - } + if (!data.expect_bytes(&state_frame[pos], 1)) { + ESP_LOGI(TAG, "non daikin_arc expect item pos: %d", pos); + return false; } - state_frame[pos] = byte; + const uint8_t byte = state_frame[pos]; if (pos == 0) { // frame header if (byte != 0x11) { diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 9fac9c7ea4c..7ee943a37be 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -36,6 +36,8 @@ const uint32_t DAIKIN_HEADER_SPACE = 1760; const uint32_t DAIKIN_BIT_MARK = 400; const uint32_t DAIKIN_ONE_SPACE = 1300; const uint32_t DAIKIN_ZERO_SPACE = 480; +static constexpr remote_base::BitTimings DAIKIN_BIT_TIMINGS{ + .bit_mark = DAIKIN_BIT_MARK, .one_space = DAIKIN_ONE_SPACE, .zero_space = DAIKIN_ZERO_SPACE}; const uint32_t DAIKIN_MESSAGE_SPACE = 35000; const uint8_t DAIKIN_DBG_TOLERANCE = 25; diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index c16eea733f7..cb21404284a 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -6,6 +6,12 @@ namespace esphome::daikin_brc { ESPHOME_LOG_TAG(TAG, "daikin_brc.climate"); +static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE0 = 0x11; +static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE1 = 0xDA; +static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE2 = 0x17; +static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE3 = 0x18; +static constexpr uint8_t DAIKIN_BRC_FRAME_TYPE_STATE = 0x00; + void DaikinBrcClimate::control(const climate::ClimateCall &call) { this->mode_button_ = 0x00; if (call.get_mode().has_value()) { @@ -42,25 +48,14 @@ void DaikinBrcClimate::transmit_state() { data->mark(DAIKIN_BRC_HEADER_MARK); data->space(DAIKIN_BRC_HEADER_SPACE); - for (int i = 0; i < DAIKIN_BRC_PREAMBLE_SIZE; i++) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BRC_BIT_MARK); - bool bit = remote_state[i] & mask; - data->space(bit ? DAIKIN_BRC_ONE_SPACE : DAIKIN_BRC_ZERO_SPACE); - } - } + data->item_bytes(remote_state, DAIKIN_BRC_PREAMBLE_SIZE); data->mark(DAIKIN_BRC_BIT_MARK); data->space(DAIKIN_BRC_MESSAGE_SPACE); data->mark(DAIKIN_BRC_HEADER_MARK); data->space(DAIKIN_BRC_HEADER_SPACE); - for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE; i++) { - for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask - data->mark(DAIKIN_BRC_BIT_MARK); - bool bit = remote_state[i] & mask; - data->space(bit ? DAIKIN_BRC_ONE_SPACE : DAIKIN_BRC_ZERO_SPACE); - } - } + data->item_bytes(remote_state + DAIKIN_BRC_PREAMBLE_SIZE, + DAIKIN_BRC_TRANSMIT_FRAME_SIZE - DAIKIN_BRC_PREAMBLE_SIZE); data->mark(DAIKIN_BRC_BIT_MARK); data->space(0); @@ -232,41 +227,16 @@ bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { } bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_BRC_STATE_FRAME_SIZE] = {}; + uint8_t state_frame[DAIKIN_BRC_STATE_FRAME_SIZE]; if (!data.expect_item(DAIKIN_BRC_HEADER_MARK, DAIKIN_BRC_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DAIKIN_BRC_STATE_FRAME_SIZE; pos++) { - uint8_t byte = 0; - for (int8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(DAIKIN_BRC_BIT_MARK, DAIKIN_BRC_ONE_SPACE)) { - byte |= 1 << bit; - } else if (!data.expect_item(DAIKIN_BRC_BIT_MARK, DAIKIN_BRC_ZERO_SPACE)) { - return false; - } - } - state_frame[pos] = byte; - if (pos == 0) { - // frame header - if (byte != 0x11) - return false; - } else if (pos == 1) { - // frame header - if (byte != 0xDA) - return false; - } else if (pos == 2) { - // frame header - if (byte != 0x17) - return false; - } else if (pos == 3) { - // frame header, with this unit's address in the low 3 bits - if (byte != (0x18 | this->sub_unit_)) - return false; - } else if (pos == 4) { - // frame type - if (byte != 0x00) - return false; - } + // Frame header, with this unit's address in the low 3 bits of byte 3, then the frame type + if (!data.expect_bytes(state_frame, DAIKIN_BRC_STATE_FRAME_SIZE) || + state_frame[0] != DAIKIN_BRC_HEADER_BYTE0 || state_frame[1] != DAIKIN_BRC_HEADER_BYTE1 || + state_frame[2] != DAIKIN_BRC_HEADER_BYTE2 || state_frame[3] != (DAIKIN_BRC_HEADER_BYTE3 | this->sub_unit_) || + state_frame[4] != DAIKIN_BRC_FRAME_TYPE_STATE) { + return false; } return this->parse_state_frame_(state_frame); } diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index 5160e44a111..38a64b58a54 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -31,8 +31,8 @@ const uint32_t DAIKIN_BRC_IR_FREQUENCY = 38000; const uint32_t DAIKIN_BRC_HEADER_MARK = 5070; const uint32_t DAIKIN_BRC_HEADER_SPACE = 2140; const uint32_t DAIKIN_BRC_BIT_MARK = 370; -const uint32_t DAIKIN_BRC_ONE_SPACE = 1780; -const uint32_t DAIKIN_BRC_ZERO_SPACE = 710; +static constexpr remote_base::BitTimings DAIKIN_BRC_BIT_TIMINGS{ + .bit_mark = DAIKIN_BRC_BIT_MARK, .one_space = 1780, .zero_space = 710}; const uint32_t DAIKIN_BRC_MESSAGE_SPACE = 29410; const uint8_t DAIKIN_BRC_IR_DRY_FAN_TEMP_F = 72; // Dry/Fan mode is always 17 Celsius. diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp index 65f1cd10d6f..9eb70d9e641 100644 --- a/esphome/components/samsung/samsung.cpp +++ b/esphome/components/samsung/samsung.cpp @@ -14,8 +14,7 @@ static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; static constexpr uint32_t HEADER_MARK_US = 3000; static constexpr uint32_t HEADER_SPACE_US = 9000; static constexpr uint32_t BIT_MARK_US = 500; -static constexpr uint32_t ONE_SPACE_US = 1500; -static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr remote_base::BitTimings BIT_TIMINGS{.bit_mark = BIT_MARK_US, .one_space = 1500, .zero_space = 500}; static constexpr uint32_t SECTION_SPACE_US = 2000; static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; @@ -163,11 +162,7 @@ void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t coun uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); store_checksum(section); - for (uint8_t byte : section) { - for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { - data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); - } - } + data->item_bytes(section, SAMSUNG_AC_SECTION_LENGTH); } data->mark(BIT_MARK_US); @@ -180,29 +175,25 @@ bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { } // Decode into a local buffer so a bad frame leaves the stored state untouched - uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t frame[EXTENDED_LENGTH]; uint8_t length = EXTENDED_LENGTH; - for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { - if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + for (uint8_t s = 0; s < EXTENDED_LENGTH / SAMSUNG_AC_SECTION_LENGTH; s++) { + if (s != 0) { const bool have_separator = data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); if (!have_separator) { // A third section is optional; a missing separator after the second one ends the frame - if (i >= SAMSUNG_AC_STATE_LENGTH) { - length = i; + if (s >= SAMSUNG_AC_STATE_LENGTH / SAMSUNG_AC_SECTION_LENGTH) { + length = s * SAMSUNG_AC_SECTION_LENGTH; break; } - ESP_LOGW(TAG, "Missing section separator before byte %u", i); + ESP_LOGW(TAG, "Missing section separator before section %u", s); return false; } } - for (uint8_t bit = 0; bit < 8; bit++) { - if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { - frame[i] |= 1 << bit; - } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { - ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); - return false; - } + if (!data.expect_bytes(frame + s * SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH)) { + ESP_LOGW(TAG, "Bad bit in section %u", s); + return false; } } if (!data.expect_mark(BIT_MARK_US)) { diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 59b48014579..6d7506b433a 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -9,13 +9,15 @@ ESPHOME_LOG_TAG(TAG, "whirlpool.climate"); const uint16_t WHIRLPOOL_HEADER_MARK = 9000; const uint16_t WHIRLPOOL_HEADER_SPACE = 4494; const uint16_t WHIRLPOOL_BIT_MARK = 572; -const uint16_t WHIRLPOOL_ONE_SPACE = 1659; -const uint16_t WHIRLPOOL_ZERO_SPACE = 553; +static constexpr remote_base::BitTimings WHIRLPOOL_BIT_TIMINGS{ + .bit_mark = WHIRLPOOL_BIT_MARK, .one_space = 1659, .zero_space = 553}; const uint32_t WHIRLPOOL_GAP = 7960; const uint32_t WHIRLPOOL_CARRIER_FREQUENCY = 38000; const uint8_t WHIRLPOOL_STATE_LENGTH = 21; +static constexpr uint8_t WHIRLPOOL_FIRST_DIVIDER = 6; +static constexpr uint8_t WHIRLPOOL_SECOND_DIVIDER = 14; const uint8_t WHIRLPOOL_HEAT = 0; const uint8_t WHIRLPOOL_DRY = 3; @@ -158,19 +160,13 @@ void WhirlpoolClimate::transmit_state() { data->mark(WHIRLPOOL_HEADER_MARK); data->space(WHIRLPOOL_HEADER_SPACE); // Data - auto bytes_sent = 0; - for (uint8_t i : remote_state) { - for (uint8_t j = 0; j < 8; j++) { - data->mark(WHIRLPOOL_BIT_MARK); - bool bit = i & (1 << j); - data->space(bit ? WHIRLPOOL_ONE_SPACE : WHIRLPOOL_ZERO_SPACE); - } - bytes_sent++; - if (bytes_sent == 6 || bytes_sent == 14) { + for (uint8_t i = 0; i < WHIRLPOOL_STATE_LENGTH; i++) { + if (i == WHIRLPOOL_FIRST_DIVIDER || i == WHIRLPOOL_SECOND_DIVIDER) { // Divider data->mark(WHIRLPOOL_BIT_MARK); data->space(WHIRLPOOL_GAP); } + data->item_bytes(remote_state + i, 1); } // Footer data->mark(WHIRLPOOL_BIT_MARK); @@ -238,29 +234,18 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { bool skip_footer = false; // Read all bytes. for (int i = 0; i < WHIRLPOOL_STATE_LENGTH; i++) { - // Read bit - if (i == 6 || i == 14) { + if (i == WHIRLPOOL_FIRST_DIVIDER || i == WHIRLPOOL_SECOND_DIVIDER) { if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP)) return false; } - if (i == 14 && !data.is_valid()) { + if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) { // Remote control only sent 14 bytes, nothing more to read, not even the footer ESP_LOGV(TAG, "Remote control only sent %d bytes", i); skip_footer = true; break; } - - for (int j = 0; j < 8; j++) { - if (data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_ONE_SPACE)) { - remote_state[i] |= 1 << j; - - } else if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_ZERO_SPACE)) { - ESP_LOGV(TAG, "Byte %d bit %d fail", i, j); - return false; - } - } - - ESP_LOGVV(TAG, "Byte %d %02X", i, remote_state[i]); + if (!data.expect_bytes(remote_state + i, 1)) + return false; } // Validate footer if (!data.expect_mark(WHIRLPOOL_BIT_MARK) && !skip_footer) { From b18a5b09e7e9dbd5b4b018ae180ec4e3205f233e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:50:38 -1000 Subject: [PATCH 37/43] [tests] Drop the external component path replacements the yaml_config fixture already does (#21149) --- tests/integration/test_api_custom_services.py | 11 ----------- tests/integration/test_crc8_helper.py | 11 ----------- tests/integration/test_gpio_expander_cache.py | 11 ----------- tests/integration/test_legacy_climate_compat.py | 8 -------- tests/integration/test_legacy_fan_compat.py | 8 -------- tests/integration/test_loop_disable_enable.py | 11 ----------- .../test_scheduler_background_wake.py | 8 -------- tests/integration/test_scheduler_bulk_cleanup.py | 11 ----------- tests/integration/test_scheduler_defer_stress.py | 11 ----------- tests/integration/test_scheduler_heap_stress.py | 11 ----------- .../test_scheduler_loop_phase_timeout.py | 7 ------- .../test_scheduler_rapid_cancellation.py | 11 ----------- .../test_scheduler_recursive_timeout.py | 11 ----------- .../test_scheduler_simultaneous_callbacks.py | 11 ----------- .../test_uart_mock_bl0906_stray_rx.py | 8 -------- .../test_uart_mock_dfrobot_sen0465.py | 8 -------- .../test_uart_mock_dfrobot_sen0609.py | 8 -------- tests/integration/test_uart_mock_ld2410.py | 16 ---------------- .../test_uart_mock_ld2410_query_ack.py | 8 -------- tests/integration/test_uart_mock_ld2420.py | 16 ---------------- tests/integration/test_uart_mock_ld2450.py | 9 --------- .../test_uart_mock_ld2450_has_target_only.py | 8 -------- ...test_uart_mock_ld2450_polygon_zone_restore.py | 7 ------- ...test_uart_mock_ld2450_polygon_zone_timeout.py | 7 ------- .../test_uart_mock_ld2450_polygon_zones.py | 7 ------- tests/integration/test_uart_mock_ld2460.py | 8 -------- tests/integration/test_uart_mock_pylontech.py | 8 -------- .../integration/test_wake_loop_forces_phase_b.py | 8 -------- 28 files changed, 267 deletions(-) diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index acf69bf092b..b6001d702d8 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import re from aioesphomeapi import UserService, UserServiceArgType @@ -19,16 +18,6 @@ async def test_api_custom_services( api_client_connected: APIClientConnectedFactory, ) -> None: """Test CustomAPIDevice services work correctly with custom_services: true.""" - # Get the path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() # Track log messages diff --git a/tests/integration/test_crc8_helper.py b/tests/integration/test_crc8_helper.py index ffe6244598d..31c018efa93 100644 --- a/tests/integration/test_crc8_helper.py +++ b/tests/integration/test_crc8_helper.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import pytest @@ -17,16 +16,6 @@ async def test_crc8_helper( api_client_connected: APIClientConnectedFactory, ) -> None: """Test the CRC8 helper function through integration testing.""" - # Get the path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Track test completion with asyncio.Event test_complete = asyncio.Event() diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 1d36ca34465..8db7bbc5ef9 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import re import pytest @@ -18,16 +17,6 @@ async def test_gpio_expander_cache( api_client_connected: APIClientConnectedFactory, ) -> None: """Test gpio_expander::CachedGpioExpander correctly calls hardware functions.""" - # Get the path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - logs_done = asyncio.Event() # Patterns to match in logs - match any variation of digital_read diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py index aad71dd04ae..db37be3974c 100644 --- a/tests/integration/test_legacy_climate_compat.py +++ b/tests/integration/test_legacy_climate_compat.py @@ -11,7 +11,6 @@ in 2026.11.0 when the deprecated ClimateTraits setters are removed. from __future__ import annotations import asyncio -from pathlib import Path import aioesphomeapi from aioesphomeapi import ClimateInfo @@ -28,13 +27,6 @@ async def test_legacy_climate_compat( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that deprecated ClimateTraits custom mode setters still work end-to-end.""" - 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() async with run_compiled(yaml_config), api_client_connected() as client: diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py index 5ee41772f3d..0825a7459b8 100644 --- a/tests/integration/test_legacy_fan_compat.py +++ b/tests/integration/test_legacy_fan_compat.py @@ -10,7 +10,6 @@ in 2026.11.0 when the deprecated FanTraits setters are removed. from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import FanInfo, FanState import pytest @@ -25,13 +24,6 @@ async def test_legacy_fan_compat( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that deprecated FanTraits preset mode setters still work end-to-end.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - async with run_compiled(yaml_config), api_client_connected() as client: entities, _ = await client.list_entities_services() diff --git a/tests/integration/test_loop_disable_enable.py b/tests/integration/test_loop_disable_enable.py index 2a866b15740..2640cf3cc52 100644 --- a/tests/integration/test_loop_disable_enable.py +++ b/tests/integration/test_loop_disable_enable.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import re import pytest @@ -18,16 +17,6 @@ async def test_loop_disable_enable( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that components can disable and enable their loop() method.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Track log messages and events log_messages: list[str] = [] diff --git a/tests/integration/test_scheduler_background_wake.py b/tests/integration/test_scheduler_background_wake.py index fa7d34f4943..f89f31a6d48 100644 --- a/tests/integration/test_scheduler_background_wake.py +++ b/tests/integration/test_scheduler_background_wake.py @@ -7,7 +7,6 @@ takes the separate defer queue on multi-threaded builds. from __future__ import annotations import asyncio -from pathlib import Path import re from aioesphomeapi import ButtonInfo @@ -23,13 +22,6 @@ async def test_scheduler_background_wake( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - 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() results: dict[int, asyncio.Future[tuple[int, int]]] = { 100: loop.create_future(), diff --git a/tests/integration/test_scheduler_bulk_cleanup.py b/tests/integration/test_scheduler_bulk_cleanup.py index 973f59b838e..cc50a43f6bc 100644 --- a/tests/integration/test_scheduler_bulk_cleanup.py +++ b/tests/integration/test_scheduler_bulk_cleanup.py @@ -1,7 +1,6 @@ """Test that triggers the bulk cleanup path when to_remove_ > MAX_LOGICALLY_DELETED_ITEMS.""" import asyncio -from pathlib import Path import re from aioesphomeapi import UserService @@ -18,16 +17,6 @@ async def test_scheduler_bulk_cleanup( ) -> None: """Test that bulk cleanup path is triggered when many items are cancelled.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_event_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_scheduler_defer_stress.py b/tests/integration/test_scheduler_defer_stress.py index 345ba9434cc..befecdca40c 100644 --- a/tests/integration/test_scheduler_defer_stress.py +++ b/tests/integration/test_scheduler_defer_stress.py @@ -1,7 +1,6 @@ """Stress test for defer() thread safety with multiple threads.""" import asyncio -from pathlib import Path import re from aioesphomeapi import UserService @@ -18,16 +17,6 @@ async def test_scheduler_defer_stress( ) -> None: """Test that defer() doesn't crash when called rapidly from multiple threads.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_event_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_scheduler_heap_stress.py b/tests/integration/test_scheduler_heap_stress.py index cceadd06619..2b4b7448356 100644 --- a/tests/integration/test_scheduler_heap_stress.py +++ b/tests/integration/test_scheduler_heap_stress.py @@ -1,7 +1,6 @@ """Stress test for heap scheduler thread safety with multiple threads.""" import asyncio -from pathlib import Path import re from aioesphomeapi import UserService @@ -18,16 +17,6 @@ async def test_scheduler_heap_stress( ) -> None: """Test that set_timeout/set_interval doesn't crash when called rapidly from multiple threads.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_running_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_scheduler_loop_phase_timeout.py b/tests/integration/test_scheduler_loop_phase_timeout.py index a8b2e76a863..1b8bdac22ca 100644 --- a/tests/integration/test_scheduler_loop_phase_timeout.py +++ b/tests/integration/test_scheduler_loop_phase_timeout.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import re import pytest @@ -18,12 +17,6 @@ async def test_scheduler_loop_phase_timeout( write_yaml_config: ConfigWriter, compile_esphome: CompileFunction, ) -> None: - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) config_path = await write_yaml_config(yaml_config) binary_path = await compile_esphome(config_path) diff --git a/tests/integration/test_scheduler_rapid_cancellation.py b/tests/integration/test_scheduler_rapid_cancellation.py index 1b67e7fc338..2373716d550 100644 --- a/tests/integration/test_scheduler_rapid_cancellation.py +++ b/tests/integration/test_scheduler_rapid_cancellation.py @@ -1,7 +1,6 @@ """Rapid cancellation test - schedule and immediately cancel timeouts with string names.""" import asyncio -from pathlib import Path import re from aioesphomeapi import UserService @@ -18,16 +17,6 @@ async def test_scheduler_rapid_cancellation( ) -> None: """Test rapid schedule/cancel cycles that might expose race conditions.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_running_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_scheduler_recursive_timeout.py b/tests/integration/test_scheduler_recursive_timeout.py index 7d7131f8f69..b62a4a5bc9a 100644 --- a/tests/integration/test_scheduler_recursive_timeout.py +++ b/tests/integration/test_scheduler_recursive_timeout.py @@ -1,7 +1,6 @@ """Test for recursive timeout scheduling - scheduling timeouts from within timeout callbacks.""" import asyncio -from pathlib import Path from aioesphomeapi import UserService import pytest @@ -17,16 +16,6 @@ async def test_scheduler_recursive_timeout( ) -> None: """Test that scheduling timeouts from within timeout callbacks works correctly.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_running_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_scheduler_simultaneous_callbacks.py b/tests/integration/test_scheduler_simultaneous_callbacks.py index 66b2862eef8..7ad5a280392 100644 --- a/tests/integration/test_scheduler_simultaneous_callbacks.py +++ b/tests/integration/test_scheduler_simultaneous_callbacks.py @@ -1,7 +1,6 @@ """Simultaneous callbacks test - schedule many callbacks for the same time from multiple threads.""" import asyncio -from pathlib import Path import re from aioesphomeapi import UserService @@ -18,16 +17,6 @@ async def test_scheduler_simultaneous_callbacks( ) -> None: """Test scheduling many callbacks for the exact same time from multiple threads.""" - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - # Create a future to signal test completion loop = asyncio.get_running_loop() test_complete_future: asyncio.Future[None] = loop.create_future() diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py index 5f8a635c7f5..2969e6e82b2 100644 --- a/tests/integration/test_uart_mock_bl0906_stray_rx.py +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import EntityState, SensorInfo import pytest @@ -19,13 +18,6 @@ async def test_uart_mock_bl0906_stray_rx( api_client_connected: APIClientConnectedFactory, ) -> None: """A stray received byte does not stop the read cycle.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - lines: list[str] = [] states: dict[int, EntityState] = {} diff --git a/tests/integration/test_uart_mock_dfrobot_sen0465.py b/tests/integration/test_uart_mock_dfrobot_sen0465.py index 12fd139fad5..e7d7d422991 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0465.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0465.py @@ -9,7 +9,6 @@ from __future__ import annotations import asyncio import math -from pathlib import Path import re from aioesphomeapi import EntityState, SensorInfo @@ -30,13 +29,6 @@ async def test_uart_mock_dfrobot_sen0465( api_client_connected: APIClientConnectedFactory, ) -> None: """Boot sets passive mode and the alarm before the first reading.""" - 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() alarm_set: asyncio.Future[re.Match[str]] = loop.create_future() lines: list[str] = [] diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py index 2931ace2f4d..c11a1a495fe 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0609.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -7,7 +7,6 @@ reaches the module as setRange followed by saveConfig. from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import BinarySensorInfo, EntityState, NumberInfo, TextSensorInfo import pytest @@ -23,13 +22,6 @@ async def test_uart_mock_dfrobot_sen0609( api_client_connected: APIClientConnectedFactory, ) -> None: """Boot reads the module's settings; a number change is written and saved.""" - 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() saved = loop.create_future() diff --git a/tests/integration/test_uart_mock_ld2410.py b/tests/integration/test_uart_mock_ld2410.py index 88d6f2cbacd..1b1ce35835c 100644 --- a/tests/integration/test_uart_mock_ld2410.py +++ b/tests/integration/test_uart_mock_ld2410.py @@ -19,7 +19,6 @@ test_uart_mock_ld2410_engineering (engineering mode): from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ButtonInfo import pytest @@ -35,14 +34,6 @@ async def test_uart_mock_ld2410( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2410 data parsing with happy path, garbage, overflow, and recovery.""" - # 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 overflow warning in logs @@ -184,13 +175,6 @@ async def test_uart_mock_ld2410_engineering( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2410 engineering mode with per-gate energy, light, and multi-byte distance.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - collector = SensorStateCollector( sensor_names=[ "moving_distance", diff --git a/tests/integration/test_uart_mock_ld2410_query_ack.py b/tests/integration/test_uart_mock_ld2410_query_ack.py index a077b9aa16a..893a47cca47 100644 --- a/tests/integration/test_uart_mock_ld2410_query_ack.py +++ b/tests/integration/test_uart_mock_ld2410_query_ack.py @@ -9,7 +9,6 @@ The ACKs are synthetic, built from the layout the component parses, not device c from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ( ButtonInfo, @@ -54,13 +53,6 @@ async def test_uart_mock_ld2410_query_ack( api_client_connected: APIClientConnectedFactory, ) -> None: """Test a data frame, then a query ACK with the footer bytes in its values.""" - 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() ack_errors: list[str] = [] waited_for_frame_end = loop.create_future() diff --git a/tests/integration/test_uart_mock_ld2420.py b/tests/integration/test_uart_mock_ld2420.py index ae28da4d3e1..fa666e2c44e 100644 --- a/tests/integration/test_uart_mock_ld2420.py +++ b/tests/integration/test_uart_mock_ld2420.py @@ -20,7 +20,6 @@ test_uart_mock_ld2420_simple (simple mode): from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ButtonInfo import pytest @@ -36,14 +35,6 @@ async def test_uart_mock_ld2420( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2420 energy mode: happy path, truncated frame, overflow, and recovery.""" - # 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 overflow warning in logs @@ -167,13 +158,6 @@ async def test_uart_mock_ld2420_simple( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2420 simple mode: happy path, overflow, and 16-digit bug trigger.""" - 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 overflow warning in logs diff --git a/tests/integration/test_uart_mock_ld2450.py b/tests/integration/test_uart_mock_ld2450.py index 2469273e0ab..b24f6017f86 100644 --- a/tests/integration/test_uart_mock_ld2450.py +++ b/tests/integration/test_uart_mock_ld2450.py @@ -18,7 +18,6 @@ test_uart_mock_ld2450: from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ButtonInfo import pytest @@ -34,14 +33,6 @@ async def test_uart_mock_ld2450( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2450 data parsing with happy path, garbage, overflow, and recovery.""" - # 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 overflow warning in logs diff --git a/tests/integration/test_uart_mock_ld2450_has_target_only.py b/tests/integration/test_uart_mock_ld2450_has_target_only.py index 56e66ad65fb..a1678c409a6 100644 --- a/tests/integration/test_uart_mock_ld2450_has_target_only.py +++ b/tests/integration/test_uart_mock_ld2450_has_target_only.py @@ -8,7 +8,6 @@ Tests: from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState import pytest @@ -27,13 +26,6 @@ async def test_uart_mock_ld2450_has_target_only( api_client_connected: APIClientConnectedFactory, ) -> None: """Test has_target with no sensor platform and no presence_timeout number.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - async with ( run_compiled(yaml_config), api_client_connected() as client, diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py index 2b25e65e190..2a289179d65 100644 --- a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -8,7 +8,6 @@ Tests: from __future__ import annotations import asyncio -from pathlib import Path import socket import struct @@ -37,12 +36,6 @@ async def test_uart_mock_ld2450_polygon_zone_restore( ) -> None: """Test that polygon zones are restored after a restart, and invalid saved polygons are rejected.""" port, port_socket = reserved_tcp_port - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) clear_host_prefs(DEVICE_NAME) binary_path = await compile_esphome(await write_yaml_config(yaml_config)) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py index 9aba119713f..9f4731bca02 100644 --- a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py @@ -8,7 +8,6 @@ Tests: from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ( BinarySensorInfo, @@ -38,12 +37,6 @@ async def test_uart_mock_ld2450_polygon_zone_timeout( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that polygon zone presence follows presence_timeout.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) # The polygon and presence_timeout are saved, so a previous run would otherwise restore them clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zones.py b/tests/integration/test_uart_mock_ld2450_polygon_zones.py index c77b7288e4c..c862bdf4f8e 100644 --- a/tests/integration/test_uart_mock_ld2450_polygon_zones.py +++ b/tests/integration/test_uart_mock_ld2450_polygon_zones.py @@ -11,7 +11,6 @@ Tests: from __future__ import annotations import asyncio -from pathlib import Path import re from aioesphomeapi import ( @@ -44,12 +43,6 @@ async def test_uart_mock_ld2450_polygon_zones( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2450 polygon zone configuration and presence detection.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) # Polygons are saved, so a previous run would otherwise restore them clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2460.py b/tests/integration/test_uart_mock_ld2460.py index 4374ee92d7d..e2b8d24d74e 100644 --- a/tests/integration/test_uart_mock_ld2460.py +++ b/tests/integration/test_uart_mock_ld2460.py @@ -15,7 +15,6 @@ test_uart_mock_ld2460: from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ButtonInfo import pytest @@ -31,13 +30,6 @@ async def test_uart_mock_ld2460( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2460 frame parsing with the datasheet frames, garbage and overflow.""" - 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() overflow_seen = loop.create_future() tx_log_lines: list[str] = [] diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index a434ed5bdee..c26ca85d0df 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -12,7 +12,6 @@ Phases, all injected after the Start Scenario button press: from __future__ import annotations import asyncio -from pathlib import Path from aioesphomeapi import ButtonInfo import pytest @@ -66,13 +65,6 @@ async def test_uart_mock_pylontech( api_client_connected: APIClientConnectedFactory, ) -> None: """Test pylontech parsing with happy path, garbage, overflow and recovery.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - overflow_lines: list[str] = [] rejected_lines: list[str] = [] diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py index 5f05f07dd82..0c1432b2dc2 100644 --- a/tests/integration/test_wake_loop_forces_phase_b.py +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -20,7 +20,6 @@ With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). from __future__ import annotations import asyncio -from pathlib import Path import re import pytest @@ -37,13 +36,6 @@ async def test_wake_loop_forces_phase_b( """A wake_loop_threadsafe() call from a background thread must trigger the component phase within the next tick, even when loop_interval_ is raised well above the observation window.""" - 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() result: asyncio.Future[tuple[int, int]] = loop.create_future() From 8b951fa620e83992aa0c2f7776fb69744b77dd6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:52:26 -1000 Subject: [PATCH 38/43] [haier_tundra][tm1638][bl0940][micronova] Use Apply entities for hub forwarding buttons and switches (#21148) --- esphome/components/bl0940/bl0940.cpp | 1 + esphome/components/bl0940/button/__init__.py | 27 +++++++------------ .../button/calibration_reset_button.cpp | 18 ------------- .../bl0940/button/calibration_reset_button.h | 17 ------------ .../haier_tundra/button/__init__.py | 23 +++++----------- .../button/haier_tundra_button.cpp | 18 ------------- .../haier_tundra/button/haier_tundra_button.h | 25 ----------------- .../components/micronova/button/__init__.py | 22 +++++++-------- .../micronova/button/micronova_button.cpp | 16 ----------- .../micronova/button/micronova_button.h | 23 ---------------- esphome/components/tm1638/switch/__init__.py | 27 +++++++------------ .../tm1638/switch/tm1638_switch_led.cpp | 18 ------------- .../tm1638/switch/tm1638_switch_led.h | 21 --------------- 13 files changed, 35 insertions(+), 221 deletions(-) delete mode 100644 esphome/components/bl0940/button/calibration_reset_button.cpp delete mode 100644 esphome/components/bl0940/button/calibration_reset_button.h delete mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.cpp delete mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.h delete mode 100644 esphome/components/micronova/button/micronova_button.cpp delete mode 100644 esphome/components/micronova/button/micronova_button.h delete mode 100644 esphome/components/tm1638/switch/tm1638_switch_led.cpp delete mode 100644 esphome/components/tm1638/switch/tm1638_switch_led.h diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index ac1386df03a..82153fa5b43 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -122,6 +122,7 @@ float BL0940::calculate_energy_reference_() { float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } void BL0940::reset_calibration() { + ESP_LOGI(TAG, "Resetting calibration defaults..."); #ifdef USE_NUMBER if (this->current_calibration_number_ != nullptr && this->current_cal_ != 1) { this->current_calibration_number_->make_call().set_value(0).perform(); diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button/__init__.py index e87a647392f..690fd306163 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button/__init__.py @@ -1,28 +1,19 @@ -import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART from esphome.types import ConfigType -from .. import CONF_BL0940_ID, bl0940_ns +from .. import CONF_BL0940_ID from ..sensor import BL0940 -CalibrationResetButton = bl0940_ns.class_( - "CalibrationResetButton", button.Button, cg.Component -) - -CONFIG_SCHEMA = cv.All( - button.button_schema( - CalibrationResetButton, - entity_category=ENTITY_CATEGORY_CONFIG, - icon=ICON_RESTART, - ) - .extend({cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940)}) - .extend(cv.COMPONENT_SCHEMA) -) +CONFIG_SCHEMA = button.button_schema( + button.ApplyButton, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART, +).extend({cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940)}) async def to_code(config: ConfigType) -> None: - var = await button.new_button(config) - await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_BL0940_ID]) + await button.sub_buttons(config, parent=config[CONF_BL0940_ID]).create( + config, None, apply="reset_calibration()" + ) diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp deleted file mode 100644 index 5598818f546..00000000000 --- a/esphome/components/bl0940/button/calibration_reset_button.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "calibration_reset_button.h" -#include "../bl0940.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" -#include "esphome/core/application.h" - -namespace esphome::bl0940 { - -ESPHOME_LOG_TAG(TAG, "bl0940.button.calibration_reset"); - -void CalibrationResetButton::dump_config() { LOG_BUTTON("", "Calibration Reset Button", this); } - -void CalibrationResetButton::press_action() { - ESP_LOGI(TAG, "Resetting calibration defaults..."); - this->parent_->reset_calibration(); -} - -} // namespace esphome::bl0940 diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h deleted file mode 100644 index f5a4f50886e..00000000000 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/button/button.h" - -namespace esphome::bl0940 { - -class BL0940; // Forward declaration of BL0940 class - -class CalibrationResetButton final : public button::Button, public Component, public Parented { - public: - void dump_config() override; - - void press_action() override; -}; - -} // namespace esphome::bl0940 diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button/__init__.py index 2e919b80099..358e88b2e1b 100644 --- a/esphome/components/haier_tundra/button/__init__.py +++ b/esphome/components/haier_tundra/button/__init__.py @@ -1,18 +1,12 @@ -import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import CONF_LIGHT from esphome.types import ConfigType -from ..climate import HaierTundra, haier_tundra_ns +from ..climate import HaierTundra CODEOWNERS = ["@fauxpark"] -HaierTundraButton = haier_tundra_ns.class_( - "HaierTundraButton", button.Button, cg.Component -) -ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True) - ICON_LED_OUTLINE = "mdi:led-outline" ICON_SPRAY_BOTTLE = "mdi:spray-bottle" @@ -23,21 +17,16 @@ CONFIG_SCHEMA = cv.Schema( { cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), cv.Optional(CONF_LIGHT): button.button_schema( - HaierTundraButton, icon=ICON_LED_OUTLINE + button.ApplyButton, icon=ICON_LED_OUTLINE ), cv.Optional(CONF_SELF_CLEAN): button.button_schema( - HaierTundraButton, icon=ICON_SPRAY_BOTTLE + button.ApplyButton, icon=ICON_SPRAY_BOTTLE ), } ) async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) - for key, kind in ( - (CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT), - (CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN), - ): - if (conf := config.get(key)) is not None: - var = await button.new_button(conf, hub, kind) - await cg.register_component(var, conf) + buttons = button.sub_buttons(config, parent=config[CONF_HAIER_TUNDRA_ID]) + await buttons(CONF_LIGHT, None, apply="toggle_light()") + await buttons(CONF_SELF_CLEAN, None, apply="start_self_clean()") diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.cpp b/esphome/components/haier_tundra/button/haier_tundra_button.cpp deleted file mode 100644 index 39246530bc3..00000000000 --- a/esphome/components/haier_tundra/button/haier_tundra_button.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "haier_tundra_button.h" -#include "esphome/core/log.h" - -namespace esphome::haier_tundra { - -ESPHOME_LOG_TAG(TAG, "haier_tundra.button"); - -void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); } - -void HaierTundraButton::press_action() { - if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) { - this->parent_->toggle_light(); - } else { - this->parent_->start_self_clean(); - } -} - -} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.h b/esphome/components/haier_tundra/button/haier_tundra_button.h deleted file mode 100644 index c4888a4616a..00000000000 --- a/esphome/components/haier_tundra/button/haier_tundra_button.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/button/button.h" -#include "esphome/components/haier_tundra/haier_tundra.h" - -namespace esphome::haier_tundra { - -enum class ButtonKind : uint8_t { - BUTTON_KIND_LIGHT, - BUTTON_KIND_SELF_CLEAN, -}; - -class HaierTundraButton final : public button::Button, public Component, public Parented { - public: - HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {} - void dump_config() override; - - protected: - void press_action() override; - - ButtonKind kind_; -}; - -} // namespace esphome::haier_tundra diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 68b5b9aca69..e203ef37aa9 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -1,4 +1,3 @@ -import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.types import ConfigType @@ -9,12 +8,9 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - micronova_ns, register_micronova_writer, ) -MicroNovaButton = micronova_ns.class_("MicroNovaButton", button.Button, cg.Component) - CONF_CUSTOM_BUTTON = "custom_button" CONF_MEMORY_DATA = "memory_data" @@ -22,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), cv.Optional(CONF_CUSTOM_BUTTON): button.button_schema( - MicroNovaButton, + button.ApplyButton, ) .extend( MICRONOVA_ADDRESS_SCHEMA( @@ -35,11 +31,13 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) - - if custom_button_config := config.get(CONF_CUSTOM_BUTTON): + if conf := config.get(CONF_CUSTOM_BUTTON): register_micronova_writer() - bt = await button.new_button(custom_button_config, mv) - cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) - cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) - cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) + location = conf[CONF_MEMORY_LOCATION] + address = conf[CONF_MEMORY_ADDRESS] + data = conf[CONF_MEMORY_DATA] + await button.sub_buttons(config, parent=config[CONF_MICRONOVA_ID]).create( + conf, + None, + apply=f"queue_write_command(0x{location:02X}, 0x{address:02X}, 0x{data:02X})", + ) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp deleted file mode 100644 index 159dee2d20e..00000000000 --- a/esphome/components/micronova/button/micronova_button.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include "micronova_button.h" - -namespace esphome::micronova { - -ESPHOME_LOG_TAG(TAG, "micronova.button"); - -void MicroNovaButton::dump_config() { - LOG_BUTTON("", "Micronova button", this); - this->dump_base_config(); -} - -void MicroNovaButton::press_action() { - this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_); -} - -} // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h deleted file mode 100644 index 9f8f66ee02d..00000000000 --- a/esphome/components/micronova/button/micronova_button.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/components/micronova/micronova.h" -#include "esphome/core/component.h" -#include "esphome/components/button/button.h" - -namespace esphome::micronova { - -class MicroNovaButton final : public Component, public button::Button, public MicroNovaBaseListener { - public: - MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} - void dump_config() override; - - void set_memory_data(uint8_t f) { this->memory_data_ = f; } - uint8_t get_memory_data() { return this->memory_data_; } - - protected: - void press_action() override; - - uint8_t memory_data_ = 0; -}; - -} // namespace esphome::micronova diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index eee55dc118e..3b0fc05cb9c 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -1,28 +1,19 @@ -import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_LED from esphome.types import ConfigType -from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns +from ..display import CONF_TM1638_ID, TM1638Component -TM1638SwitchLed = tm1638_ns.class_("TM1638SwitchLed", switch.Switch, cg.Component) - - -CONFIG_SCHEMA = ( - switch.switch_schema(TM1638SwitchLed) - .extend( - { - cv.GenerateID(CONF_TM1638_ID): cv.use_id(TM1638Component), - cv.Required(CONF_LED): cv.int_range(min=0, max=7), - } - ) - .extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = switch.switch_schema(switch.ApplySwitch).extend( + { + cv.GenerateID(CONF_TM1638_ID): cv.use_id(TM1638Component), + cv.Required(CONF_LED): cv.int_range(min=0, max=7), + } ) async def to_code(config: ConfigType) -> None: - var = await switch.new_switch(config) - await cg.register_component(var, config) - cg.add(var.set_lednum(config[CONF_LED])) - await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) + await switch.sub_switches(config, parent=config[CONF_TM1638_ID]).create( + config, None, apply=f"set_led({config[CONF_LED]}, state)", publish="after" + ) diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.cpp b/esphome/components/tm1638/switch/tm1638_switch_led.cpp deleted file mode 100644 index f5573a35921..00000000000 --- a/esphome/components/tm1638/switch/tm1638_switch_led.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "tm1638_switch_led.h" -#include "esphome/core/log.h" - -namespace esphome::tm1638 { - -ESPHOME_LOG_TAG(TAG, "tm1638.led"); - -void TM1638SwitchLed::write_state(bool state) { - tm1638_->set_led(led_, state); - publish_state(state); -} - -void TM1638SwitchLed::dump_config() { - LOG_SWITCH("", "TM1638 LED", this); - ESP_LOGCONFIG(TAG, " LED: %d", led_); -} - -} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h deleted file mode 100644 index 8df4678d62d..00000000000 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/switch/switch.h" -#include "../tm1638.h" - -namespace esphome::tm1638 { - -class TM1638SwitchLed final : public switch_::Switch, public Component { - public: - void dump_config() override; - - void set_tm1638(TM1638Component *tm1638) { tm1638_ = tm1638; } - void set_lednum(int led) { led_ = led; } - - protected: - void write_state(bool state) override; - TM1638Component *tm1638_; - int led_; -}; -} // namespace esphome::tm1638 From a2fbedeac3bc6d7d2bb2cc32aa0cb59ce15005cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 02:55:50 -1000 Subject: [PATCH 39/43] [components] Set optional plain values with cg.add_optional (a-m) (#21181) --- esphome/components/bedjet/__init__.py | 3 +-- .../components/beken_spi_led_strip/light.py | 3 +-- .../components/ble_presence/binary_sensor.py | 6 ++--- esphome/components/ble_rssi/sensor.py | 6 ++--- esphome/components/ccs811/sensor.py | 3 +-- esphome/components/current_based/cover.py | 3 +-- esphome/components/deep_sleep/__init__.py | 9 +++---- .../components/display_menu_base/__init__.py | 3 +-- esphome/components/ds248x/__init__.py | 21 ++++++---------- esphome/components/endstop/cover.py | 3 +-- esphome/components/epaper_spi/display.py | 3 +-- esphome/components/esp32/gpio.py | 3 +-- esphome/components/esp32_ble/__init__.py | 12 ++++------ .../components/esp32_ble_beacon/__init__.py | 3 +-- .../components/esp32_ble_server/__init__.py | 3 +-- esphome/components/esp32_can/canbus.py | 6 ++--- .../power_management.py | 6 ++--- .../components/esp32_rmt_led_strip/light.py | 6 ++--- esphome/components/esp32_touch/__init__.py | 21 ++++++---------- esphome/components/espectre/__init__.py | 6 ++--- esphome/components/ethernet/__init__.py | 15 ++++-------- .../exponential_moving_average/sensor.py | 3 +-- esphome/components/fastled_base/__init__.py | 3 +-- esphome/components/feedback/cover.py | 3 +-- esphome/components/graph/__init__.py | 21 ++++++---------- esphome/components/haier/climate.py | 18 +++++--------- esphome/components/hbridge/fan/__init__.py | 3 +-- esphome/components/homeassistant/__init__.py | 3 +-- esphome/components/hydreon_rgxx/sensor.py | 6 ++--- esphome/components/i2c/__init__.py | 12 ++++------ esphome/components/i2s_audio/__init__.py | 9 +++---- esphome/components/ir_rf_proxy/infrared.py | 3 +-- esphome/components/it8951/display.py | 9 +++---- esphome/components/key_collector/__init__.py | 24 +++++++------------ esphome/components/ledc/output.py | 6 ++--- esphome/components/matrix_keypad/__init__.py | 6 ++--- esphome/components/mhz19/sensor.py | 3 +-- esphome/components/mics_5524/sensor.py | 3 +-- esphome/components/midea/climate.py | 12 ++++------ esphome/components/mmc5603/sensor.py | 3 +-- .../components/modbus_controller/__init__.py | 3 +-- esphome/components/mopeka_std_check/sensor.py | 3 +-- .../motion/binary_sensor/__init__.py | 3 +-- .../mpr121/binary_sensor/__init__.py | 6 ++--- esphome/components/mqtt/__init__.py | 15 ++++-------- esphome/components/mt6701_i2c/__init__.py | 12 ++++------ tests/component_tests/motion/test_motion.py | 6 ++--- 47 files changed, 115 insertions(+), 227 deletions(-) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 2bcbf557649..4ff76dc5186 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -43,5 +43,4 @@ async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: var = await ble_client.new_ble_node_component(config) await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) - if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: - cg.add(var.set_status_timeout(receive_timeout)) + cg.add_optional(var.set_status_timeout, config.get(CONF_RECEIVE_TIMEOUT)) diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 5576f286f13..8f388f0c1f6 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -111,8 +111,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - if CONF_MAX_REFRESH_RATE in config: - cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) + cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE)) chipset = CHIPSETS[config[CONF_CHIPSET]] cg.add( diff --git a/esphome/components/ble_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index a43d3561f8e..e8105c3b222 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -83,8 +83,6 @@ async def to_code(config: ConfigType) -> None: ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) - if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: - cg.add(var.set_ibeacon_major(ibeacon_major)) + cg.add_optional(var.set_ibeacon_major, config.get(CONF_IBEACON_MAJOR)) - if (ibeacon_minor := config.get(CONF_IBEACON_MINOR)) is not None: - cg.add(var.set_ibeacon_minor(ibeacon_minor)) + cg.add_optional(var.set_ibeacon_minor, config.get(CONF_IBEACON_MINOR)) diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index 6813505d587..2cef9daeed3 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -79,8 +79,6 @@ async def to_code(config: ConfigType) -> None: ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) - if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: - cg.add(var.set_ibeacon_major(ibeacon_major)) + cg.add_optional(var.set_ibeacon_major, config.get(CONF_IBEACON_MAJOR)) - if (ibeacon_minor := config.get(CONF_IBEACON_MINOR)) is not None: - cg.add(var.set_ibeacon_minor(ibeacon_minor)) + cg.add_optional(var.set_ibeacon_minor, config.get(CONF_IBEACON_MINOR)) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 703ae619c5d..8b6e34c9da4 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -69,8 +69,7 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) await text_sensors(CONF_VERSION, var.set_version) - if (baseline := config.get(CONF_BASELINE)) is not None: - cg.add(var.set_baseline(baseline)) + cg.add_optional(var.set_baseline, config.get(CONF_BASELINE)) await cg.add_variable(var.set_temperature, config.get(CONF_TEMPERATURE)) await cg.add_variable(var.set_humidity, config.get(CONF_HUMIDITY)) diff --git a/esphome/components/current_based/cover.py b/esphome/components/current_based/cover.py index 4edcb98a1d9..58bd2c3a24f 100644 --- a/esphome/components/current_based/cover.py +++ b/esphome/components/current_based/cover.py @@ -117,8 +117,7 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK])) - if (max_duration := config.get(CONF_MAX_DURATION)) is not None: - cg.add(var.set_max_duration(max_duration)) + cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION)) cg.add(var.set_malfunction_detection(config[CONF_MALFUNCTION_DETECTION])) if malfunction_action := config.get(CONF_MALFUNCTION_ACTION): await automation.build_automation( diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 69aa1120ba1..98ab35ac918 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -364,8 +364,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_SLEEP_DURATION in config: - cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) + cg.add_optional(var.set_sleep_duration, config.get(CONF_SLEEP_DURATION)) if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: cg.add(var.init_wakeup_pins(len(wakeup_pins))) @@ -380,8 +379,7 @@ async def to_code(config: ConfigType) -> None: ) else: await cg.add_pin(var.set_wakeup_pin, wakeup_pins[0][CONF_PIN]) - if CONF_WAKEUP_PIN_MODE in config: - cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) + cg.add_optional(var.set_wakeup_pin_mode, config.get(CONF_WAKEUP_PIN_MODE)) if CONF_RUN_DURATION in config: run_duration_config = config[CONF_RUN_DURATION] if not isinstance(run_duration_config, dict): @@ -427,8 +425,7 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf) cg.add_define("USE_DEEP_SLEEP_ON_WAKE") - if CONF_TOUCH_WAKEUP in config: - cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) + cg.add_optional(var.set_touch_wakeup, config.get(CONF_TOUCH_WAKEUP)) if CORE.using_zephyr: # Devices are suspended when CPU is entering a low power state # https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index ba51bdccaae..4c6d91898d8 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -277,8 +277,7 @@ async def menu_item_to_code(menu, config, parent): if CONF_ITEMS in config: for c in config[CONF_ITEMS]: await menu_item_to_code(menu, c, item) - if CONF_IMMEDIATE_EDIT in config: - cg.add(item.set_immediate_edit(config[CONF_IMMEDIATE_EDIT])) + cg.add_optional(item.set_immediate_edit, config.get(CONF_IMMEDIATE_EDIT)) if config[CONF_TYPE] == CONF_SELECT: await cg.add_variable(item.set_select_variable, config[CONF_SELECT]) if config[CONF_TYPE] == CONF_NUMBER: diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 623a7547d40..dfe7a7e94f2 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -91,20 +91,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) cg.add(var.set_channel_count(get_channel_count(config))) - if CONF_BUS_SLEEP in config: - cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP])) - if CONF_HUB_SLEEP in config: - cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP])) + cg.add_optional(var.set_bus_sleep, config.get(CONF_BUS_SLEEP)) + cg.add_optional(var.set_hub_sleep, config.get(CONF_HUB_SLEEP)) - if CONF_RESET_LOW_TIME in config: - cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME])) - if CONF_MASTER_SAMPLE_TIME in config: - cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME])) - if CONF_WRITE_0_LOW_TIME in config: - cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME])) - if CONF_RECOVERY_TIME in config: - cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME])) - if CONF_ACTIVE_PULLUP_RESISTANCE in config: - cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) + cg.add_optional(var.set_val_trstl, config.get(CONF_RESET_LOW_TIME)) + cg.add_optional(var.set_val_tmsp, config.get(CONF_MASTER_SAMPLE_TIME)) + cg.add_optional(var.set_val_tw0l, config.get(CONF_WRITE_0_LOW_TIME)) + cg.add_optional(var.set_val_trec0, config.get(CONF_RECOVERY_TIME)) + cg.add_optional(var.set_val_rwpu, config.get(CONF_ACTIVE_PULLUP_RESISTANCE)) await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/endstop/cover.py b/esphome/components/endstop/cover.py index 066326e9d46..1e7f6e44216 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -55,5 +55,4 @@ async def to_code(config: ConfigType) -> None: var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] ) - if CONF_MAX_DURATION in config: - cg.add(var.set_max_duration(config[CONF_MAX_DURATION])) + cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION)) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 35bb99ed872..1f4f1e057c1 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -252,8 +252,7 @@ async def to_code(config): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) - if CONF_RESET_DURATION in config: - cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) + cg.add_optional(var.set_reset_duration, config.get(CONF_RESET_DURATION)) if transform := config.get(CONF_TRANSFORM): transform[CONF_SWAP_XY] = False else: diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index f81584891b6..2a96719f9a9 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -265,8 +265,7 @@ async def esp32_pin_to_code(config): # (inverted bit in pin_flags_ bitfield is zero-initialized to false) if config[CONF_INVERTED]: cg.add(var.set_inverted(True)) - if CONF_DRIVE_STRENGTH in config: - cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) + cg.add_optional(var.set_drive_strength, config.get(CONF_DRIVE_STRENGTH)) flags = pins.gpio_flags_expr(config[CONF_MODE]) if config[CONF_HOLD_STATE]: flags = flags | cg.gpio_Flags.FLAG_HOLD diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 6c83d5e44fe..a308f7fed4e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -535,16 +535,12 @@ async def to_code(config: ConfigType) -> None: ): cg.add_define("ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS", None) - if CONF_AUTH_REQ_MODE in config: - cg.add(var.set_auth_req(config[CONF_AUTH_REQ_MODE])) - if CONF_MAX_KEY_SIZE in config: - cg.add(var.set_max_key_size(config[CONF_MAX_KEY_SIZE])) - if CONF_MIN_KEY_SIZE in config: - cg.add(var.set_min_key_size(config[CONF_MIN_KEY_SIZE])) + cg.add_optional(var.set_auth_req, config.get(CONF_AUTH_REQ_MODE)) + cg.add_optional(var.set_max_key_size, config.get(CONF_MAX_KEY_SIZE)) + cg.add_optional(var.set_min_key_size, config.get(CONF_MIN_KEY_SIZE)) cg.add(var.set_advertising_cycle_time(config[CONF_ADVERTISING_CYCLE_TIME])) - if (name := config.get(CONF_NAME)) is not None: - cg.add(var.set_name(name)) + cg.add_optional(var.set_name, config.get(CONF_NAME)) await cg.register_component(var, config) # Define max connections for use in C++ code (e.g., ble_server.h) diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7498228f4c6..7d49abf58b7 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -83,8 +83,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_measured_power(config[CONF_MEASURED_POWER])) # TX power control only available on native Bluetooth (not ESP-Hosted) - if CONF_TX_POWER in config: - cg.add(var.set_tx_power(config[CONF_TX_POWER])) + cg.add_optional(var.set_tx_power, config.get(CONF_TX_POWER)) cg.add_define("USE_ESP32_BLE_ADVERTISING") diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d37b2f4020d..001988fe92b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -647,8 +647,7 @@ async def to_code(config): ) ) ) - if CONF_MANUFACTURER_DATA in config: - cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA])) + cg.add_optional(var.set_manufacturer_data, config.get(CONF_MANUFACTURER_DATA)) for service_config in config[CONF_SERVICES]: # Calculate the optimal number of handles based on the number of characteristics and descriptors num_handles = calculate_num_handles(service_config) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index f8f76b2d578..e62578a703c 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -157,10 +157,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) cg.add(var.set_mode(config[CONF_MODE])) - if (rx_queue_len := config.get(CONF_RX_QUEUE_LEN)) is not None: - cg.add(var.set_rx_queue_len(rx_queue_len)) - if (tx_queue_len := config.get(CONF_TX_QUEUE_LEN)) is not None: - cg.add(var.set_tx_queue_len(tx_queue_len)) + cg.add_optional(var.set_rx_queue_len, config.get(CONF_RX_QUEUE_LEN)) + cg.add_optional(var.set_tx_queue_len, config.get(CONF_TX_QUEUE_LEN)) if CONF_TX_ENQUEUE_TIMEOUT in config: tx_enqueue_timeout_ms = config[CONF_TX_ENQUEUE_TIMEOUT].total_milliseconds diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py index e9b6d6462cd..dbe9798e90a 100644 --- a/esphome/components/esp32_power_management/power_management.py +++ b/esphome/components/esp32_power_management/power_management.py @@ -170,10 +170,8 @@ async def to_code(config: ConfigType) -> None: await power_management.register_power_management(var, config) add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) - if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: - cg.add(var.set_max_freq_mhz(max_mhz)) - if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: - cg.add(var.set_min_freq_mhz(min_mhz)) + cg.add_optional(var.set_max_freq_mhz, config.get(CONF_MAX_FREQUENCY)) + cg.add_optional(var.set_min_freq_mhz, config.get(CONF_MIN_FREQUENCY)) if config[CONF_PROFILING]: add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) if config[CONF_TRACE]: diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 571b7d93b8b..9b8bd8f2117 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -137,8 +137,7 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PIN][CONF_INVERTED]: cg.add(var.set_inverted(True)) - if CONF_MAX_REFRESH_RATE in config: - cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) + cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE)) if CONF_CHIPSET in config: chipset = CHIPSETS[config[CONF_CHIPSET]] @@ -169,5 +168,4 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) - if CONF_USE_DMA in config: - cg.add(var.set_use_dma(config[CONF_USE_DMA])) + cg.add_optional(var.set_use_dma, config.get(CONF_USE_DMA)) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index bdd16bc2eff..215f016b9cd 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -366,20 +366,13 @@ async def to_code(config: ConfigType) -> None: cg.add(touch.set_iir_filter(config[CONF_IIR_FILTER])) if variant in (VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32P4): - if CONF_FILTER_MODE in config: - cg.add(touch.set_filter_mode(config[CONF_FILTER_MODE])) - if CONF_DEBOUNCE_COUNT in config: - cg.add(touch.set_debounce_count(config[CONF_DEBOUNCE_COUNT])) - if CONF_NOISE_THRESHOLD in config: - cg.add(touch.set_noise_threshold(config[CONF_NOISE_THRESHOLD])) - if CONF_JITTER_STEP in config: - cg.add(touch.set_jitter_step(config[CONF_JITTER_STEP])) - if CONF_SMOOTH_MODE in config: - cg.add(touch.set_smooth_level(config[CONF_SMOOTH_MODE])) - if CONF_DENOISE_GRADE in config: - cg.add(touch.set_denoise_grade(config[CONF_DENOISE_GRADE])) - if CONF_DENOISE_CAP_LEVEL in config: - cg.add(touch.set_denoise_cap(config[CONF_DENOISE_CAP_LEVEL])) + cg.add_optional(touch.set_filter_mode, config.get(CONF_FILTER_MODE)) + cg.add_optional(touch.set_debounce_count, config.get(CONF_DEBOUNCE_COUNT)) + cg.add_optional(touch.set_noise_threshold, config.get(CONF_NOISE_THRESHOLD)) + cg.add_optional(touch.set_jitter_step, config.get(CONF_JITTER_STEP)) + cg.add_optional(touch.set_smooth_level, config.get(CONF_SMOOTH_MODE)) + cg.add_optional(touch.set_denoise_grade, config.get(CONF_DENOISE_GRADE)) + cg.add_optional(touch.set_denoise_cap, config.get(CONF_DENOISE_CAP_LEVEL)) if CONF_WATERPROOF_GUARD_RING in config: cg.add( touch.set_waterproof_guard_ring_pad(config[CONF_WATERPROOF_GUARD_RING]) diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index 3d9154c5d62..8323a56db0a 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -151,10 +151,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_traffic_generator_target_ip(target_ip)) if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None: cg.add(var.set_csi_traffic_multicast_group(group)) - if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None: - cg.add(var.set_motion_on_hits(on_hits)) - if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None: - cg.add(var.set_motion_off_hits(off_hits)) + cg.add_optional(var.set_motion_on_hits, config.get(CONF_MOTION_ON_HITS)) + cg.add_optional(var.set_motion_off_hits, config.get(CONF_MOTION_OFF_HITS)) if esp32.get_esp32_variant() == VARIANT_ESP32C5: band = CORE.config[WIFI_DOMAIN].get(wifi.CONF_BAND_MODE, "AUTO") cg.add( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index a4b18942026..85ef3c0b55c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -724,8 +724,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_polling_interval(config[CONF_POLLING_INTERVAL])) if _is_framework_spi_polling_mode_supported(): cg.add_define("USE_ETHERNET_SPI_POLLING_SUPPORT") - if CONF_RESET_PIN in config: - cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) + cg.add_optional(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.set_clock_speed(config[CONF_CLOCK_SPEED])) cg.add_define("USE_ETHERNET_SPI") @@ -746,8 +745,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: elif config[CONF_TYPE] in ("GENERIC", "YT8531"): # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. _add_mdio_setters(var, config) - if CONF_POWER_PIN in config: - cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + cg.add_optional(var.set_power_pin, config.get(CONF_POWER_PIN)) _add_phy_registers(var, config) else: _add_mdio_setters(var, config) @@ -755,8 +753,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_mode(clk_mode)) if (clk_pin := config[CONF_CLK][CONF_PIN]) != DEFAULT_CLK_PIN: cg.add(var.set_clk_pin(clk_pin)) - if CONF_POWER_PIN in config: - cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + cg.add_optional(var.set_power_pin, config.get(CONF_POWER_PIN)) _add_phy_registers(var, config) # Register Ethernet with the esp32 sdkconfig reconciler. It disables the @@ -782,10 +779,8 @@ async def _to_code_rp2040(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) - if CONF_INTERRUPT_PIN in config: - cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) - if CONF_RESET_PIN in config: - cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) + cg.add_optional(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + cg.add_optional(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add_define("USE_ETHERNET_SPI") cg.add_library(_RP2_SPI_LIBRARIES[config[CONF_TYPE]], None) diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py index d8f6a949969..a944e6b79de 100644 --- a/esphome/components/exponential_moving_average/sensor.py +++ b/esphome/components/exponential_moving_average/sensor.py @@ -83,8 +83,7 @@ async def to_code(config: ConfigType) -> None: if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None: cg.add(var.set_time_constant(time_constant)) - if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None: - cg.add(var.set_time_weighting(weighting)) + cg.add_optional(var.set_time_weighting, config.get(CONF_TIME_WEIGHTING)) else: cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA))) cg.add(var.set_restore(config[CONF_RESTORE])) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index 8936627aaab..8082e9f2e84 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -41,8 +41,7 @@ async def new_fastled_light(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await cg.register_component(var, config) - if CONF_MAX_REFRESH_RATE in config: - cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) + cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE)) if CORE.is_esp32: from esphome.components.esp32 import add_idf_component diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index f203e8d5186..aca5715f4f8 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -147,8 +147,7 @@ async def to_code(config: ConfigType) -> None: ) # OTHER - if CONF_MAX_DURATION in config: - cg.add(var.set_max_duration(config[CONF_MAX_DURATION])) + cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION)) cg.add(var.set_has_built_in_endstop(config[CONF_HAS_BUILT_IN_ENDSTOP])) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 9dfa8df4cc8..6ebb8113404 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -151,21 +151,14 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # Graph options - if CONF_X_GRID in config: - cg.add(var.set_grid_x(config[CONF_X_GRID])) - if CONF_Y_GRID in config: - cg.add(var.set_grid_y(config[CONF_Y_GRID])) - if CONF_BORDER in config: - cg.add(var.set_border(config[CONF_BORDER])) + cg.add_optional(var.set_grid_x, config.get(CONF_X_GRID)) + cg.add_optional(var.set_grid_y, config.get(CONF_Y_GRID)) + cg.add_optional(var.set_border, config.get(CONF_BORDER)) # Axis related options - if CONF_MIN_VALUE in config: - cg.add(var.set_min_value(config[CONF_MIN_VALUE])) - if CONF_MAX_VALUE in config: - cg.add(var.set_max_value(config[CONF_MAX_VALUE])) - if CONF_MIN_RANGE in config: - cg.add(var.set_min_range(config[CONF_MIN_RANGE])) - if CONF_MAX_RANGE in config: - cg.add(var.set_max_range(config[CONF_MAX_RANGE])) + cg.add_optional(var.set_min_value, config.get(CONF_MIN_VALUE)) + cg.add_optional(var.set_max_value, config.get(CONF_MAX_VALUE)) + cg.add_optional(var.set_min_range, config.get(CONF_MIN_RANGE)) + cg.add_optional(var.set_max_range, config.get(CONF_MAX_RANGE)) # Trace options for trace in config[CONF_TRACES]: tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 52171d1855f..dec090352bf 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -373,20 +373,14 @@ async def to_code(config): await uart.register_uart_device(var, config) cg.add(var.set_send_wifi(config[CONF_WIFI_SIGNAL])) - if CONF_CONTROL_METHOD in config: - cg.add(var.set_control_method(config[CONF_CONTROL_METHOD])) - if CONF_BEEPER in config: - cg.add(var.set_beeper_state(config[CONF_BEEPER])) - if CONF_DISPLAY in config: - cg.add(var.set_display_state(config[CONF_DISPLAY])) - if CONF_SUPPORTED_MODES in config: - cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) + cg.add_optional(var.set_control_method, config.get(CONF_CONTROL_METHOD)) + cg.add_optional(var.set_beeper_state, config.get(CONF_BEEPER)) + cg.add_optional(var.set_display_state, config.get(CONF_DISPLAY)) + cg.add_optional(var.set_supported_modes, config.get(CONF_SUPPORTED_MODES)) if CONF_SUPPORTED_SWING_MODES in config: cg.add(var.set_supported_swing_modes(config[CONF_SUPPORTED_SWING_MODES])) - if CONF_SUPPORTED_PRESETS in config: - cg.add(var.set_supported_presets(config[CONF_SUPPORTED_PRESETS])) - if CONF_ANSWER_TIMEOUT in config: - cg.add(var.set_answer_timeout(config[CONF_ANSWER_TIMEOUT])) + cg.add_optional(var.set_supported_presets, config.get(CONF_SUPPORTED_PRESETS)) + cg.add_optional(var.set_answer_timeout, config.get(CONF_ANSWER_TIMEOUT)) if CONF_ALTERNATIVE_SWING_CONTROL in config: cg.add( var.set_alternative_swing_control(config[CONF_ALTERNATIVE_SWING_CONTROL]) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 01713ba34f4..f65fc5001fa 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -60,5 +60,4 @@ async def to_code(config: ConfigType) -> None: await cg.add_variable(var.set_pin_b, config[CONF_PIN_B]) await cg.add_variable(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) - if CONF_PRESET_MODES in config: - cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) + cg.add_optional(var.set_preset_modes, config.get(CONF_PRESET_MODES)) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 6f5360bfed1..3913b3e1c73 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -91,8 +91,7 @@ def setup_home_assistant_entity( cg.add(var.set_entity_id(cg.RawExpression(buffer))) else: cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) - if CONF_ATTRIBUTE in config: - cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) + cg.add_optional(var.set_attribute, config.get(CONF_ATTRIBUTE)) cg.add_define("USE_API_HOMEASSISTANT_STATES") if state_subscription_count > 0: data = _get_data() diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index eeab5303922..ad99dfe3fd2 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -156,10 +156,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sensor(sens, i)) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESOLUTION in config: - cg.add(var.set_resolution(config[CONF_RESOLUTION])) + cg.add_optional(var.set_resolution, config.get(CONF_RESOLUTION)) cg.add(var.set_request_temperature(CONF_TEMPERATURE in config)) - if CONF_DISABLE_LED in config: - cg.add(var.set_disable_led(config[CONF_DISABLE_LED])) + cg.add_optional(var.set_disable_led, config.get(CONF_DISABLE_LED)) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 8598998f563..53fc183b0bb 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -333,11 +333,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add_optional(var.set_sda_pullup_enabled, config.get(CONF_SDA_PULLUP_ENABLED)) cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add_optional(var.set_scl_pullup_enabled, config.get(CONF_SCL_PULLUP_ENABLED)) cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) cg.add(var.set_scan(config[CONF_SCAN])) @@ -350,11 +348,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add_optional(var.set_sda_pullup_enabled, config.get(CONF_SDA_PULLUP_ENABLED)) cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add_optional(var.set_scl_pullup_enabled, config.get(CONF_SCL_PULLUP_ENABLED)) cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) cg.add(var.set_scan(config[CONF_SCAN])) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 4abc807ee3d..3a4d5467648 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -370,12 +370,9 @@ async def to_code(config: ConfigType) -> None: # Helps avoid callbacks being skipped due to processor load add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) - if CONF_I2S_LRCLK_PIN in config: - cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) - if CONF_I2S_BCLK_PIN in config: - cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) - if CONF_I2S_MCLK_PIN in config: - cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) + cg.add_optional(var.set_lrclk_pin, config.get(CONF_I2S_LRCLK_PIN)) + cg.add_optional(var.set_bclk_pin, config.get(CONF_I2S_BCLK_PIN)) + cg.add_optional(var.set_mclk_pin, config.get(CONF_I2S_MCLK_PIN)) if config[CONF_FULL_DUPLEX]: cg.add_define("USE_I2S_AUDIO_FULL_DUPLEX") diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 548ba2b1326..b4e2c40f839 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -84,5 +84,4 @@ async def to_code(config: dict[str, Any]) -> None: await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) - if CONF_RECEIVER_FREQUENCY in config: - cg.add(var.set_receiver_frequency(config[CONF_RECEIVER_FREQUENCY])) + cg.add_optional(var.set_receiver_frequency, config.get(CONF_RECEIVER_FREQUENCY)) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index bb0c1f10fbb..a98d2143583 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -376,22 +376,19 @@ async def to_code(config: ConfigType) -> None: ) ) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) - if (reset_duration := config.get(CONF_RESET_DURATION)) is not None: - cg.add(var.set_reset_duration(reset_duration)) + cg.add_optional(var.set_reset_duration, config.get(CONF_RESET_DURATION)) if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) if config.get(CONF_SLEEP_WHEN_DONE): cg.add(var.set_sleep_when_done(True)) cg.add(var.set_vcom(config[CONF_VCOM])) cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER])) - if CONF_FORCE_TEMPERATURE in config: - cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE])) + cg.add_optional(var.set_force_temperature, config.get(CONF_FORCE_TEMPERATURE)) if config.get(CONF_USE_LEGACY_DPY_AREA): cg.add(var.set_use_legacy_dpy_area(True)) cg.add(var.set_grayscale(config[CONF_GRAYSCALE])) cg.add(var.set_dithering(config[CONF_DITHERING])) - if (mode := config.get(CONF_UPDATE_MODE)) is not None: - cg.add(var.set_update_mode(mode)) + cg.add_optional(var.set_update_mode, config.get(CONF_UPDATE_MODE)) transform = config.get( CONF_TRANSFORM, diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index dc50f8cdc0d..1ecde346ea0 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -94,22 +94,14 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): await cg.add_variable(var.add_provider, source_conf) - if CONF_MIN_LENGTH in config: - cg.add(var.set_min_length(config[CONF_MIN_LENGTH])) - if CONF_MAX_LENGTH in config: - cg.add(var.set_max_length(config[CONF_MAX_LENGTH])) - if CONF_START_KEYS in config: - cg.add(var.set_start_keys(config[CONF_START_KEYS])) - if CONF_END_KEYS in config: - cg.add(var.set_end_keys(config[CONF_END_KEYS])) - if CONF_END_KEY_REQUIRED in config: - cg.add(var.set_end_key_required(config[CONF_END_KEY_REQUIRED])) - if CONF_BACK_KEYS in config: - cg.add(var.set_back_keys(config[CONF_BACK_KEYS])) - if CONF_CLEAR_KEYS in config: - cg.add(var.set_clear_keys(config[CONF_CLEAR_KEYS])) - if CONF_ALLOWED_KEYS in config: - cg.add(var.set_allowed_keys(config[CONF_ALLOWED_KEYS])) + cg.add_optional(var.set_min_length, config.get(CONF_MIN_LENGTH)) + cg.add_optional(var.set_max_length, config.get(CONF_MAX_LENGTH)) + cg.add_optional(var.set_start_keys, config.get(CONF_START_KEYS)) + cg.add_optional(var.set_end_keys, config.get(CONF_END_KEYS)) + cg.add_optional(var.set_end_key_required, config.get(CONF_END_KEY_REQUIRED)) + cg.add_optional(var.set_back_keys, config.get(CONF_BACK_KEYS)) + cg.add_optional(var.set_clear_keys, config.get(CONF_CLEAR_KEYS)) + cg.add_optional(var.set_allowed_keys, config.get(CONF_ALLOWED_KEYS)) for trigger_name, args in TRIGGER_TYPES.items(): arglist: TemplateArgsType = [(arg.type, arg.name) for arg in args] diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index bb7f8de3fe8..a606982a9b8 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -67,11 +67,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) await output.register_output(var, config) - if CONF_CHANNEL in config: - cg.add(var.set_channel(config[CONF_CHANNEL])) + cg.add_optional(var.set_channel, config.get(CONF_CHANNEL)) cg.add(var.set_frequency(config[CONF_FREQUENCY])) - if CONF_PHASE_ANGLE in config: - cg.add(var.set_phase_angle(config[CONF_PHASE_ANGLE])) + cg.add_optional(var.set_phase_angle, config.get(CONF_PHASE_ANGLE)) automation.register_apply_action( diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 8e9b21fe433..2ce699d4686 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -85,10 +85,8 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_keys(table)) cg.add(var.set_debounce_time(config[CONF_DEBOUNCE_TIME])) - if CONF_HAS_DIODES in config: - cg.add(var.set_has_diodes(config[CONF_HAS_DIODES])) - if CONF_HAS_PULLDOWNS in config: - cg.add(var.set_has_pulldowns(config[CONF_HAS_PULLDOWNS])) + cg.add_optional(var.set_has_diodes, config.get(CONF_HAS_DIODES)) + cg.add_optional(var.set_has_pulldowns, config.get(CONF_HAS_PULLDOWNS)) for conf in config.get(CONF_ON_KEY, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) cg.add(var.register_key_trigger(trigger)) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 5c5b24f6f48..585e0f06bf4 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -88,8 +88,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_warmup_seconds(config[CONF_WARMUP_TIME])) - if CONF_DETECTION_RANGE in config: - cg.add(var.set_detection_range(config[CONF_DETECTION_RANGE])) + cg.add_optional(var.set_detection_range, config.get(CONF_DETECTION_RANGE)) NO_ARGS_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index 0440a4829d9..03122f8330d 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -427,8 +427,7 @@ async def to_code(config: ConfigType) -> None: # Only a build with a datasheet model links the power law and its libm cg.add_define("USE_MICS_5524_DATASHEET") cg.add(var.set_vcc(config[CONF_VCC])) - if (rl := config.get(CONF_RL)) is not None: - cg.add(var.set_rl(rl)) + cg.add_optional(var.set_rl, config.get(CONF_RL)) cg.add(var.set_voltage_multiplier(_voltage_multiplier(config))) cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index f5c1ed71b84..2a9901db4f5 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -225,16 +225,12 @@ async def to_code(config): cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) - if CONF_SUPPORTED_MODES in config: - cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) + cg.add_optional(var.set_supported_modes, config.get(CONF_SUPPORTED_MODES)) if CONF_SUPPORTED_SWING_MODES in config: cg.add(var.set_supported_swing_modes(config[CONF_SUPPORTED_SWING_MODES])) - if CONF_SUPPORTED_PRESETS in config: - cg.add(var.set_supported_presets(config[CONF_SUPPORTED_PRESETS])) - if CONF_CUSTOM_PRESETS in config: - cg.add(var.set_custom_presets(config[CONF_CUSTOM_PRESETS])) - if CONF_CUSTOM_FAN_MODES in config: - cg.add(var.set_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES])) + cg.add_optional(var.set_supported_presets, config.get(CONF_SUPPORTED_PRESETS)) + cg.add_optional(var.set_custom_presets, config.get(CONF_CUSTOM_PRESETS)) + cg.add_optional(var.set_custom_fan_modes, config.get(CONF_CUSTOM_FAN_MODES)) sensors = sensor.sub_sensors(config) await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor) await sensors(CONF_POWER_USAGE, var.set_power_sensor) diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 874b774829e..4331c80898d 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -84,5 +84,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) await sensors(CONF_HEADING, var.set_heading_sensor) - if CONF_AUTO_SET_RESET in config: - cg.add(var.set_auto_set_reset(config[CONF_AUTO_SET_RESET])) + cg.add_optional(var.set_auto_set_reset, config.get(CONF_AUTO_SET_RESET)) diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index aa72a08a60b..e1e40b11440 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -451,8 +451,7 @@ async def add_modbus_base_properties( lambda_param_type: cg.MockObj = cg.float_, lambda_return_type: Any = float, ) -> None: - if CONF_CUSTOM_PDU in config: - cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU])) + cg.add_optional(var.set_custom_pdu, config.get(CONF_CUSTOM_PDU)) if config[CONF_RESPONSE_SIZE] > 0: cg.add(var.set_register_size(config[CONF_RESPONSE_SIZE])) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index 644684afa67..96af9067f6c 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -122,8 +122,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_tank_empty(CONF_SUPPORTED_TANKS_MAP[t][0])) cg.add(var.set_tank_full(CONF_SUPPORTED_TANKS_MAP[t][1])) - if CONF_PROPANE_BUTANE_MIX in config: - cg.add(var.set_propane_butane_mix(config[CONF_PROPANE_BUTANE_MIX])) + cg.add_optional(var.set_propane_butane_mix, config.get(CONF_PROPANE_BUTANE_MIX)) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py index 175f859e02e..9c421a18677 100644 --- a/esphome/components/motion/binary_sensor/__init__.py +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -113,5 +113,4 @@ async def to_code(config: ConfigType) -> None: # Convert the configured tilt angle (degrees) to the cosine the C++ side expects. threshold = round(math.cos(math.radians(threshold)), 6) cg.add(var.set_threshold(threshold)) - if (duration := config.get(CONF_DURATION)) is not None: - cg.add(var.set_duration(duration)) + cg.add_optional(var.set_duration, config.get(CONF_DURATION)) diff --git a/esphome/components/mpr121/binary_sensor/__init__.py b/esphome/components/mpr121/binary_sensor/__init__.py index 565789cdc33..0ce0f42f1de 100644 --- a/esphome/components/mpr121/binary_sensor/__init__.py +++ b/esphome/components/mpr121/binary_sensor/__init__.py @@ -31,9 +31,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_channel(config[CONF_CHANNEL])) await cg.register_parented(var, hub) - if CONF_TOUCH_THRESHOLD in config: - cg.add(var.set_touch_threshold(config[CONF_TOUCH_THRESHOLD])) - if CONF_RELEASE_THRESHOLD in config: - cg.add(var.set_release_threshold(config[CONF_RELEASE_THRESHOLD])) + cg.add_optional(var.set_touch_threshold, config.get(CONF_TOUCH_THRESHOLD)) + cg.add_optional(var.set_release_threshold, config.get(CONF_RELEASE_THRESHOLD)) cg.add(hub.register_channel(var)) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fe426827ce6..0eaf4a9bbfa 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -362,8 +362,7 @@ async def to_code(config): cg.add(var.set_username(config[CONF_USERNAME])) cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_clean_session(config[CONF_CLEAN_SESSION])) - if CONF_CLIENT_ID in config: - cg.add(var.set_client_id(config[CONF_CLIENT_ID])) + cg.add_optional(var.set_client_id, config.get(CONF_CLIENT_ID)) discovery = config[CONF_DISCOVERY] discovery_retain = config[CONF_DISCOVERY_RETAIN] @@ -561,12 +560,9 @@ def get_default_topic_for(data, component_type, name, suffix): async def register_mqtt_component(var, config): await cg.register_component(var, {}) - if CONF_QOS in config: - cg.add(var.set_qos(config[CONF_QOS])) - if CONF_RETAIN in config: - cg.add(var.set_retain(config[CONF_RETAIN])) - if CONF_SUBSCRIBE_QOS in config: - cg.add(var.set_subscribe_qos(config[CONF_SUBSCRIBE_QOS])) + cg.add_optional(var.set_qos, config.get(CONF_QOS)) + cg.add_optional(var.set_retain, config.get(CONF_RETAIN)) + cg.add_optional(var.set_subscribe_qos, config.get(CONF_SUBSCRIBE_QOS)) if not config.get(CONF_DISCOVERY, True): cg.add(var.disable_discovery()) if CONF_STATE_TOPIC in config: @@ -577,8 +573,7 @@ async def register_mqtt_component(var, config): config[CONF_COMMAND_TOPIC], [], cg.std_string ) cg.add(var.set_custom_command_topic(command_topic)) - if CONF_COMMAND_RETAIN in config: - cg.add(var.set_command_retain(config[CONF_COMMAND_RETAIN])) + cg.add_optional(var.set_command_retain, config.get(CONF_COMMAND_RETAIN)) if CONF_AVAILABILITY in config: availability = config[CONF_AVAILABILITY] if not availability: diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py index 79c73eebe46..0c9539fd9f2 100644 --- a/esphome/components/mt6701_i2c/__init__.py +++ b/esphome/components/mt6701_i2c/__init__.py @@ -157,14 +157,10 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if (direction := config.get(CONF_DIRECTION)) is not None: - cg.add(var.set_direction(direction)) - if (zero_offset := config.get(CONF_ZERO_OFFSET)) is not None: - cg.add(var.set_zero_offset(zero_offset)) - if (hysteresis := config.get(CONF_HYSTERESIS)) is not None: - cg.add(var.set_hysteresis(hysteresis)) - if (output_mode := config.get(CONF_OUTPUT_MODE)) is not None: - cg.add(var.set_output_mode(output_mode)) + cg.add_optional(var.set_direction, config.get(CONF_DIRECTION)) + cg.add_optional(var.set_zero_offset, config.get(CONF_ZERO_OFFSET)) + cg.add_optional(var.set_hysteresis, config.get(CONF_HYSTERESIS)) + cg.add_optional(var.set_output_mode, config.get(CONF_OUTPUT_MODE)) if abz := config.get(CONF_ABZ): if (ppr := abz.get(CONF_PULSES_PER_REVOLUTION)) is not None: diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py index ce0fd669b24..0ad3465b68a 100644 --- a/tests/component_tests/motion/test_motion.py +++ b/tests/component_tests/motion/test_motion.py @@ -944,6 +944,7 @@ class TestEventSchema: def mock_binary_sensor_codegen(): mock_var = MagicMock() mock_parent = MagicMock() + mock_add = MagicMock() with ( patch( "esphome.components.motion.binary_sensor.cg.get_variable", @@ -962,9 +963,8 @@ def mock_binary_sensor_codegen(): "esphome.components.motion.binary_sensor.cg.register_component", new_callable=AsyncMock, ) as mock_reg_comp, - patch( - "esphome.components.motion.binary_sensor.cg.add", - ) as mock_add, + patch("esphome.components.motion.binary_sensor.cg.add", mock_add), + patch("esphome.cpp_helpers.add", mock_add), ): yield { "get_variable": mock_get_var, From 808075bf93ff55223411db16881a5ca8d164e59d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 03:03:51 -1000 Subject: [PATCH 40/43] [components] Set optional plain values with cg.add_optional (n-z) (#21183) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nextion/base_component.py | 6 ++--- esphome/components/nextion/display.py | 9 +++---- esphome/components/nextion/sensor/__init__.py | 12 ++++------ .../components/nfc/binary_sensor/__init__.py | 3 +-- esphome/components/number/__init__.py | 6 ++--- esphome/components/one_wire/__init__.py | 6 ++--- .../components/opentherm/number/__init__.py | 6 ++--- esphome/components/openthread/__init__.py | 6 ++--- esphome/components/output/__init__.py | 3 +-- esphome/components/pca9685/__init__.py | 3 +-- esphome/components/pn71xx/__init__.py | 3 +-- esphome/components/rd03d/__init__.py | 6 ++--- esphome/components/remote_base/__init__.py | 6 ++--- .../components/remote_receiver/__init__.py | 12 ++++------ .../components/remote_transmitter/__init__.py | 9 +++---- esphome/components/rotary_encoder/sensor.py | 6 ++--- esphome/components/scd30/sensor.py | 3 +-- esphome/components/sdl/display.py | 3 +-- esphome/components/sen5x/sensor.py | 3 +-- esphome/components/sen6x/sensor.py | 3 +-- esphome/components/sensor/__init__.py | 12 ++++------ esphome/components/sgp30/sensor.py | 3 +-- esphome/components/speed/fan/__init__.py | 3 +-- esphome/components/sps30/sensor.py | 3 +-- esphome/components/ssd1306_base/__init__.py | 24 +++++++------------ esphome/components/ssd1322_base/__init__.py | 6 ++--- esphome/components/ssd1325_base/__init__.py | 6 ++--- esphome/components/ssd1327_base/__init__.py | 3 +-- esphome/components/ssd1331_base/__init__.py | 3 +-- esphome/components/ssd1351_base/__init__.py | 3 +-- esphome/components/stepper/__init__.py | 9 +++---- esphome/components/switch/__init__.py | 3 +-- esphome/components/tcp_uart/__init__.py | 3 +-- esphome/components/template/fan/__init__.py | 6 ++--- .../components/template/number/__init__.py | 3 +-- .../template/water_heater/__init__.py | 3 +-- esphome/components/thermostat/climate.py | 3 +-- esphome/components/tinyusb/__init__.py | 3 +-- esphome/components/tmp1075/sensor.py | 3 +-- esphome/components/touchscreen/__init__.py | 3 +-- esphome/components/tuya/cover/__init__.py | 6 ++--- esphome/components/tuya/fan/__init__.py | 12 ++++------ esphome/components/tuya/light/__init__.py | 12 ++++------ .../components/tuya/water_heater/__init__.py | 3 +-- esphome/components/uart/__init__.py | 9 +++---- esphome/components/uart/switch/__init__.py | 3 +-- esphome/components/uart_tcp/__init__.py | 3 +-- .../components/vbus/binary_sensor/__init__.py | 9 +++---- esphome/components/vbus/sensor/__init__.py | 9 +++---- esphome/components/vevor_dtx/__init__.py | 3 +-- .../components/voice_assistant/__init__.py | 3 +-- esphome/components/wifi/__init__.py | 15 ++++-------- esphome/components/wireguard/__init__.py | 3 +-- esphome/components/xiaomi_cgg1/sensor.py | 3 +-- 54 files changed, 104 insertions(+), 208 deletions(-) diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 442eb979158..7851e9b1e42 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -128,8 +128,6 @@ async def setup_component_core_(var, config, arg): var.set_foreground_pressed_color, config.get(CONF_FOREGROUND_PRESSED_COLOR) ) - if CONF_FONT_ID in config: - cg.add(var.set_font_id(config[CONF_FONT_ID])) + cg.add_optional(var.set_font_id, config.get(CONF_FONT_ID)) - if CONF_VISIBLE in config: - cg.add(var.set_visible(config[CONF_VISIBLE])) + cg.add_optional(var.set_visible, config.get(CONF_VISIBLE)) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 92255a6eda2..2e855c4e2dd 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -228,8 +228,7 @@ async def to_code(config): cg.add(var.set_startup_override_ms(config[CONF_STARTUP_OVERRIDE_MS])) - if CONF_BRIGHTNESS in config: - cg.add(var.set_brightness(config[CONF_BRIGHTNESS])) + cg.add_optional(var.set_brightness, config.get(CONF_BRIGHTNESS)) await display.add_writer(var, config, nextion_ref) @@ -265,11 +264,9 @@ async def to_code(config): elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) - if CONF_TOUCH_SLEEP_TIMEOUT in config: - cg.add(var.set_touch_sleep_timeout(config[CONF_TOUCH_SLEEP_TIMEOUT])) + cg.add_optional(var.set_touch_sleep_timeout, config.get(CONF_TOUCH_SLEEP_TIMEOUT)) - if CONF_WAKE_UP_PAGE in config: - cg.add(var.set_wake_up_page(config[CONF_WAKE_UP_PAGE])) + cg.add_optional(var.set_wake_up_page, config.get(CONF_WAKE_UP_PAGE)) if CONF_START_UP_PAGE in config: cg.add_define("USE_NEXTION_CONF_START_UP_PAGE") diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 6c93286656a..93c3dc8d7c0 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -74,11 +74,9 @@ async def to_code(config): await setup_component_core_(var, config, ".val") - if CONF_PRECISION in config: - cg.add(var.set_precision(config[CONF_PRECISION])) + cg.add_optional(var.set_precision, config.get(CONF_PRECISION)) - if CONF_COMPONENT_ID in config: - cg.add(var.set_component_id(config[CONF_COMPONENT_ID])) + cg.add_optional(var.set_component_id, config.get(CONF_COMPONENT_ID)) if CONF_WAVE_CHANNEL_ID in config: cg.add_define("USE_NEXTION_WAVEFORM") @@ -87,10 +85,8 @@ async def to_code(config): cg.add( var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE]) ) - if CONF_WAVE_MAX_VALUE in config: - cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) - if CONF_WAVE_MAX_LENGTH in config: - cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) + cg.add_optional(var.set_wave_max_value, config.get(CONF_WAVE_MAX_VALUE)) + cg.add_optional(var.set_wave_max_length, config.get(CONF_WAVE_MAX_LENGTH)) automation.register_apply_action( diff --git a/esphome/components/nfc/binary_sensor/__init__.py b/esphome/components/nfc/binary_sensor/__init__.py index 2fde6804da9..77a099b204e 100644 --- a/esphome/components/nfc/binary_sensor/__init__.py +++ b/esphome/components/nfc/binary_sensor/__init__.py @@ -68,8 +68,7 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_NFCC_ID]) _request_listener_slot(str(hub)) cg.add(hub.register_listener(var)) - if CONF_NDEF_CONTAINS in config: - cg.add(var.set_ndef_match_string(config[CONF_NDEF_CONTAINS])) + cg.add_optional(var.set_ndef_match_string, config.get(CONF_NDEF_CONTAINS)) if CONF_TAG_ID in config: cg.add(var.set_tag_name(config[CONF_TAG_ID])) elif CONF_UID in config: diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index c863caa51af..04aa5a5f2f5 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -354,10 +354,8 @@ async def number_in_range_to_code(config, condition_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(condition_id, template_arg, paren) - if (above := config.get(CONF_ABOVE)) is not None: - cg.add(var.set_min(above)) - if (below := config.get(CONF_BELOW)) is not None: - cg.add(var.set_max(below)) + cg.add_optional(var.set_min, config.get(CONF_ABOVE)) + cg.add_optional(var.set_max, config.get(CONF_BELOW)) return var diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 4a8edc656e2..070692ef129 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -38,7 +38,5 @@ async def register_one_wire_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ await cg.add_variable(var.set_one_wire_bus, config[CONF_ONE_WIRE_ID]) - if (address := config.get(CONF_ADDRESS)) is not None: - cg.add(var.set_address(address)) - if (index := config.get(CONF_INDEX)) is not None: - cg.add(var.set_index(index)) + cg.add_optional(var.set_address, config.get(CONF_ADDRESS)) + cg.add_optional(var.set_index, config.get(CONF_INDEX)) diff --git a/esphome/components/opentherm/number/__init__.py b/esphome/components/opentherm/number/__init__.py index 17ec12a61ab..b8c34b9a8b6 100644 --- a/esphome/components/opentherm/number/__init__.py +++ b/esphome/components/opentherm/number/__init__.py @@ -31,10 +31,8 @@ async def new_openthermnumber(config: dict[str, Any]) -> cg.Pvariable: await cg.register_component(var, config) input.generate_setters(var, config) - if (initial_value := config.get(CONF_INITIAL_VALUE)) is not None: - cg.add(var.set_initial_value(initial_value)) - if (restore_value := config.get(CONF_RESTORE_VALUE)) is not None: - cg.add(var.set_restore_value(restore_value)) + cg.add_optional(var.set_initial_value, config.get(CONF_INITIAL_VALUE)) + cg.add_optional(var.set_restore_value, config.get(CONF_RESTORE_VALUE)) return var diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 181bcea1d55..477dcaf576e 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -566,8 +566,7 @@ async def to_code(config: ConfigType) -> None: ) add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) - if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: - cg.add(ot.set_poll_period(poll_period)) + cg.add_optional(ot.set_poll_period, config.get(CONF_POLL_PERIOD)) if not border_router: mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) @@ -575,8 +574,7 @@ async def to_code(config: ConfigType) -> None: cg.add(srp.set_mdns(mdns_component)) await cg.register_component(srp, config) - if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: - cg.add(ot.set_output_power(output_power)) + cg.add_optional(ot.set_output_power, config.get(CONF_OUTPUT_POWER)) if CORE.is_esp32: set_sdkconfig_options(config) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index ff7f55586da..6471880a65a 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -57,8 +57,7 @@ FloatOutputPtr = FloatOutput.operator("ptr") async def setup_output_platform_(obj, config): - if CONF_INVERTED in config: - cg.add(obj.set_inverted(config[CONF_INVERTED])) + cg.add_optional(obj.set_inverted, config.get(CONF_INVERTED)) await cg.add_variable(obj.set_power_supply, config.get(CONF_POWER_SUPPLY)) # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when # the config matches them. The define stays whenever the key is present because diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 699643f044c..f8fa62b7920 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -53,8 +53,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - if CONF_FREQUENCY in config: - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + cg.add_optional(var.set_frequency, config.get(CONF_FREQUENCY)) cg.add(var.set_extclk(config[CONF_EXTERNAL_CLOCK_INPUT])) cg.add(var.set_phase_balancer(config[CONF_PHASE_BALANCER])) await cg.register_component(var, config) diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 10d806ca0c6..774ec57c44a 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -134,8 +134,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: cg.add(var.set_tag_emulation_message(emulation_message_config)) cg.add(var.set_tag_emulation_on()) - if (tag_ttl := config.get(CONF_TAG_TTL)) is not None: - cg.add(var.set_tag_ttl(tag_ttl)) + cg.add_optional(var.set_tag_ttl, config.get(CONF_TAG_TTL)) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index ff2eaebad59..13b781bd0f0 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -43,8 +43,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) - if CONF_TRACKING_MODE in config: - cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) + cg.add_optional(var.set_tracking_mode, config.get(CONF_TRACKING_MODE)) - if CONF_THROTTLE in config: - cg.add(var.set_throttle(config[CONF_THROTTLE])) + cg.add_optional(var.set_throttle, config.get(CONF_THROTTLE)) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 3d5053ca7e0..d9ebdcd13d9 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2709,10 +2709,8 @@ def abbwelcome_binary_sensor(var, config): cg.add(var.set_retransmission(config[CONF_RETRANSMISSION])) cg.add(var.set_message_type(config[CONF_MESSAGE_TYPE])) cg.add(var.set_auto_message_id(CONF_MESSAGE_ID not in config)) - if CONF_MESSAGE_ID in config: - cg.add(var.set_message_id(config[CONF_MESSAGE_ID])) - if CONF_DATA in config: - cg.add(var.set_data(config[CONF_DATA])) + cg.add_optional(var.set_message_id, config.get(CONF_MESSAGE_ID)) + cg.add_optional(var.set_data, config.get(CONF_DATA)) cg.add(var.finalize()) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 481acaa06c9..005f33c7b2f 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -213,12 +213,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_receive_symbols(config[CONF_RECEIVE_SYMBOLS])) - if CONF_USE_DMA in config: - cg.add(var.set_with_dma(config[CONF_USE_DMA])) - if CONF_CLOCK_RESOLUTION in config: - cg.add(var.set_clock_resolution(config[CONF_CLOCK_RESOLUTION])) - if CONF_FILTER_SYMBOLS in config: - cg.add(var.set_filter_symbols(config[CONF_FILTER_SYMBOLS])) + cg.add_optional(var.set_with_dma, config.get(CONF_USE_DMA)) + cg.add_optional(var.set_clock_resolution, config.get(CONF_CLOCK_RESOLUTION)) + cg.add_optional(var.set_filter_symbols, config.get(CONF_FILTER_SYMBOLS)) cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT])) cg.add(var.set_carrier_frequency(config[CONF_CARRIER_FREQUENCY])) else: @@ -238,8 +235,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_TOLERANCE][CONF_VALUE], config[CONF_TOLERANCE][CONF_TYPE] ) ) - if CONF_BUFFER_SIZE in config: - cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + cg.add_optional(var.set_buffer_size, config.get(CONF_BUFFER_SIZE)) cg.add(var.set_filter_us(config[CONF_FILTER])) cg.add(var.set_idle_us(config[CONF_IDLE])) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 7553ed767b3..7f0dcd05db9 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -150,10 +150,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], pin) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) cg.add(var.set_non_blocking(config[CONF_NON_BLOCKING])) - if CONF_CLOCK_RESOLUTION in config: - cg.add(var.set_clock_resolution(config[CONF_CLOCK_RESOLUTION])) - if CONF_USE_DMA in config: - cg.add(var.set_with_dma(config[CONF_USE_DMA])) + cg.add_optional(var.set_clock_resolution, config.get(CONF_CLOCK_RESOLUTION)) + cg.add_optional(var.set_with_dma, config.get(CONF_USE_DMA)) if CONF_EOT_LEVEL in config: cg.add(var.set_eot_level(config[CONF_EOT_LEVEL])) else: @@ -165,8 +163,7 @@ async def to_code(config: ConfigType) -> None: ) else: var = cg.new_Pvariable(config[CONF_ID], pin) - if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None: - cg.add(var.set_non_blocking(non_blocking)) + cg.add_optional(var.set_non_blocking, config.get(CONF_NON_BLOCKING)) await cg.register_component(var, config) cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT])) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 15629c446ab..529dd047c4c 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -100,10 +100,8 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_reset_pin, config.get(CONF_PIN_RESET)) cg.add(var.set_resolution(config[CONF_RESOLUTION])) - if CONF_MIN_VALUE in config: - cg.add(var.set_min_value(config[CONF_MIN_VALUE])) - if CONF_MAX_VALUE in config: - cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + cg.add_optional(var.set_min_value, config.get(CONF_MIN_VALUE)) + cg.add_optional(var.set_max_value, config.get(CONF_MAX_VALUE)) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 3c39f3e7c21..c12fa05e7f3 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -91,8 +91,7 @@ async def to_code(config: ConfigType) -> None: ) ) - if CONF_TEMPERATURE_OFFSET in config: - cg.add(var.set_temperature_offset(config[CONF_TEMPERATURE_OFFSET])) + cg.add_optional(var.set_temperature_offset, config.get(CONF_TEMPERATURE_OFFSET)) cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 58dff4430eb..06ed1b27610 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -157,8 +157,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await register_snapshot(var, config) cg.add(var.set_headless(config[CONF_HEADLESS])) - if (key := config.get(CONF_SNAPSHOT_KEY)) is not None: - cg.add(var.set_snapshot_key(key)) + cg.add_optional(var.set_snapshot_key, config.get(CONF_SNAPSHOT_KEY)) dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index b3fde64f820..19152090df7 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -225,8 +225,7 @@ async def to_code(config: ConfigType) -> None: if cfg := config.get(key): cg.add(getattr(var, funcName)(cfg)) - if (model := config.get(CONF_MODEL)) is not None: - cg.add(var.set_model(model)) + cg.add_optional(var.set_model, config.get(CONF_MODEL)) for key, funcName in SENSOR_MAP.items(): if cfg := config.get(key): diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index d961d05bc85..71190c582b3 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -272,8 +272,7 @@ SENSOR_MAP = { async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if CONF_TYPE in config: - cg.add(var.set_type(config[CONF_TYPE])) + cg.add_optional(var.set_type, config.get(CONF_TYPE)) # The number concentration read and its sensors compile in only when one is configured if any(key in config for key in _PMC_KEYS): cg.add_define("USE_SEN6X_NUMBER_CONCENTRATION") diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79c6785be24..9f0bad4ab76 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1046,10 +1046,8 @@ async def _build_sensor_automations(var, config): async def setup_sensor_core_(var, config): setup_device_class(config) setup_unit_of_measurement(config) - if (state_class := config.get(CONF_STATE_CLASS)) is not None: - cg.add(var.set_state_class(state_class)) - if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: - cg.add(var.set_accuracy_decimals(accuracy_decimals)) + cg.add_optional(var.set_state_class, config.get(CONF_STATE_CLASS)) + cg.add_optional(var.set_accuracy_decimals, config.get(CONF_ACCURACY_DECIMALS)) # Only set force_update if True (default is False) if config[CONF_FORCE_UPDATE]: cg.add(var.set_force_update(True)) @@ -1117,10 +1115,8 @@ async def sensor_in_range_to_code(config, condition_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(condition_id, template_arg, paren) - if (above := config.get(CONF_ABOVE)) is not None: - cg.add(var.set_min(above)) - if (below := config.get(CONF_BELOW)) is not None: - cg.add(var.set_max(below)) + cg.add_optional(var.set_min, config.get(CONF_ABOVE)) + cg.add_optional(var.set_max, config.get(CONF_BELOW)) return var diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 87b0d3aa2c0..af40feeffc8 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -90,8 +90,7 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_ECO2_BASELINE, var.set_eco2_baseline_sensor) await sensors(CONF_TVOC_BASELINE, var.set_tvoc_baseline_sensor) - if (store_baseline := config.get(CONF_STORE_BASELINE)) is not None: - cg.add(var.set_store_baseline(store_baseline)) + cg.add_optional(var.set_store_baseline, config.get(CONF_STORE_BASELINE)) if baseline_config := config.get(CONF_BASELINE): cg.add(var.set_eco2_baseline(baseline_config[CONF_ECO2_BASELINE])) diff --git a/esphome/components/speed/fan/__init__.py b/esphome/components/speed/fan/__init__.py index cd94796f081..70df78152c4 100644 --- a/esphome/components/speed/fan/__init__.py +++ b/esphome/components/speed/fan/__init__.py @@ -42,5 +42,4 @@ async def to_code(config: ConfigType) -> None: await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) - if CONF_PRESET_MODES in config: - cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) + cg.add_optional(var.set_preset_modes, config.get(CONF_PRESET_MODES)) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index d86d5b7ecf5..af443664fa7 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -134,8 +134,7 @@ async def to_code(config: ConfigType) -> None: if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) - if CONF_IDLE_INTERVAL in config: - cg.add(var.set_idle_interval(config[CONF_IDLE_INTERVAL])) + cg.add_optional(var.set_idle_interval, config.get(CONF_IDLE_INTERVAL)) SPS30_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 4f9dda09dd7..a308ae94eb8 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -81,20 +81,12 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: cg.add(var.set_model(config[CONF_MODEL])) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_CONTRAST in config: - cg.add(var.init_contrast(config[CONF_CONTRAST])) - if CONF_EXTERNAL_VCC in config: - cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_FLIP_X in config: - cg.add(var.init_flip_x(config[CONF_FLIP_X])) - if CONF_FLIP_Y in config: - cg.add(var.init_flip_y(config[CONF_FLIP_Y])) - if CONF_OFFSET_X in config: - cg.add(var.init_offset_x(config[CONF_OFFSET_X])) - if CONF_OFFSET_Y in config: - cg.add(var.init_offset_y(config[CONF_OFFSET_Y])) - if CONF_INVERT in config: - cg.add(var.init_invert(config[CONF_INVERT])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) + cg.add_optional(var.init_contrast, config.get(CONF_CONTRAST)) + cg.add_optional(var.set_external_vcc, config.get(CONF_EXTERNAL_VCC)) + cg.add_optional(var.init_flip_x, config.get(CONF_FLIP_X)) + cg.add_optional(var.init_flip_y, config.get(CONF_FLIP_Y)) + cg.add_optional(var.init_offset_x, config.get(CONF_OFFSET_X)) + cg.add_optional(var.init_offset_y, config.get(CONF_OFFSET_Y)) + cg.add_optional(var.init_invert, config.get(CONF_INVERT)) await display.add_writer(var, config) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index e3a6ac5656e..6243068660a 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -34,8 +34,6 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: cg.add(var.set_model(config[CONF_MODEL])) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_EXTERNAL_VCC in config: - cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) + cg.add_optional(var.set_external_vcc, config.get(CONF_EXTERNAL_VCC)) await display.add_writer(var, config) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index ee75f2b0b0b..185aa37461b 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -38,8 +38,6 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: cg.add(var.set_model(config[CONF_MODEL])) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_EXTERNAL_VCC in config: - cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) + cg.add_optional(var.set_external_vcc, config.get(CONF_EXTERNAL_VCC)) await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index bd317cf17f4..0e950ef4143 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -33,6 +33,5 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: cg.add(var.set_model(config[CONF_MODEL])) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index acde9a1ea46..23ea7adb074 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -24,6 +24,5 @@ async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index 7078f7d2421..deb2e9d115a 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -34,6 +34,5 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: cg.add(var.set_model(config[CONF_MODEL])) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if CONF_BRIGHTNESS in config: - cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + cg.add_optional(var.init_brightness, config.get(CONF_BRIGHTNESS)) await display.add_writer(var, config) diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index f5c1a304335..388ef681fe2 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -75,12 +75,9 @@ STEPPER_SCHEMA = cv.Schema( async def setup_stepper_core_(stepper_var, config): - if CONF_ACCELERATION in config: - cg.add(stepper_var.set_acceleration(config[CONF_ACCELERATION])) - if CONF_DECELERATION in config: - cg.add(stepper_var.set_deceleration(config[CONF_DECELERATION])) - if CONF_MAX_SPEED in config: - cg.add(stepper_var.set_max_speed(config[CONF_MAX_SPEED])) + cg.add_optional(stepper_var.set_acceleration, config.get(CONF_ACCELERATION)) + cg.add_optional(stepper_var.set_deceleration, config.get(CONF_DECELERATION)) + cg.add_optional(stepper_var.set_max_speed, config.get(CONF_MAX_SPEED)) async def register_stepper(var, config): diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 51deaaac166..576ff0459a9 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -146,8 +146,7 @@ async def _build_switch_automations(var, config): @setup_entity("switch") async def setup_switch_core_(var, config): - if (inverted := config.get(CONF_INVERTED)) is not None: - cg.add(var.set_inverted(inverted)) + cg.add_optional(var.set_inverted, config.get(CONF_INVERTED)) CORE.add_job(_build_switch_automations, var, config) diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 1e120275602..5c8f784e71e 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -81,5 +81,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) - if (host := config.get(CONF_HOST)) is not None: - cg.add(var.set_host(host)) + cg.add_optional(var.set_host, config.get(CONF_HOST)) diff --git a/esphome/components/template/fan/__init__.py b/esphome/components/template/fan/__init__.py index 72b20e1efea..4d2630f3be0 100644 --- a/esphome/components/template/fan/__init__.py +++ b/esphome/components/template/fan/__init__.py @@ -34,8 +34,6 @@ async def to_code(config): cg.add(var.set_has_direction(config[CONF_HAS_DIRECTION])) cg.add(var.set_has_oscillating(config[CONF_HAS_OSCILLATING])) - if CONF_SPEED_COUNT in config: - cg.add(var.set_speed_count(config[CONF_SPEED_COUNT])) + cg.add_optional(var.set_speed_count, config.get(CONF_SPEED_COUNT)) - if CONF_PRESET_MODES in config: - cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) + cg.add_optional(var.set_preset_modes, config.get(CONF_PRESET_MODES)) diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index 3b6485fec3d..ea512beb3d3 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -92,8 +92,7 @@ async def to_code(config): else: cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) cg.add(var.set_initial_value(config[CONF_INITIAL_VALUE])) - if CONF_RESTORE_VALUE in config: - cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) + cg.add_optional(var.set_restore_value, config.get(CONF_RESTORE_VALUE)) if CONF_SET_ACTION in config: await automation.build_automation( diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 39e3c56da5f..0e86c695362 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -97,8 +97,7 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_mode_lambda(template_)) - if CONF_SUPPORTED_MODES in config: - cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) + cg.add_optional(var.set_supported_modes, config.get(CONF_SUPPORTED_MODES)) if CONF_AWAY in config: template_ = await cg.process_lambda( diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3e6d63adbb7..6460ae4556d 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -1026,8 +1026,7 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_default_preset(default_preset_name)) - if CONF_ON_BOOT_RESTORE_FROM in config: - cg.add(var.set_on_boot_restore_from(config[CONF_ON_BOOT_RESTORE_FROM])) + cg.add_optional(var.set_on_boot_restore_from, config.get(CONF_ON_BOOT_RESTORE_FROM)) if CONF_PRESET_CHANGE in config: await automation.build_automation( diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 92a8e439d2a..3f80d66c1c9 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -123,8 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: - cg.add(var.set_vbus_monitor_pin(vbus_pin)) + cg.add_optional(var.set_vbus_monitor_pin, config.get(CONF_VBUS_MONITOR_PIN)) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index 09ac80aa4f9..bdacfbd76fc 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -74,8 +74,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_CONVERSION_RATE in config: - cg.add(var.set_conversion_rate(config[CONF_CONVERSION_RATE])) + cg.add_optional(var.set_conversion_rate, config.get(CONF_CONVERSION_RATE)) alert = config[CONF_ALERT] if CONF_LIMIT_LOW in alert: diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 36679e828b7..b8072b12b41 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -157,8 +157,7 @@ async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.add_variable(var.set_display, config[CONF_DISPLAY]) - if CONF_TOUCH_TIMEOUT in config: - cg.add(var.set_touch_timeout(config[CONF_TOUCH_TIMEOUT])) + cg.add_optional(var.set_touch_timeout, config.get(CONF_TOUCH_TIMEOUT)) if CONF_TRANSFORM in config: transform = config[CONF_TRANSFORM] diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index c93fdf55445..55054d030ca 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -57,10 +57,8 @@ async def to_code(config): var = await cover.new_cover(config) await cg.register_component(var, config) - if CONF_CONTROL_DATAPOINT in config: - cg.add(var.set_control_id(config[CONF_CONTROL_DATAPOINT])) - if CONF_DIRECTION_DATAPOINT in config: - cg.add(var.set_direction_id(config[CONF_DIRECTION_DATAPOINT])) + cg.add_optional(var.set_control_id, config.get(CONF_CONTROL_DATAPOINT)) + cg.add_optional(var.set_direction_id, config.get(CONF_DIRECTION_DATAPOINT)) cg.add(var.set_position_id(config[CONF_POSITION_DATAPOINT])) if CONF_POSITION_REPORT_DATAPOINT in config: cg.add(var.set_position_report_id(config[CONF_POSITION_REPORT_DATAPOINT])) diff --git a/esphome/components/tuya/fan/__init__.py b/esphome/components/tuya/fan/__init__.py index de95888b6b8..a26344472c0 100644 --- a/esphome/components/tuya/fan/__init__.py +++ b/esphome/components/tuya/fan/__init__.py @@ -37,11 +37,7 @@ async def to_code(config): await cg.register_component(var, config) await fan.register_fan(var, config) - if CONF_SPEED_DATAPOINT in config: - cg.add(var.set_speed_id(config[CONF_SPEED_DATAPOINT])) - if CONF_SWITCH_DATAPOINT in config: - cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) - if CONF_OSCILLATION_DATAPOINT in config: - cg.add(var.set_oscillation_id(config[CONF_OSCILLATION_DATAPOINT])) - if CONF_DIRECTION_DATAPOINT in config: - cg.add(var.set_direction_id(config[CONF_DIRECTION_DATAPOINT])) + cg.add_optional(var.set_speed_id, config.get(CONF_SPEED_DATAPOINT)) + cg.add_optional(var.set_switch_id, config.get(CONF_SWITCH_DATAPOINT)) + cg.add_optional(var.set_oscillation_id, config.get(CONF_OSCILLATION_DATAPOINT)) + cg.add_optional(var.set_direction_id, config.get(CONF_DIRECTION_DATAPOINT)) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index 9811995ad0c..e4f41cf6e13 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -80,12 +80,10 @@ async def to_code(config): await cg.register_component(var, config) await light.register_light(var, config) - if CONF_DIMMER_DATAPOINT in config: - cg.add(var.set_dimmer_id(config[CONF_DIMMER_DATAPOINT])) + cg.add_optional(var.set_dimmer_id, config.get(CONF_DIMMER_DATAPOINT)) if CONF_MIN_VALUE_DATAPOINT in config: cg.add(var.set_min_value_datapoint_id(config[CONF_MIN_VALUE_DATAPOINT])) - if CONF_SWITCH_DATAPOINT in config: - cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + cg.add_optional(var.set_switch_id, config.get(CONF_SWITCH_DATAPOINT)) if CONF_COLOR_DATAPOINT in config: cg.add(var.set_color_id(config[CONF_COLOR_DATAPOINT])) cg.add(var.set_color_type(config[CONF_COLOR_TYPE])) @@ -100,10 +98,8 @@ async def to_code(config): cg.add( var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) ) - if CONF_MIN_VALUE in config: - cg.add(var.set_min_value(config[CONF_MIN_VALUE])) - if CONF_MAX_VALUE in config: - cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + cg.add_optional(var.set_min_value, config.get(CONF_MIN_VALUE)) + cg.add_optional(var.set_max_value, config.get(CONF_MAX_VALUE)) if CONF_COLOR_TEMPERATURE_MAX_VALUE in config: cg.add( var.set_color_temperature_max_value( diff --git a/esphome/components/tuya/water_heater/__init__.py b/esphome/components/tuya/water_heater/__init__.py index 96b7136c725..69bc04caf0b 100644 --- a/esphome/components/tuya/water_heater/__init__.py +++ b/esphome/components/tuya/water_heater/__init__.py @@ -115,5 +115,4 @@ async def to_code(config: ConfigType) -> None: if (value := config.get(key)) is not None: cg.add(getattr(var, setter)(value)) - if (supported_modes := config.get(CONF_SUPPORTED_MODES)) is not None: - cg.add(var.set_supported_modes(supported_modes)) + cg.add_optional(var.set_supported_modes, config.get(CONF_SUPPORTED_MODES)) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index c70f0b6c693..d847c45bfb9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -373,8 +373,7 @@ async def to_code(config): await cg.add_pin(var.set_tx_pin, config.get(CONF_TX_PIN)) await cg.add_pin(var.set_rx_pin, config.get(CONF_RX_PIN)) await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) - if CONF_PORT in config: - cg.add(var.set_name(config[CONF_PORT])) + cg.add_optional(var.set_name, config.get(CONF_PORT)) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) if CORE.is_esp32: if CONF_RX_FULL_THRESHOLD not in config: @@ -391,10 +390,8 @@ async def to_code(config): ) cg.add(var.set_rx_full_threshold(config[CONF_RX_FULL_THRESHOLD])) cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) - if CONF_FLUSH_TIMEOUT in config: - cg.add(var.set_flush_timeout(config[CONF_FLUSH_TIMEOUT])) - if (tx_buffer_size := config.get(CONF_TX_BUFFER_SIZE)) is not None: - cg.add(var.set_tx_buffer_size(tx_buffer_size)) + cg.add_optional(var.set_flush_timeout, config.get(CONF_FLUSH_TIMEOUT)) + cg.add_optional(var.set_tx_buffer_size, config.get(CONF_TX_BUFFER_SIZE)) # The member already defaults to UART_SCLK_DEFAULT, so only emit a real choice if (clock_source := config.get(CONF_CLOCK_SOURCE, "DEFAULT")) != "DEFAULT": cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 3288ceeee20..4f0faea131d 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -51,5 +51,4 @@ async def to_code(config): cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) if single_state: cg.add(var.set_single_state(True)) - if CONF_SEND_EVERY in config: - cg.add(var.set_send_every(config[CONF_SEND_EVERY])) + cg.add_optional(var.set_send_every, config.get(CONF_SEND_EVERY)) diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 4a69029e6f7..c0ae219cb68 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -127,5 +127,4 @@ async def to_code(config: ConfigType) -> None: socket.require_tcp_client_link() cg.add(var.set_port(config[CONF_PORT])) cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) - if (host := config.get(CONF_HOST)) is not None: - cg.add(var.set_host(host)) + cg.add_optional(var.set_host, config.get(CONF_HOST)) diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index d3b26d64d8b..1d53de4572e 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -337,12 +337,9 @@ async def to_code(config: ConfigType) -> None: await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_CUSTOM: - if CONF_COMMAND in config: - cg.add(var.set_command(config[CONF_COMMAND])) - if CONF_SOURCE in config: - cg.add(var.set_source(config[CONF_SOURCE])) - if CONF_DEST in config: - cg.add(var.set_dest(config[CONF_DEST])) + cg.add_optional(var.set_command, config.get(CONF_COMMAND)) + cg.add_optional(var.set_source, config.get(CONF_SOURCE)) + cg.add_optional(var.set_dest, config.get(CONF_DEST)) bsensors = [] for conf in config[CONF_BINARY_SENSORS]: bsens = await binary_sensor.new_binary_sensor(conf) diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index 84e78900900..d96f8045e3e 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -768,12 +768,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_CUSTOM: - if CONF_COMMAND in config: - cg.add(var.set_command(config[CONF_COMMAND])) - if CONF_SOURCE in config: - cg.add(var.set_source(config[CONF_SOURCE])) - if CONF_DEST in config: - cg.add(var.set_dest(config[CONF_DEST])) + cg.add_optional(var.set_command, config.get(CONF_COMMAND)) + cg.add_optional(var.set_source, config.get(CONF_SOURCE)) + cg.add_optional(var.set_dest, config.get(CONF_DEST)) custom_sensors = [] for conf in config[CONF_SENSORS]: sens = await sensor.new_sensor(conf) diff --git a/esphome/components/vevor_dtx/__init__.py b/esphome/components/vevor_dtx/__init__.py index 03522c6f1d1..b2cf48f5db0 100644 --- a/esphome/components/vevor_dtx/__init__.py +++ b/esphome/components/vevor_dtx/__init__.py @@ -44,5 +44,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_listener(var, config) cg.add(var.set_bit_time_us(config[CONF_BIT_TIME])) - if (station_id := config.get(CONF_STATION_ID)) is not None: - cg.add(var.set_station_id(station_id)) + cg.add_optional(var.set_station_id, config.get(CONF_STATION_ID)) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 1b7ad38c1c5..79204a489b5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -213,8 +213,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) - if (vad_threshold := config.get(CONF_VAD_THRESHOLD)) is not None: - cg.add(var.set_vad_threshold(vad_threshold)) + cg.add_optional(var.set_vad_threshold, config.get(CONF_VAD_THRESHOLD)) cg.add(var.set_noise_suppression_level(config[CONF_NOISE_SUPPRESSION_LEVEL])) cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN])) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 886b7ef9990..da9ec36203a 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -751,19 +751,15 @@ def manual_ip(config): def wifi_network(config, ap, static_ip): - if CONF_SSID in config: - cg.add(ap.set_ssid(config[CONF_SSID])) - if CONF_PASSWORD in config: - cg.add(ap.set_password(config[CONF_PASSWORD])) + cg.add_optional(ap.set_ssid, config.get(CONF_SSID)) + cg.add_optional(ap.set_password, config.get(CONF_PASSWORD)) if CONF_EAP in config: cg.add(ap.set_eap(eap_auth(config[CONF_EAP]))) cg.add_define("USE_WIFI_WPA2_EAP") if CONF_BSSID in config: cg.add(ap.set_bssid([HexInt(i) for i in config[CONF_BSSID].parts])) - if CONF_HIDDEN in config: - cg.add(ap.set_hidden(config[CONF_HIDDEN])) - if CONF_CHANNEL in config: - cg.add(ap.set_channel(config[CONF_CHANNEL])) + cg.add_optional(ap.set_hidden, config.get(CONF_HIDDEN)) + cg.add_optional(ap.set_channel, config.get(CONF_CHANNEL)) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) # priority_ is 0 in C++; skip the setter when the config matches it. @@ -952,8 +948,7 @@ async def to_code(config): cg.add(var.set_btm(config[CONF_ENABLE_BTM])) if config[CONF_ENABLE_RRM]: cg.add(var.set_rrm(config[CONF_ENABLE_RRM])) - if CONF_BAND_MODE in config: - cg.add(var.set_band_mode(config[CONF_BAND_MODE])) + cg.add_optional(var.set_band_mode, config.get(CONF_BAND_MODE)) if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 07cb154c91c..ce0ab022f3f 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -109,8 +109,7 @@ async def to_code(config): cg.add(var.set_keepalive(config[CONF_PEER_PERSISTENT_KEEPALIVE])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - if CONF_PEER_PRESHARED_KEY in config: - cg.add(var.set_preshared_key(config[CONF_PEER_PRESHARED_KEY])) + cg.add_optional(var.set_preshared_key, config.get(CONF_PEER_PRESHARED_KEY)) allowed_ips = list( ipaddress.collapse_addresses( diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index a81d8d2f4e4..81dd9d5234b 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -61,8 +61,7 @@ async def to_code(config: ConfigType) -> None: var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_BINDKEY in config: - cg.add(var.set_bindkey(config[CONF_BINDKEY])) + cg.add_optional(var.set_bindkey, config.get(CONF_BINDKEY)) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) From 442bd4975e1d8f7d538f8bf9ac8ca5de83e6b868 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 03:05:05 -1000 Subject: [PATCH 41/43] [ci] Add the SEN0395, LD2450 zone and held light report flows to the hardware-test skill (#21160) --- .agents/skills/hardware-test/SKILL.md | 5 +- .../hardware-test/reference/recipes/README.md | 5 +- .../reference/recipes/dfrobot_sen0395.md | 21 ++ .../reference/recipes/homeassistant.md | 12 ++ .../hardware-test/reference/recipes/ld2450.md | 8 + .../scripts/dfrobot_sen0395/cases.sh | 22 +++ .../scripts/dfrobot_sen0395/ctl.py | 63 ++++++ .../scripts/homeassistant_light/heldcase.py | 71 +++++++ .../hardware-test/scripts/ld2450/zonedrive.py | 85 ++++++++ .../templates/dfrobot-sen0395-dut.yaml | 186 ++++++++++++++++++ .../templates/homeassistant-light-dut.yaml | 14 ++ 11 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 .agents/skills/hardware-test/reference/recipes/dfrobot_sen0395.md create mode 100755 .agents/skills/hardware-test/scripts/dfrobot_sen0395/cases.sh create mode 100644 .agents/skills/hardware-test/scripts/dfrobot_sen0395/ctl.py create mode 100644 .agents/skills/hardware-test/scripts/homeassistant_light/heldcase.py create mode 100644 .agents/skills/hardware-test/scripts/ld2450/zonedrive.py create mode 100644 .agents/skills/hardware-test/templates/dfrobot-sen0395-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index ec841c97612..9080e95432b 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/ (host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and -capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player). +capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player). ## Templates in `$SKILL/templates` @@ -130,7 +130,8 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `h `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`, `homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`, `globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`, - `cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`. + `cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`, + `dfrobot-sen0395-dut.yaml`. ## When something looks wrong diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md index 3db5e05f05a..62485f6eb3a 100644 --- a/.agents/skills/hardware-test/reference/recipes/README.md +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -9,7 +9,7 @@ One file per component, named after it: - [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API - [`espnow.md`](espnow.md): ESP8266 broadcast receive - [`hdmi_cec.md`](hdmi_cec.md): two-board wire -- [`ld2450.md`](ld2450.md): polygon zones +- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback - [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot - [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT - [`nrf52.md`](nrf52.md): dongle board support @@ -25,5 +25,6 @@ One file per component, named after it: - [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards - [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy - [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind -- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant +- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls - [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin +- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply diff --git a/.agents/skills/hardware-test/reference/recipes/dfrobot_sen0395.md b/.agents/skills/hardware-test/reference/recipes/dfrobot_sen0395.md new file mode 100644 index 00000000000..706f031503e --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/dfrobot_sen0395.md @@ -0,0 +1,21 @@ +# dfrobot_sen0395 + +## DFRobot SEN0395 mmWave on an ESP32 DevKit V1 (#20906, #20979) + +Wiring: V to VIN (5 V, the module wants 3.6 to 5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17 +(TX2), IO2 to GPIO18 (presence output, a gpio binary sensor), I1 unconnected. `templates/dfrobot-sen0395-dut.yaml` +has the hub, both detection binary sensors, the four switches, settings buttons and junk-line buttons. +`scripts/dfrobot_sen0395/ctl.py HOST KEY press:NAME sw:NAME=on|off wait:S` drives it and prints binary sensor and +switch updates; `cases.sh DEV_IP KEY CAPTURE LABEL` runs the whole flow against a running serial capture. Flash by +USB, `serial_capture.py PORT 540 CAPTURE --reset`, then the cases; compare the `Sending command` and `Message:` lines +and the counts of `Done`, `Error`, prompts and frames between builds. The module echoes every command, so the echoed +lines are the bytes the component sent and can be compared between builds as a set. + +Module facts: it answers `Done` or `Error` and then the `leapMMW:/>` prompt without a line end; a junk line gets +` is not recognized as a CLI command` (36 chars longer than the line, so 220 and 240 byte junk gives 256 and +276 char replies); input lines of 256 bytes or more make the module assert (`..\User_code\main.c line:232`) and +restart, after which the sensor is stopped; a factory reset restarts the module about 7 s later; `sensorStop` on a +stopped sensor answers `sensor stopped already` then `Error`; a settings press takes 5 to 6 s (stop, change, save, +start). Free heap on the ESP32 is Wi-Fi noise (192 to 198 kB) and says nothing about the component. +Results: https://github.com/esphome/esphome/pull/20979#issuecomment-6279129146 and +https://github.com/esphome/esphome/pull/20906#issuecomment-6279129604 diff --git a/.agents/skills/hardware-test/reference/recipes/homeassistant.md b/.agents/skills/hardware-test/reference/recipes/homeassistant.md index 61f0614973b..15c41b481e4 100644 --- a/.agents/skills/hardware-test/reference/recipes/homeassistant.md +++ b/.agents/skills/hardware-test/reference/recipes/homeassistant.md @@ -33,3 +33,15 @@ connected but not yet subscribed is dropped by the api server (`client has not s fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409 + +## Held report versus a later call (#21144) + +A report that arrives during a transition is held and applied when the transition ends, unless a call came after +it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror +device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to +90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop +pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build +without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many` +ends at 100% on both builds because the last call's target differs from the held report, so it is a regression +check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade. +The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695 diff --git a/.agents/skills/hardware-test/reference/recipes/ld2450.md b/.agents/skills/hardware-test/reference/recipes/ld2450.md index 9d1a8d8f32b..94ea253faa3 100644 --- a/.agents/skills/hardware-test/reference/recipes/ld2450.md +++ b/.agents/skills/hardware-test/reference/recipes/ld2450.md @@ -5,3 +5,11 @@ To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. Writing those numbers blocks the ld2450 loop about 50 ms. + +## Zone numbers round trip (#21146) + +`scripts/ld2450/zonedrive.py HOST KEY(- for plaintext) set:NAME=VALUE sel:NAME=OPTION wait:S list` sets numbers and +selects over the API and prints every LD2450 state update. After each set the hub sends the set zone command, queries +the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct +value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content. +Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922 diff --git a/.agents/skills/hardware-test/scripts/dfrobot_sen0395/cases.sh b/.agents/skills/hardware-test/scripts/dfrobot_sen0395/cases.sh new file mode 100755 index 00000000000..28e11c4216b --- /dev/null +++ b/.agents/skills/hardware-test/scripts/dfrobot_sen0395/cases.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# cases.sh DEV_IP KEY CAPTURE LABEL : the SEN0395 flow (settings, switches, junk lines, factory reset, reset) against +# templates/dfrobot-sen0395-dut.yaml, with a serial capture of the device running into CAPTURE +D=$1; K=$2; LOG=$3; LABEL=$4; PY=${PYTHON:-python3}; CTL="$(dirname "$0")/ctl.py" +show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]' | grep -a -v 'JYBSS\|took a long time' | cut -c1-200; } +step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; $PY "$CTL" "$D" "$K" "$@" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; } +frames() { echo "frames: $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,1') present, $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,0') absent"; } +n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames ($(date +%T))"; sleep 20; frames "$n0" +step "range 0 to 3 m" "press:Range 0 to 3m" wait:12 +step "range 0 to 9 m" "press:Range 0 to 9m" wait:12 +step "latency 1 s / 2 s" "press:Latency 1s 2s" wait:12 +step "latency 0 / 0" "press:Latency 0 0" wait:12 +step "sensitivity 7" "press:Sensitivity 7" wait:12 +step "LED off then on" "sw:LED=off" wait:10 "sw:LED=on" wait:10 +step "sensor active off then on" "sw:Sensor active=off" wait:10 "sw:Sensor active=on" wait:10 +step "presence via UART off then on" "sw:Presence via UART=off" wait:10 "sw:Presence via UART=on" wait:10 +step "start after boot off then on" "sw:Start after boot=off" wait:10 "sw:Start after boot=on" wait:10 +for n in 100 160 200 220 240 256; do step "junk line of $n bytes" "press:Junk $n" wait:7; done +step "junk 220 then a command" "press:Junk 220" wait:6 "press:Range 0 to 9m" wait:12 +step "factory reset" "press:Factory reset" wait:15 +step "reset" "press:Reset" wait:15 +n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames after ($(date +%T))"; sleep 20; frames "$n0" diff --git a/.agents/skills/hardware-test/scripts/dfrobot_sen0395/ctl.py b/.agents/skills/hardware-test/scripts/dfrobot_sen0395/ctl.py new file mode 100644 index 00000000000..8c7bc712706 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/dfrobot_sen0395/ctl.py @@ -0,0 +1,63 @@ +"""Drive the SEN0395 test device over the API and print its binary sensor and switch updates. +usage: ctl.py HOST KEY(- for plaintext) OP [OP ...] OP: press:NAME | sw:NAME=on|off | wait:SECONDS +""" + +import asyncio +import sys +import time + +from aioesphomeapi import ( + APIClient, + BinarySensorState, + ButtonInfo, + SwitchInfo, + SwitchState, +) + +HOST, KEY = sys.argv[1], sys.argv[2] +OPS = sys.argv[3:] +T0 = time.time() + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + by_key = {e.key: e for e in ents} + buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)} + switches = {e.name: e for e in ents if isinstance(e, SwitchInfo)} + + def on_state(s) -> None: + e = by_key.get(s.key) + if isinstance(s, BinarySensorState): + print( + f"{time.time() - T0:7.3f} BIN {e.name} = {'ON' if s.state else 'off'}" + ) + elif isinstance(s, SwitchState): + print( + f"{time.time() - T0:7.3f} SW {e.name} = {'on' if s.state else 'off'}" + ) + + c.subscribe_states(on_state) + await asyncio.sleep(1.0) + for op in OPS: + kind, _, rest = op.partition(":") + if kind == "wait": + await asyncio.sleep(float(rest)) + elif kind == "press": + if rest not in buttons: + sys.exit(f"no button {rest!r}; have {sorted(buttons)}") + print(f"{time.time() - T0:7.3f} >>> press {rest}") + c.button_command(buttons[rest].key) + elif kind == "sw": + name, _, val = rest.partition("=") + if name not in switches: + sys.exit(f"no switch {name!r}; have {sorted(switches)}") + print(f"{time.time() - T0:7.3f} >>> switch {name} {val}") + c.switch_command(switches[name].key, val == "on") + else: + sys.exit(f"unknown op {op!r}") + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/homeassistant_light/heldcase.py b/.agents/skills/hardware-test/scripts/homeassistant_light/heldcase.py new file mode 100644 index 00000000000..06437b171c7 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/homeassistant_light/heldcase.py @@ -0,0 +1,71 @@ +"""Held-report cases for esphome#21144 with persistent API connections, so the steps land inside the 3 s fade. +usage: heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY] (KEY or BULB_KEY of - means plaintext) + t0 press "A 50 fade 3s" on the mirror device (3 s fade to 50%) + t0+0.7 set the bulb directly to 90% (Home Assistant reports it to the mirror during the fade: held) + t0+1.4 same: press "A 50 fade 3s" again (same target as the running fade) + many: press "A many calls" (16 calls in one loop pass, the last one 100% with a 1 s fade) + t0+6.5 read the bulb, press Log state so the mirror prints its state +""" + +import asyncio +import sys +import time + +from aioesphomeapi import APIClient, ButtonInfo, LightInfo, LightState + +DEV, KEY, BULB, CASE = sys.argv[1:5] +BULB_KEY = sys.argv[5] if len(sys.argv) > 5 else "-" +if CASE not in ("same", "many"): + sys.exit(f"case must be same or many, got {CASE!r}") +T0 = time.time() + + +def stamp() -> str: + return f"{time.time() - T0:6.3f}" + + +async def main() -> None: + dev = APIClient(DEV, 6053, None, noise_psk=None if KEY == "-" else KEY) + bulb = APIClient(BULB, 6053, None, noise_psk=None if BULB_KEY == "-" else BULB_KEY) + await dev.connect(login=True) + await bulb.connect(login=True) + dents, _ = await dev.list_entities_services() + bents, _ = await bulb.list_entities_services() + buttons = {e.name: e for e in dents if isinstance(e, ButtonInfo)} + blight = next(e for e in bents if isinstance(e, LightInfo)) + states = {} + + def on_bulb(s) -> None: + if isinstance(s, LightState) and s.key == blight.key: + states["bulb"] = s + print(f"{stamp()} bulb state: on={s.state} bri={s.brightness:.2f}") + + bulb.subscribe_states(on_bulb) + await asyncio.sleep(1.0) + print(f"{stamp()} >>> bulb to 100% (precondition)") + bulb.light_command(blight.key, state=True, brightness=1.0) + await asyncio.sleep(4.0) + t = time.time() + print(f"{stamp()} >>> t0 press A 50 fade 3s") + dev.button_command(buttons["A 50 fade 3s"].key) + await asyncio.sleep(0.7 - (time.time() - t)) + print(f"{stamp()} >>> bulb direct to 90%") + bulb.light_command(blight.key, state=True, brightness=0.9) + await asyncio.sleep(1.4 - (time.time() - t)) + name = "A 50 fade 3s" if CASE == "same" else "A many calls" + print(f"{stamp()} >>> press {name}") + dev.button_command(buttons[name].key) + await asyncio.sleep(6.5 - (time.time() - t)) + print(f"{stamp()} >>> press Log state (the mirror's state is in the device log)") + dev.button_command(buttons["Log state"].key) + await asyncio.sleep(1.0) + b = states.get("bulb") + print( + f"{stamp()} RESULT bulb " + + (f"bri={b.brightness:.2f}" if b is not None else "no state") + ) + await dev.disconnect() + await bulb.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/ld2450/zonedrive.py b/.agents/skills/hardware-test/scripts/ld2450/zonedrive.py new file mode 100644 index 00000000000..cf0bfb5d815 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ld2450/zonedrive.py @@ -0,0 +1,85 @@ +"""Drive LD2450 numbers and selects over the API while printing every LD2450 state update. +usage: zonedrive.py HOST KEY(- for plaintext) OP [OP ...] + OP: set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS | list +Every state update of an entity whose name contains "LD2450" is printed with a timestamp. +""" + +import asyncio +import sys +import time + +from aioesphomeapi import ( + APIClient, + NumberInfo, + NumberState, + SelectInfo, + SelectState, + SensorInfo, + SensorState, +) + +HOST, KEY = sys.argv[1], sys.argv[2] +OPS = sys.argv[3:] +T0 = time.time() + + +def stamp() -> str: + return f"{time.time() - T0:7.3f}" + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + by_key = {e.key: e for e in ents} + numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)} + selects = {e.name: e for e in ents if isinstance(e, SelectInfo)} + sensors = {e.name: e for e in ents if isinstance(e, SensorInfo)} + + def on_state(s) -> None: + e = by_key.get(s.key) + if e is None or "LD2450" not in e.name: + return + if isinstance(s, NumberState): + print( + f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}" + ) + elif isinstance(s, SelectState): + print(f"{stamp()} SEL {e.name} = {s.state}") + elif isinstance(s, SensorState) and "Count" in e.name: + print(f"{stamp()} CNT {e.name} = {s.state:.0f}") + + c.subscribe_states(on_state) + await asyncio.sleep(1.5) + for op in OPS: + if op == "list": + for n in sorted(numbers): + print("number:", n) + for n in sorted(selects): + print("select:", n, selects[n].options) + for n in sorted(sensors): + if "Count" in n: + print("sensor:", n) + continue + kind, _, rest = op.partition(":") + if kind == "wait": + await asyncio.sleep(float(rest)) + continue + name, _, value = rest.partition("=") + if kind == "set": + if name not in numbers: + sys.exit(f"no number named {name!r}; have {sorted(numbers)}") + print(f"{stamp()} >>> set {name} = {value}") + c.number_command(numbers[name].key, float(value)) + elif kind == "sel": + if name not in selects: + sys.exit(f"no select named {name!r}; have {sorted(selects)}") + print(f"{stamp()} >>> select {name} = {value}") + c.select_command(selects[name].key, value) + else: + sys.exit(f"unknown op {op!r}") + await asyncio.sleep(0.3) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/templates/dfrobot-sen0395-dut.yaml b/.agents/skills/hardware-test/templates/dfrobot-sen0395-dut.yaml new file mode 100644 index 00000000000..af8e732adab --- /dev/null +++ b/.agents/skills/hardware-test/templates/dfrobot-sen0395-dut.yaml @@ -0,0 +1,186 @@ +# DFRobot SEN0395 on an ESP32 DevKit V1: V to VIN (5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17 (TX2), +# IO2 to GPIO18, I1 unconnected. Settings buttons, the four switches and junk-line buttons whose echo and +# "is not recognized" reply come back as lines of known length (220 and 240 bytes give replies over 252 chars). +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: VERBOSE + logs: + api: DEBUG + api.connection: DEBUG + api.service: DEBUG + wifi: DEBUG + mdns: DEBUG + scheduler: DEBUG + component: DEBUG + esp32: DEBUG + uart: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +uart: + id: mmwave_uart + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 115200 + +dfrobot_sen0395: + id: mmwave + uart_id: mmwave_uart + +binary_sensor: + - platform: dfrobot_sen0395 + dfrobot_sen0395_id: mmwave + name: Detected via UART + id: uart_detected + - platform: gpio + name: Detected via GPIO + device_class: motion + pin: + number: GPIO18 + mode: INPUT_PULLDOWN + +switch: + - platform: dfrobot_sen0395 + dfrobot_sen0395_id: mmwave + type: sensor_active + name: Sensor active + - platform: dfrobot_sen0395 + dfrobot_sen0395_id: mmwave + type: presence_via_uart + name: Presence via UART + - platform: dfrobot_sen0395 + dfrobot_sen0395_id: mmwave + type: turn_on_led + name: LED + - platform: dfrobot_sen0395 + dfrobot_sen0395_id: mmwave + type: start_after_boot + name: Start after boot + +button: + - platform: template + name: Range 0 to 3m + on_press: + - dfrobot_sen0395.settings: + id: mmwave + detection_segments: + - [0cm, 3m] + - platform: template + name: Range 0 to 9m + on_press: + - dfrobot_sen0395.settings: + id: mmwave + detection_segments: + - [0cm, 9m] + - platform: template + name: Latency 1s 2s + on_press: + - dfrobot_sen0395.settings: + id: mmwave + output_latency: + delay_after_detect: 1s + delay_after_disappear: 2s + - platform: template + name: Latency 0 0 + on_press: + - dfrobot_sen0395.settings: + id: mmwave + output_latency: + delay_after_detect: 0s + delay_after_disappear: 0s + - platform: template + name: Sensitivity 7 + on_press: + - dfrobot_sen0395.settings: + id: mmwave + sensitivity: 7 + - platform: template + name: Factory reset + on_press: + - dfrobot_sen0395.settings: + id: mmwave + factory_reset: true + - platform: template + name: Reset + on_press: + - dfrobot_sen0395.reset: + id: mmwave + - platform: template + name: Junk 100 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(100, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + - platform: template + name: Junk 160 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(160, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + - platform: template + name: Junk 200 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(200, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + - platform: template + name: Junk 220 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(220, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + - platform: template + name: Junk 240 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(240, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + - platform: template + name: Junk 256 + on_press: + - uart.write: + id: mmwave_uart + data: !lambda |- + std::vector v(256, 'x'); + v.push_back('\r'); + v.push_back('\n'); + return v; + +interval: + - interval: 30s + then: + - lambda: 'ESP_LOGI("sen", "alive uart_detected=%d heap=%u", id(uart_detected).state, (unsigned) esp_get_free_heap_size());' diff --git a/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml index 9681a41de7a..7a42029571a 100644 --- a/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml +++ b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml @@ -124,6 +124,20 @@ button: name: B off on_press: - light.turn_off: bulb_b + - platform: template + name: A 50 fade 3s + on_press: + - light.turn_on: + id: bulb_a + brightness: 50% + transition_length: 3s + - platform: template + name: A many calls + on_press: + - lambda: |- + for (int i = 0; i < 16; i++) { + id(bulb_a).make_call().set_brightness(i == 15 ? 1.0f : 0.6f).set_transition_length(1000).perform(); + } - platform: template name: Log state on_press: From 89eb58edce51d1a3a92830b46394bc457d7aa27c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 03:06:58 -1000 Subject: [PATCH 42/43] [ld2420][ld2450] Use Apply numbers for hub forwarding numbers (#21146) --- esphome/components/ld2420/ld2420.cpp | 11 +++ esphome/components/ld2420/ld2420.h | 2 + esphome/components/ld2420/number/__init__.py | 89 +++++++++---------- .../ld2420/number/gate_config_number.cpp | 37 -------- .../ld2420/number/gate_config_number.h | 44 --------- esphome/components/ld2450/ld2450.cpp | 8 +- esphome/components/ld2450/ld2450.h | 2 +- esphome/components/ld2450/number/__init__.py | 54 +++++------ .../ld2450/number/zone_coordinate_number.cpp | 12 --- .../ld2450/number/zone_coordinate_number.h | 17 ---- .../ld2420/ld2420_gate_threshold.cpp | 71 +++++++++++++++ tests/components/ld2420/number/__init__.py | 12 +++ .../components/ld2450/ld2450_zone_number.cpp | 83 +++++++++++++++++ tests/components/ld2450/number/__init__.py | 12 +++ 14 files changed, 261 insertions(+), 193 deletions(-) delete mode 100644 esphome/components/ld2450/number/zone_coordinate_number.cpp delete mode 100644 esphome/components/ld2450/number/zone_coordinate_number.h create mode 100644 tests/components/ld2420/ld2420_gate_threshold.cpp create mode 100644 tests/components/ld2420/number/__init__.py create mode 100644 tests/components/ld2450/ld2450_zone_number.cpp create mode 100644 tests/components/ld2450/number/__init__.py diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index b5f3292fbc2..849190ff3a2 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -917,6 +917,17 @@ void LD2420Component::init_gate_config_numbers() { } } +void LD2420Component::set_gate_threshold_value(number::Number *n, float value) { + for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) { + const bool move = this->gate_move_threshold_numbers_[gate] == n; + if (!move && this->gate_still_threshold_numbers_[gate] != n) + continue; + const uint8_t target = this->is_gate_select() ? this->get_gate_select_value() : gate; + (move ? this->new_config.move_thresh : this->new_config.still_thresh)[target] = value; + return; + } +} + void LD2420Component::refresh_gate_config_numbers() { this->gate_timeout_number_->publish_state(this->new_config.timeout); this->min_gate_distance_number_->publish_state(this->new_config.min_gate); diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index e13d0271e10..14c83497997 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -89,6 +89,8 @@ class LD2420Component final : public Component, public uart::UARTDevice { void publish_gate_still_threshold(uint8_t gate) { this->gate_still_threshold_numbers_[0]->publish_state(this->new_config.still_thresh[gate]); }; + // Stores a threshold number's value for its gate, or for the selected gate with gate_select + void set_gate_threshold_value(number::Number *n, float value); void init_gate_config_numbers(); void refresh_gate_config_numbers(); #endif diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index 200f4e202fb..07abfdbf440 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -1,8 +1,9 @@ +from functools import partial + import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MOVE_THRESHOLD, CONF_STILL_THRESHOLD, DEVICE_CLASS_DISTANCE, @@ -16,22 +17,9 @@ from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns -LD2420TimeoutNumber = ld2420_ns.class_("LD2420TimeoutNumber", number.Number) -LD2420MoveSensFactorNumber = ld2420_ns.class_( - "LD2420MoveSensFactorNumber", number.Number -) -LD2420StillSensFactorNumber = ld2420_ns.class_( - "LD2420StillSensFactorNumber", number.Number -) LD2420MinDistanceNumber = ld2420_ns.class_("LD2420MinDistanceNumber", number.Number) LD2420MaxDistanceNumber = ld2420_ns.class_("LD2420MaxDistanceNumber", number.Number) LD2420GateSelectNumber = ld2420_ns.class_("LD2420GateSelectNumber", number.Number) -LD2420MoveThresholdNumbers = ld2420_ns.class_( - "LD2420MoveThresholdNumbers", number.Number -) -LD2420StillThresholdNumbers = ld2420_ns.class_( - "LD2420StillThresholdNumbers", number.Number -) CONF_MIN_GATE_DISTANCE = "min_gate_distance" CONF_MAX_GATE_DISTANCE = "max_gate_distance" CONF_GATE_MOVE_SENSITIVITY = "gate_move_sensitivity" @@ -46,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component), cv.Inclusive(CONF_PRESENCE_TIMEOUT, TIMEOUT_GROUP): number.number_schema( - LD2420TimeoutNumber, + number.ApplyNumber, unit_of_measurement=UNIT_SECOND, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_TIMELAPSE, @@ -70,23 +58,23 @@ CONFIG_SCHEMA = cv.Schema( icon=ICON_MOTION_SENSOR, ), cv.Inclusive(CONF_STILL_THRESHOLD, GATE_GROUP): number.number_schema( - LD2420StillThresholdNumbers, + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), cv.Inclusive(CONF_MOVE_THRESHOLD, GATE_GROUP): number.number_schema( - LD2420MoveThresholdNumbers, + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), cv.Optional(CONF_GATE_MOVE_SENSITIVITY): number.number_schema( - LD2420MoveSensFactorNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_SCALE, ), cv.Optional(CONF_GATE_STILL_SENSITIVITY): number.number_schema( - LD2420StillSensFactorNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_SCALE, @@ -98,12 +86,12 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(f"gate_{x}"): ( { cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( - LD2420MoveThresholdNumbers, + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), cv.Required(CONF_STILL_THRESHOLD): number.number_schema( - LD2420StillThresholdNumbers, + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), @@ -123,6 +111,8 @@ async def to_code(config: ConfigType) -> None: min_value=0, max_value=255, step=5, + apply="new_config.timeout = value", + publish="before", ) await numbers( CONF_MIN_GATE_DISTANCE, @@ -144,6 +134,8 @@ async def to_code(config: ConfigType) -> None: min_value=0.05, max_value=1, step=0.025, + apply="gate_move_sensitivity_factor = value", + publish="before", ) await numbers( CONF_GATE_STILL_SENSITIVITY, @@ -151,7 +143,16 @@ async def to_code(config: ConfigType) -> None: min_value=0.05, max_value=1, step=0.025, + apply="gate_still_sensitivity_factor = value", + publish="before", ) + threshold = { + "min_value": 0, + "max_value": 65535, + "step": 25, + "apply": "set_gate_threshold_value(entity, value)", + "publish": "before", + } if config.get(CONF_GATE_SELECT): await numbers( CONF_GATE_SELECT, @@ -160,35 +161,27 @@ async def to_code(config: ConfigType) -> None: max_value=15, step=1, ) - if gate_still_threshold := config.get(CONF_STILL_THRESHOLD): - n = cg.new_Pvariable(gate_still_threshold[CONF_ID]) - await number.register_number( - n, gate_still_threshold, min_value=0, max_value=65535, step=25 - ) - await cg.register_parented(n, hub) - cg.add(hub.set_gate_still_threshold_numbers(0, n)) - if gate_move_threshold := config.get(CONF_MOVE_THRESHOLD): - n = cg.new_Pvariable(gate_move_threshold[CONF_ID]) - await number.register_number( - n, gate_move_threshold, min_value=0, max_value=65535, step=25 - ) - await cg.register_parented(n, hub) - cg.add(hub.set_gate_move_threshold_numbers(0, n)) + # With gate_select one pair of numbers sits at index 0 and edits the selected gate + await numbers( + CONF_STILL_THRESHOLD, + partial(hub.set_gate_still_threshold_numbers, 0), + **threshold, + ) + await numbers( + CONF_MOVE_THRESHOLD, + partial(hub.set_gate_move_threshold_numbers, 0), + **threshold, + ) else: for x in range(16): if gate_conf := config.get(f"gate_{x}"): - move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) - await number.register_number( - n, move_config, min_value=0, max_value=65535, step=25 + await numbers.create( + gate_conf[CONF_MOVE_THRESHOLD], + partial(hub.set_gate_move_threshold_numbers, x), + **threshold, ) - await cg.register_parented(n, hub) - cg.add(hub.set_gate_move_threshold_numbers(x, n)) - - still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) - await number.register_number( - n, still_config, min_value=0, max_value=65535, step=25 + await numbers.create( + gate_conf[CONF_STILL_THRESHOLD], + partial(hub.set_gate_still_threshold_numbers, x), + **threshold, ) - await cg.register_parented(n, hub) - cg.add(hub.set_gate_still_threshold_numbers(x, n)) diff --git a/esphome/components/ld2420/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp index 491cd857349..ee0088c6499 100644 --- a/esphome/components/ld2420/number/gate_config_number.cpp +++ b/esphome/components/ld2420/number/gate_config_number.cpp @@ -6,11 +6,6 @@ ESPHOME_LOG_TAG(TAG, "ld2420.number"); namespace esphome::ld2420 { -void LD2420TimeoutNumber::control(float timeout) { - this->publish_state(timeout); - this->parent_->new_config.timeout = timeout; -} - void LD2420MinDistanceNumber::control(float min_gate) { if ((uint16_t) min_gate > this->parent_->new_config.max_gate) { min_gate = this->parent_->get_min_gate_distance_value(); @@ -36,36 +31,4 @@ void LD2420GateSelectNumber::control(float gate_select) { this->parent_->publish_gate_still_threshold(gate); } -void LD2420MoveSensFactorNumber::control(float move_factor) { - this->publish_state(move_factor); - this->parent_->gate_move_sensitivity_factor = move_factor; -} - -void LD2420StillSensFactorNumber::control(float still_factor) { - this->publish_state(still_factor); - this->parent_->gate_still_sensitivity_factor = still_factor; -} - -LD2420MoveThresholdNumbers::LD2420MoveThresholdNumbers(uint8_t gate) : gate_(gate) {} - -void LD2420MoveThresholdNumbers::control(float move_threshold) { - this->publish_state(move_threshold); - if (!this->parent_->is_gate_select()) { - this->parent_->new_config.move_thresh[this->gate_] = move_threshold; - } else { - this->parent_->new_config.move_thresh[this->parent_->get_gate_select_value()] = move_threshold; - } -} - -LD2420StillThresholdNumbers::LD2420StillThresholdNumbers(uint8_t gate) : gate_(gate) {} - -void LD2420StillThresholdNumbers::control(float still_threshold) { - this->publish_state(still_threshold); - if (!this->parent_->is_gate_select()) { - this->parent_->new_config.still_thresh[this->gate_] = still_threshold; - } else { - this->parent_->new_config.still_thresh[this->parent_->get_gate_select_value()] = still_threshold; - } -} - } // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index e1c12e023a9..eb19db95d9a 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -5,14 +5,6 @@ namespace esphome::ld2420 { -class LD2420TimeoutNumber final : public number::Number, public Parented { - public: - LD2420TimeoutNumber() = default; - - protected: - void control(float timeout) override; -}; - class LD2420MinDistanceNumber final : public number::Number, public Parented { public: LD2420MinDistanceNumber() = default; @@ -37,40 +29,4 @@ class LD2420GateSelectNumber final : public number::Number, public Parented { - public: - LD2420MoveSensFactorNumber() = default; - - protected: - void control(float move_factor) override; -}; - -class LD2420StillSensFactorNumber final : public number::Number, public Parented { - public: - LD2420StillSensFactorNumber() = default; - - protected: - void control(float still_factor) override; -}; - -class LD2420StillThresholdNumbers final : public number::Number, public Parented { - public: - LD2420StillThresholdNumbers() = default; - LD2420StillThresholdNumbers(uint8_t gate); - - protected: - uint8_t gate_; - void control(float still_threshold) override; -}; - -class LD2420MoveThresholdNumbers final : public number::Number, public Parented { - public: - LD2420MoveThresholdNumbers() = default; - LD2420MoveThresholdNumbers(uint8_t gate); - - protected: - uint8_t gate_; - void control(float move_threshold) override; -}; - } // namespace esphome::ld2420 diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index e14be5ac968..a1bcb96b607 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -957,7 +957,13 @@ void LD2450Component::set_direction_text_sensor(uint8_t target, text_sensor::Tex // Send Zone coordinates data to LD2450 #ifdef USE_NUMBER -void LD2450Component::set_zone_coordinate(uint8_t zone) { +void LD2450Component::set_zone_coordinate(number::Number *n) { + uint8_t zone = 0; + while (zone < MAX_ZONES && this->zone_numbers_[zone].x1 != n && this->zone_numbers_[zone].y1 != n && + this->zone_numbers_[zone].x2 != n && this->zone_numbers_[zone].y2 != n) + zone++; + if (zone == MAX_ZONES) + return; number::Number *x1sens = this->zone_numbers_[zone].x1; number::Number *y1sens = this->zone_numbers_[zone].y1; number::Number *x2sens = this->zone_numbers_[zone].x2; diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index f85a26ba675..feaaa500e33 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -128,7 +128,7 @@ class LD2450Component : public Component, public uart::UARTDevice { void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s); #endif #ifdef USE_NUMBER - void set_zone_coordinate(uint8_t zone); + void set_zone_coordinate(number::Number *n); void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2); #endif #ifdef USE_SENSOR diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 237c7c4c231..576b8c966ce 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -25,7 +25,6 @@ ICON_ARROW_TOP_LEFT_BOLD_BOX_OUTLINE = "mdi:arrow-top-left-bold-box-outline" MAX_ZONES = 3 PresenceTimeoutNumber = ld2450_ns.class_("PresenceTimeoutNumber", number.Number) -ZoneCoordinateNumber = ld2450_ns.class_("ZoneCoordinateNumber", number.Number) CONFIG_SCHEMA = cv.Schema( { @@ -45,28 +44,28 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(f"zone_{n + 1}"): cv.Schema( { cv.Required(CONF_X1): number.number_schema( - ZoneCoordinateNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_ARROW_TOP_LEFT_BOLD_BOX_OUTLINE, ), cv.Required(CONF_Y1): number.number_schema( - ZoneCoordinateNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_ARROW_TOP_LEFT, ), cv.Required(CONF_X2): number.number_schema( - ZoneCoordinateNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_ARROW_BOTTOM_RIGHT_BOLD_BOX_OUTLINE, ), cv.Required(CONF_Y2): number.number_schema( - ZoneCoordinateNumber, + number.ApplyNumber, device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, entity_category=ENTITY_CATEGORY_CONFIG, @@ -91,32 +90,21 @@ async def to_code(config: ConfigType) -> None: ) for zone_num in range(MAX_ZONES): if zone_conf := config.get(f"zone_{zone_num + 1}"): - zone_x1_config = zone_conf.get(CONF_X1) - x1 = cg.new_Pvariable(zone_x1_config[CONF_ID], zone_num) - await number.register_number( - x1, zone_x1_config, min_value=-4860, max_value=4860, step=1 - ) - await cg.register_parented(x1, hub) - - zone_y1_config = zone_conf.get(CONF_Y1) - y1 = cg.new_Pvariable(zone_y1_config[CONF_ID], zone_num) - await number.register_number( - y1, zone_y1_config, min_value=0, max_value=7560, step=1 - ) - await cg.register_parented(y1, hub) - - zone_x2_config = zone_conf.get(CONF_X2) - x2 = cg.new_Pvariable(zone_x2_config[CONF_ID], zone_num) - await number.register_number( - x2, zone_x2_config, min_value=-4860, max_value=4860, step=1 - ) - await cg.register_parented(x2, hub) - - zone_y2_config = zone_conf.get(CONF_Y2) - y2 = cg.new_Pvariable(zone_y2_config[CONF_ID], zone_num) - await number.register_number( - y2, zone_y2_config, min_value=0, max_value=7560, step=1 - ) - await cg.register_parented(y2, hub) - + x1, y1, x2, y2 = [ + await numbers.create( + zone_conf[key], + None, + min_value=lo, + max_value=hi, + step=1, + apply="set_zone_coordinate(entity)", + publish="before", + ) + for key, lo, hi in ( + (CONF_X1, -4860, 4860), + (CONF_Y1, 0, 7560), + (CONF_X2, -4860, 4860), + (CONF_Y2, 0, 7560), + ) + ] cg.add(hub.set_zone_numbers(zone_num, x1, y1, x2, y2)) diff --git a/esphome/components/ld2450/number/zone_coordinate_number.cpp b/esphome/components/ld2450/number/zone_coordinate_number.cpp deleted file mode 100644 index 83be3cbec4b..00000000000 --- a/esphome/components/ld2450/number/zone_coordinate_number.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "zone_coordinate_number.h" - -namespace esphome::ld2450 { - -ZoneCoordinateNumber::ZoneCoordinateNumber(uint8_t zone) : zone_(zone) {} - -void ZoneCoordinateNumber::control(float value) { - this->publish_state(value); - this->parent_->set_zone_coordinate(this->zone_); -} - -} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/zone_coordinate_number.h b/esphome/components/ld2450/number/zone_coordinate_number.h deleted file mode 100644 index f5a389d7129..00000000000 --- a/esphome/components/ld2450/number/zone_coordinate_number.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/number/number.h" -#include "../ld2450.h" - -namespace esphome::ld2450 { - -class ZoneCoordinateNumber : public number::Number, public Parented { - public: - ZoneCoordinateNumber(uint8_t zone); - - protected: - uint8_t zone_; - void control(float value) override; -}; - -} // namespace esphome::ld2450 diff --git a/tests/components/ld2420/ld2420_gate_threshold.cpp b/tests/components/ld2420/ld2420_gate_threshold.cpp new file mode 100644 index 00000000000..7937001fcb7 --- /dev/null +++ b/tests/components/ld2420/ld2420_gate_threshold.cpp @@ -0,0 +1,71 @@ +#include +#include "esphome/components/ld2420/ld2420.h" + +namespace esphome::ld2420::testing { + +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +class LD2420GateThresholdTest : public ::testing::Test { + protected: + void SetUp() override { + for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) { + this->ld2420_.new_config.move_thresh[gate] = 1000 + gate; + this->ld2420_.new_config.still_thresh[gate] = 2000 + gate; + } + } + void expect_untouched_except_(const uint32_t *thresh, uint32_t base, int skip) { + for (int gate = 0; gate < TOTAL_GATES; gate++) { + if (gate != skip) + EXPECT_EQ(thresh[gate], base + gate) << "gate " << gate; + } + } + + LD2420Component ld2420_; + TestNumber move_; + TestNumber still_; +}; + +TEST_F(LD2420GateThresholdTest, PerGateNumbersWriteTheirOwnGate) { + this->ld2420_.set_gate_move_threshold_numbers(3, &this->move_); + this->ld2420_.set_gate_still_threshold_numbers(3, &this->still_); + + this->ld2420_.set_gate_threshold_value(&this->move_, 500); + EXPECT_EQ(this->ld2420_.new_config.move_thresh[3], 500u); + this->expect_untouched_except_(this->ld2420_.new_config.move_thresh, 1000, 3); + this->expect_untouched_except_(this->ld2420_.new_config.still_thresh, 2000, -1); + + this->ld2420_.set_gate_threshold_value(&this->still_, 750); + EXPECT_EQ(this->ld2420_.new_config.still_thresh[3], 750u); + EXPECT_EQ(this->ld2420_.new_config.move_thresh[3], 500u); + this->expect_untouched_except_(this->ld2420_.new_config.still_thresh, 2000, 3); +} + +TEST_F(LD2420GateThresholdTest, GateSelectWritesTheSelectedGate) { + TestNumber select; + this->ld2420_.set_gate_select_number(&select); + this->ld2420_.set_gate_move_threshold_numbers(0, &this->move_); + this->ld2420_.set_gate_still_threshold_numbers(0, &this->still_); + select.publish_state(5); + + this->ld2420_.set_gate_threshold_value(&this->move_, 300); + this->ld2420_.set_gate_threshold_value(&this->still_, 400); + EXPECT_EQ(this->ld2420_.new_config.move_thresh[5], 300u); + EXPECT_EQ(this->ld2420_.new_config.still_thresh[5], 400u); + this->expect_untouched_except_(this->ld2420_.new_config.move_thresh, 1000, 5); + this->expect_untouched_except_(this->ld2420_.new_config.still_thresh, 2000, 5); +} + +TEST_F(LD2420GateThresholdTest, UnregisteredNumberChangesNothing) { + this->ld2420_.set_gate_move_threshold_numbers(3, &this->move_); + this->ld2420_.set_gate_still_threshold_numbers(3, &this->still_); + TestNumber other; + + this->ld2420_.set_gate_threshold_value(&other, 123); + this->expect_untouched_except_(this->ld2420_.new_config.move_thresh, 1000, -1); + this->expect_untouched_except_(this->ld2420_.new_config.still_thresh, 2000, -1); +} + +} // namespace esphome::ld2420::testing diff --git a/tests/components/ld2420/number/__init__.py b/tests/components/ld2420/number/__init__.py new file mode 100644 index 00000000000..539d06de1a3 --- /dev/null +++ b/tests/components/ld2420/number/__init__.py @@ -0,0 +1,12 @@ +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Turns on USE_NUMBER so the hub's number forwarding code is in the host test build + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("number") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["number"] diff --git a/tests/components/ld2450/ld2450_zone_number.cpp b/tests/components/ld2450/ld2450_zone_number.cpp new file mode 100644 index 00000000000..0c5cf993aeb --- /dev/null +++ b/tests/components/ld2450/ld2450_zone_number.cpp @@ -0,0 +1,83 @@ +#include +#include "common.h" + +namespace esphome::ld2450::testing { + +namespace { +class CapturingUARTComponent : public MockUARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + std::vector written; +}; + +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +// Header, length 28, command 0xC2 (set zone): the coordinate payload follows two zone type bytes. +constexpr uint8_t SET_ZONE_PREFIX[] = {0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0xC2, 0x00}; +} // namespace + +class LD2450ZoneNumberTest : public ::testing::Test { + protected: + void SetUp() override { + this->ld2450_.set_uart_parent(&this->uart_); + for (auto &zone : this->ld2450_.zone_config_) { + zone = {1, 2, 3, 4}; + } + // Only the third zone has numbers, so the lookup must walk past the empty slots. + this->ld2450_.set_zone_numbers(2, &this->x1_, &this->y1_, &this->x2_, &this->y2_); + this->x1_.publish_state(-100); + this->y1_.publish_state(200); + this->x2_.publish_state(1500); + this->y2_.publish_state(3000); + } + + CapturingUARTComponent uart_; + TestableLD2450 ld2450_; + TestNumber x1_, y1_, x2_, y2_; +}; + +TEST_F(LD2450ZoneNumberTest, EachCoordinateNumberUpdatesItsZoneAndSendsCommand) { + for (TestNumber *n : {&this->x1_, &this->y1_, &this->x2_, &this->y2_}) { + this->uart_.written.clear(); + this->ld2450_.zone_config_[2] = {0, 0, 0, 0}; + this->ld2450_.set_zone_coordinate(n); + + EXPECT_EQ(this->ld2450_.zone_config_[2].x1, -100); + EXPECT_EQ(this->ld2450_.zone_config_[2].y1, 200); + EXPECT_EQ(this->ld2450_.zone_config_[2].x2, 1500); + EXPECT_EQ(this->ld2450_.zone_config_[2].y2, 3000); + for (size_t i = 0; i < 2; i++) { + EXPECT_EQ(this->ld2450_.zone_config_[i].x1, 1) << "zone " << i; + EXPECT_EQ(this->ld2450_.zone_config_[i].y2, 4) << "zone " << i; + } + + const auto &w = this->uart_.written; + auto it = std::search(w.begin(), w.end(), std::begin(SET_ZONE_PREFIX), std::end(SET_ZONE_PREFIX)); + ASSERT_NE(it, w.end()); + ASSERT_GE(static_cast(w.end() - it), sizeof(SET_ZONE_PREFIX) + 26 + 4); + const uint8_t *zone2 = &*it + sizeof(SET_ZONE_PREFIX) + 2 + 2 * 8; + const std::vector expected = {0x9C, 0xFF, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B}; + EXPECT_EQ(std::vector(zone2, zone2 + 8), expected); + } +} + +TEST_F(LD2450ZoneNumberTest, UnregisteredNumberChangesNothing) { + TestNumber other; + other.publish_state(42); + this->ld2450_.set_zone_coordinate(&other); + + for (size_t i = 0; i < MAX_ZONES; i++) { + EXPECT_EQ(this->ld2450_.zone_config_[i].x1, 1) << "zone " << i; + EXPECT_EQ(this->ld2450_.zone_config_[i].y1, 2) << "zone " << i; + EXPECT_EQ(this->ld2450_.zone_config_[i].x2, 3) << "zone " << i; + EXPECT_EQ(this->ld2450_.zone_config_[i].y2, 4) << "zone " << i; + } + EXPECT_TRUE(this->uart_.written.empty()); +} + +} // namespace esphome::ld2450::testing diff --git a/tests/components/ld2450/number/__init__.py b/tests/components/ld2450/number/__init__.py new file mode 100644 index 00000000000..539d06de1a3 --- /dev/null +++ b/tests/components/ld2450/number/__init__.py @@ -0,0 +1,12 @@ +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Turns on USE_NUMBER so the hub's number forwarding code is in the host test build + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("number") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["number"] From c0cbf9cdfdc77d211e3d9971bd1209f820fe54dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 03:07:31 -1000 Subject: [PATCH 43/43] [tests] Add require_service, assert_device_name and exact entity lookups (#21150) --- tests/integration/README.md | 37 ++++++--- tests/integration/state_utils.py | 62 ++++++++++++++- .../test_integration_state_utils.py | 77 +++++++++++++++++++ 3 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 tests/unit_tests/test_integration_state_utils.py diff --git a/tests/integration/README.md b/tests/integration/README.md index 553cb980942..50dc5fcb5b2 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -63,24 +63,45 @@ Consider converting existing integration tests to use `InitialStateHelper` for m #### Entity Lookup Helpers (`state_utils.py`) -Two helper functions simplify finding entities in test code: +These helpers simplify finding entities, services and device info in test code: -**`find_entity(entities, object_id_substring, entity_type=None)`** +**`find_entity(entities, object_id_substring, entity_type=None, *, exact=False)`** - Finds an entity by searching for a substring in its `object_id` (case-insensitive) +- With `exact=True`, the whole `object_id` must match exactly (case-sensitive) - Optionally filters by entity type (e.g., `BinarySensorInfo`) - Returns `None` if not found -**`require_entity(entities, object_id_substring, entity_type=None, description=None)`** +**`require_entity(entities, object_id_substring, entity_type=None, description=None, *, exact=False)`** - Same as `find_entity` but raises `AssertionError` if not found - Use `description` parameter for clearer error messages +**`require_only_entity(entities, entity_type)`** +- Returns the one entity of `entity_type`; raises `AssertionError` if there is none or more than one + +**`require_service(services, name)`** +- Returns the user service with exactly this name; fails the test with the available names if missing + +**`await assert_device_name(client, name)`** +- Fetches `device_info()`, asserts it is not `None` and has this name, and returns it + ```python -from aioesphomeapi import BinarySensorInfo -from .state_utils import require_entity +from aioesphomeapi import BinarySensorInfo, LockInfo +from .state_utils import ( + assert_device_name, + require_entity, + require_only_entity, + require_service, +) + +await assert_device_name(client, "host-test") +entities, services = await client.list_entities_services() # Find entities with clear error messages binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) button = require_entity(entities, "set_true", description="Set True button") +switch = require_entity(entities, "relay", exact=True) # not "relay_2" +lock = require_only_entity(entities, LockInfo) +my_service = require_service(services, "my_service") ``` ### Writing Tests @@ -97,8 +118,7 @@ async def test_my_feature( # Write, compile and run the ESPHome device, then connect to API async with run_compiled(yaml_config), api_client_connected() as client: # Test your feature using the connected client - device_info = await client.device_info() - assert device_info is not None + await assert_device_name(client, "my-test-device") ``` ### Creating YAML Fixtures @@ -260,8 +280,7 @@ except asyncio.TimeoutError: ```python # Find and execute service entities, services = await client.list_entities_services() -my_service = next((s for s in services if s.name == "my_service"), None) -assert my_service is not None +my_service = require_service(services, "my_service") # Execute with parameters await client.execute_service(my_service, {"param1": "value1", "param2": 42}) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 8b8e30c8df7..909164973e2 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -11,10 +11,12 @@ from aioesphomeapi import ( APIClient, BinarySensorState, ButtonInfo, + DeviceInfo, EntityInfo, EntityState, SensorState, TextSensorState, + UserService, ) _LOGGER = logging.getLogger(__name__) @@ -113,6 +115,8 @@ def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, + *, + exact: bool = False, ) -> T | EntityInfo | None: """Find an entity by object_id substring and optionally by type. @@ -120,6 +124,7 @@ def find_entity[T: EntityInfo]( entities: List of entity info objects from the API object_id_substring: Substring to search for in object_id (case-insensitive) entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + exact: Match the whole object_id exactly (case-sensitive) instead Returns: The first matching entity, or None if not found @@ -130,8 +135,12 @@ def find_entity[T: EntityInfo]( """ substring_lower = object_id_substring.lower() for entity in entities: - if substring_lower in entity.object_id.lower() and ( - entity_type is None or isinstance(entity, entity_type) + if entity_type is not None and not isinstance(entity, entity_type): + continue + if ( + entity.object_id == object_id_substring + if exact + else substring_lower in entity.object_id.lower() ): return entity return None @@ -142,6 +151,8 @@ def require_entity[T: EntityInfo]( object_id_substring: str, entity_type: type[T] | None = None, description: str | None = None, + *, + exact: bool = False, ) -> T | EntityInfo: """Find an entity or raise AssertionError if not found. @@ -150,6 +161,7 @@ def require_entity[T: EntityInfo]( object_id_substring: Substring to search for in object_id (case-insensitive) entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) description: Human-readable description for error message + exact: Match the whole object_id exactly (case-sensitive) instead Returns: The first matching entity @@ -161,14 +173,56 @@ def require_entity[T: EntityInfo]( binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) button = require_entity(entities, "set_true", description="Set True button") """ - entity = find_entity(entities, object_id_substring, entity_type) + entity = find_entity(entities, object_id_substring, entity_type, exact=exact) if entity is None: - desc = description or f"entity with '{object_id_substring}' in object_id" + desc = description or ( + f"entity with object_id '{object_id_substring}'" + if exact + else f"entity with '{object_id_substring}' in object_id" + ) type_info = f" of type {entity_type.__name__}" if entity_type else "" raise AssertionError(f"{desc}{type_info} not found in entities") return entity +def require_only_entity[T: EntityInfo]( + entities: list[EntityInfo], entity_type: type[T] +) -> T: + """Return the single entity of ``entity_type``. + + Raises AssertionError when there is none or more than one. + """ + matches = [entity for entity in entities if isinstance(entity, entity_type)] + assert len(matches) == 1, ( + f"expected exactly one {entity_type.__name__}, found " + f"{[entity.object_id for entity in matches]}" + ) + return matches[0] + + +def require_service(services: list[UserService], name: str) -> UserService: + """Return the user service named exactly ``name``. + + Raises AssertionError with the available service names when it is missing. + """ + service = next((s for s in services if s.name == name), None) + if service is None: + raise AssertionError( + f"service '{name}' not found; available: {[s.name for s in services]}" + ) + return service + + +async def assert_device_name(client: APIClient, name: str) -> DeviceInfo: + """Fetch device info, assert the device is called ``name`` and return the info.""" + device_info = await client.device_info() + assert device_info is not None, "device_info() returned None" + assert device_info.name == name, ( + f"expected device name '{name}', got '{device_info.name}'" + ) + return device_info + + def build_key_to_entity_mapping( entities: list[EntityInfo], entity_names: list[str] ) -> dict[int, str]: diff --git a/tests/unit_tests/test_integration_state_utils.py b/tests/unit_tests/test_integration_state_utils.py new file mode 100644 index 00000000000..21dad0e8f80 --- /dev/null +++ b/tests/unit_tests/test_integration_state_utils.py @@ -0,0 +1,77 @@ +"""Tests for the lookup helpers in tests/integration/state_utils.py.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from aioesphomeapi import ( + BinarySensorInfo, + ButtonInfo, + DeviceInfo, + EntityInfo, + LightInfo, + SensorInfo, + UserService, +) +import pytest + +from tests.integration.state_utils import ( + assert_device_name, + find_entity, + require_entity, + require_only_entity, + require_service, +) + +ENTITIES: list[EntityInfo] = [ + SensorInfo(object_id="temperature_raw", key=1), + SensorInfo(object_id="temperature", key=2), + BinarySensorInfo(object_id="Motion", key=3), + ButtonInfo(object_id="restart", key=4), +] + + +def test_find_entity_substring_default() -> None: + assert find_entity(ENTITIES, "temperature").key == 1 + assert find_entity(ENTITIES, "motion").key == 3 + + +def test_find_entity_exact() -> None: + assert find_entity(ENTITIES, "temperature", exact=True).key == 2 + assert find_entity(ENTITIES, "temp", exact=True) is None + assert find_entity(ENTITIES, "motion", exact=True) is None + assert find_entity(ENTITIES, "temperature", ButtonInfo, exact=True) is None + + +def test_require_entity_exact() -> None: + assert require_entity(ENTITIES, "Motion", BinarySensorInfo, exact=True).key == 3 + with pytest.raises(AssertionError, match="object_id 'temp' of type SensorInfo"): + require_entity(ENTITIES, "temp", SensorInfo, exact=True) + + +def test_require_only_entity() -> None: + assert require_only_entity(ENTITIES, ButtonInfo).key == 4 + with pytest.raises(AssertionError, match="exactly one SensorInfo"): + require_only_entity(ENTITIES, SensorInfo) + with pytest.raises(AssertionError, match=r"exactly one LightInfo, found \[\]"): + require_only_entity(ENTITIES, LightInfo) + + +def test_require_service() -> None: + services = [UserService(name="set_level", key=1), UserService(name="set", key=2)] + assert require_service(services, "set").key == 2 + with pytest.raises(AssertionError, match=r"'missing' not found.*set_level"): + require_service(services, "missing") + + +@pytest.mark.asyncio +async def test_assert_device_name() -> None: + info = DeviceInfo(name="host-test") + client = AsyncMock() + client.device_info.return_value = info + assert await assert_device_name(client, "host-test") is info + with pytest.raises(AssertionError, match="expected device name 'other'"): + await assert_device_name(client, "other") + client.device_info.return_value = None + with pytest.raises(AssertionError, match="returned None"): + await assert_device_name(client, "host-test")