From 55103c0652bdece270ac09e9f3224c2693d7e2d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:18:14 -0500 Subject: [PATCH 01/24] [ds2484] Fix read64() using uint8_t accumulator instead of uint64_t (#14479) Co-authored-by: Claude Opus 4.6 --- esphome/components/ds2484/ds2484.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ds2484/ds2484.cpp b/esphome/components/ds2484/ds2484.cpp index 7c890ff4339..0b36f868749 100644 --- a/esphome/components/ds2484/ds2484.cpp +++ b/esphome/components/ds2484/ds2484.cpp @@ -110,9 +110,9 @@ uint8_t DS2484OneWireBus::read8() { } uint64_t DS2484OneWireBus::read64() { - uint8_t response = 0; + uint64_t response = 0; for (uint8_t i = 0; i < 8; i++) { - response |= (this->read8() << (i * 8)); + response |= (static_cast(this->read8()) << (i * 8)); } return response; } From b6d7e8e14de939b4d6714a42138edb914fd346c4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:18:28 -0500 Subject: [PATCH 02/24] [sgp30] Fix serial number truncation from 48-bit to 24-bit (#14478) Co-authored-by: Claude Opus 4.6 --- esphome/components/sgp30/sgp30.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 18814405d48..35e5b3dd42b 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -41,7 +41,9 @@ void SGP30Component::setup() { this->mark_failed(); return; } - this->serial_number_ = encode_uint24(raw_serial_number[0], raw_serial_number[1], raw_serial_number[2]); + this->serial_number_ = (static_cast(raw_serial_number[0]) << 32) | + (static_cast(raw_serial_number[1]) << 16) | + static_cast(raw_serial_number[2]); ESP_LOGD(TAG, "Serial number: %" PRIu64, this->serial_number_); // Featureset identification for future use From c8e7f78a2567e51d4fa178799ad3351ce046272d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:32:50 -0500 Subject: [PATCH 03/24] [zwave_proxy] Fix uint8_t overflow for buffer index and frame end (#14480) Co-authored-by: Claude Opus 4.6 --- esphome/components/zwave_proxy/zwave_proxy.cpp | 4 ++++ esphome/components/zwave_proxy/zwave_proxy.h | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8506b19e7f4..b0836ac0728 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -281,6 +281,10 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: + if (this->buffer_index_ >= this->buffer_.size()) { + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + break; + } this->buffer_[this->buffer_index_++] = byte; if (!byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index eb26316f492..12cb9a90a1e 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -81,10 +81,10 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called - // 8-bit values (grouped together to minimize padding) - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent + // Small values (grouped by size to minimize padding) + uint16_t buffer_index_{0}; // Index for populating the data buffer + uint16_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module From c0143ac6d662967a8a3fbb008242ee4bc5f92a1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:46:40 -0600 Subject: [PATCH 04/24] [ai] Add docs note about keeping component index pages in sync (#14465) Co-authored-by: Brandon Harvey Co-authored-by: J. Nick Koston --- .ai/instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.ai/instructions.md b/.ai/instructions.md index 3c24177827f..240a47a52fd 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -286,6 +286,7 @@ This document provides essential context for AI models interacting with this pro * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome-docs` repository. * The contribution workflow is the same as for the codebase. + * When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync. * **Best Practices:** * **Component Development:** Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests. From 5df4fd0a271251d0dc29e0b76148b50550cfb582 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Mar 2026 15:51:51 -1000 Subject: [PATCH 05/24] [tests] Fix flaky uart_mock integration tests (#14476) --- .../external_components/uart_mock/__init__.py | 5 + .../uart_mock/uart_mock.cpp | 37 +- .../external_components/uart_mock/uart_mock.h | 4 + .../fixtures/uart_mock_ld2410.yaml | 8 + .../uart_mock_ld2410_engineering.yaml | 8 + .../fixtures/uart_mock_ld2412.yaml | 8 + .../uart_mock_ld2412_engineering.yaml | 8 + .../fixtures/uart_mock_modbus.yaml | 8 + .../fixtures/uart_mock_modbus_timing.yaml | 8 + tests/integration/state_utils.py | 96 ++++- tests/integration/test_uart_mock_ld2410.py | 353 ++++++----------- tests/integration/test_uart_mock_ld2412.py | 362 ++++++------------ tests/integration/test_uart_mock_modbus.py | 14 +- 13 files changed, 420 insertions(+), 499 deletions(-) diff --git a/tests/integration/fixtures/external_components/uart_mock/__init__.py b/tests/integration/fixtures/external_components/uart_mock/__init__.py index 8deab4c21ec..abb3abcc419 100644 --- a/tests/integration/fixtures/external_components/uart_mock/__init__.py +++ b/tests/integration/fixtures/external_components/uart_mock/__init__.py @@ -43,6 +43,7 @@ CONF_INJECT_RX = "inject_rx" CONF_EXPECT_TX = "expect_tx" CONF_PERIODIC_RX = "periodic_rx" CONF_ON_TX = "on_tx" +CONF_AUTO_START = "auto_start" UART_PARITY_OPTIONS = { "NONE": uart.UARTParityOptions.UART_CONFIG_PARITY_NONE, @@ -95,6 +96,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_INJECTIONS, default=[]): cv.ensure_list(INJECTION_SCHEMA), cv.Optional(CONF_RESPONSES, default=[]): cv.ensure_list(RESPONSE_SCHEMA), cv.Optional(CONF_PERIODIC_RX, default=[]): cv.ensure_list(PERIODIC_RX_SCHEMA), + cv.Optional(CONF_AUTO_START, default=True): cv.boolean, cv.Optional(CONF_ON_TX): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MockUartTXTrigger), @@ -138,6 +140,9 @@ async def to_code(config): cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) + if not config[CONF_AUTO_START]: + cg.add(var.set_auto_start(False)) + for injection in config[CONF_INJECTIONS]: rx_data = injection[CONF_INJECT_RX] delay_ms = injection[CONF_DELAY] diff --git a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp index a4a1c41234c..83a13793be7 100644 --- a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp +++ b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp @@ -16,17 +16,21 @@ void MockUartComponent::setup() { } void MockUartComponent::loop() { - uint32_t now = App.get_loop_component_start_time(); - - // Initialize scenario start time on first loop() call, after all components have - // finished setup(). This prevents injection delays from being consumed during setup. if (!this->loop_started_) { this->loop_started_ = true; - this->scenario_start_ms_ = now; - this->cumulative_delay_ms_ = 0; - ESP_LOGD(TAG, "Scenario started at %u ms", now); + if (this->auto_start_) { + this->start_scenario(); + } else { + ESP_LOGD(TAG, "Scenario waiting for manual start"); + } } + if (!this->scenario_active_) { + return; + } + + uint32_t now = App.get_loop_component_start_time(); + // Process at most ONE timed injection per loop iteration. // This ensures each injection is in a separate loop cycle, giving the consuming // component (e.g., LD2410) a chance to process each batch independently. @@ -50,6 +54,19 @@ void MockUartComponent::loop() { } } +void MockUartComponent::start_scenario() { + uint32_t now = App.get_loop_component_start_time(); + this->scenario_active_ = true; + this->scenario_start_ms_ = now; + this->cumulative_delay_ms_ = 0; + this->injection_index_ = 0; + this->tx_buffer_.clear(); + for (auto &periodic : this->periodic_rx_) { + periodic.last_inject_ms = now; + } + ESP_LOGD(TAG, "Scenario started at %u ms", now); +} + void MockUartComponent::dump_config() { ESP_LOGCONFIG(TAG, "Mock UART Component:\n" @@ -78,10 +95,12 @@ void MockUartComponent::write_array(const uint8_t *data, size_t len) { } #endif - this->try_match_response_(); + if (this->scenario_active_) { + this->try_match_response_(); + } // This directly calls a tx_hook (lambda) as an alternative to the simpler match_response mechanism. - if (this->tx_hook_) { + if (this->tx_hook_ && this->scenario_active_) { std::vector buf(data, data + len); this->tx_hook_(buf); } diff --git a/tests/integration/fixtures/external_components/uart_mock/uart_mock.h b/tests/integration/fixtures/external_components/uart_mock/uart_mock.h index 5bbc3c1bf60..b721512f96c 100644 --- a/tests/integration/fixtures/external_components/uart_mock/uart_mock.h +++ b/tests/integration/fixtures/external_components/uart_mock/uart_mock.h @@ -37,6 +37,8 @@ class MockUartComponent : public uart::UARTComponent, public Component { void add_response(const std::vector &expect_tx, const std::vector &inject_rx); void add_periodic_rx(const std::vector &data, uint32_t interval_ms); + void start_scenario(); + void set_auto_start(bool auto_start) { this->auto_start_ = auto_start; } void set_tx_hook(std::function &)> &&cb) { this->tx_hook_ = std::move(cb); } void inject_to_rx_buffer(const std::vector &data); void inject_to_rx_buffer(const uint8_t *data, size_t len); @@ -55,6 +57,8 @@ class MockUartComponent : public uart::UARTComponent, public Component { uint32_t scenario_start_ms_{0}; uint32_t cumulative_delay_ms_{0}; bool loop_started_{false}; + bool auto_start_{true}; + bool scenario_active_{false}; // TX-triggered responses struct Response { diff --git a/tests/integration/fixtures/uart_mock_ld2410.yaml b/tests/integration/fixtures/uart_mock_ld2410.yaml index 9a814682630..59838b0599c 100644 --- a/tests/integration/fixtures/uart_mock_ld2410.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410.yaml @@ -20,6 +20,7 @@ uart: uart_mock: id: mock_uart baud_rate: 256000 + auto_start: false injections: # Phase 1 (t=100ms): Valid LD2410 normal mode data frame - happy path # The buffer is clean at this point, so this frame should parse correctly. @@ -143,3 +144,10 @@ binary_sensor: name: "Has Moving Target" has_still_target: name: "Has Still Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410_engineering.yaml b/tests/integration/fixtures/uart_mock_ld2410_engineering.yaml index 3b730fc1f8b..4625ae85115 100644 --- a/tests/integration/fixtures/uart_mock_ld2410_engineering.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410_engineering.yaml @@ -19,6 +19,7 @@ uart: uart_mock: id: mock_uart baud_rate: 256000 + auto_start: false injections: # Phase 1 (t=100ms): Valid LD2410 engineering mode data frame # Captured from a real Screek Human Presence Sensor 1U with LD2410 firmware 2.4.x @@ -154,3 +155,10 @@ binary_sensor: name: "Has Still Target" out_pin_presence_status: name: "Out Pin Presence" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2412.yaml b/tests/integration/fixtures/uart_mock_ld2412.yaml index a502f36a253..9cf9d6bb873 100644 --- a/tests/integration/fixtures/uart_mock_ld2412.yaml +++ b/tests/integration/fixtures/uart_mock_ld2412.yaml @@ -20,6 +20,7 @@ uart: uart_mock: id: mock_uart baud_rate: 256000 + auto_start: false injections: # Phase 1 (t=100ms): Valid LD2412 normal mode data frame - happy path # The buffer is clean at this point, so this frame should parse correctly. @@ -169,3 +170,10 @@ binary_sensor: name: "Has Still Target" filters: - settle: 50ms + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml b/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml index 3c669fc9a9d..103dbed132f 100644 --- a/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml +++ b/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml @@ -19,6 +19,7 @@ uart: uart_mock: id: mock_uart baud_rate: 256000 + auto_start: false injections: # Phase 1 (t=100ms): Valid LD2412 engineering mode data frame # @@ -211,3 +212,10 @@ binary_sensor: name: "Has Still Target" filters: - settle: 50ms + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index 89b9b91861f..0a3492a0d2f 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -22,6 +22,7 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + auto_start: false debug: responses: - expect_tx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 1 on device 1 @@ -38,3 +39,10 @@ sensor: name: "basic_register" address: 0x03 register_type: holding + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(virtual_uart_dev).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index cd485ca3944..c4e29e5fe8d 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -22,6 +22,7 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + auto_start: false debug: on_tx: - then: @@ -52,3 +53,10 @@ sensor: phase_a: voltage: name: sdm_voltage + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(virtual_uart_dev).start_scenario();' diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index b649056f2ba..e8c2cc5e663 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -3,10 +3,17 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import logging from typing import TypeVar -from aioesphomeapi import ButtonInfo, EntityInfo, EntityState +from aioesphomeapi import ( + BinarySensorState, + ButtonInfo, + EntityInfo, + EntityState, + SensorState, +) _LOGGER = logging.getLogger(__name__) @@ -234,3 +241,90 @@ class InitialStateHelper: asyncio.TimeoutError: If initial states aren't received within timeout """ await asyncio.wait_for(self._initial_states_received, timeout=timeout) + + +class SensorStateCollector: + """Collects sensor and binary sensor state updates and provides wait helpers. + + Usage: + collector = SensorStateCollector( + sensor_names=["moving_distance", "still_distance"], + binary_sensor_names=["has_target"], + ) + # Use collector.on_state as the callback (or wrap it) + client.subscribe_states(helper.on_state_wrapper(collector.on_state)) + + # Wait for all sensors to have at least one value + await collector.wait_for_all(timeout=3.0) + + # Access collected states + assert collector.sensor_states["moving_distance"][0] == approx(100.0) + """ + + def __init__( + self, + sensor_names: list[str], + binary_sensor_names: list[str] | None = None, + entities: list[EntityInfo] | None = None, + ) -> None: + self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names} + self.binary_states: dict[str, list[bool]] = { + name: [] for name in (binary_sensor_names or []) + } + self._key_to_sensor: dict[int, str] = {} + self._waiters: list[tuple[Callable[[], bool], asyncio.Future[bool]]] = [] + + if entities is not None: + self.build_key_mapping(entities) + + def build_key_mapping(self, entities: list[EntityInfo]) -> None: + """Build key-to-name mapping from entities. Sorted by descending length.""" + all_names = list(self.sensor_states.keys()) + list(self.binary_states.keys()) + all_names.sort(key=len, reverse=True) + self._key_to_sensor = build_key_to_entity_mapping(entities, all_names) + + def on_state(self, state: EntityState) -> None: + """Process a state update.""" + if isinstance(state, SensorState) and not state.missing_state: + sensor_name = self._key_to_sensor.get(state.key) + if sensor_name and sensor_name in self.sensor_states: + self.sensor_states[sensor_name].append(state.state) + self._check_waiters() + elif isinstance(state, BinarySensorState): + sensor_name = self._key_to_sensor.get(state.key) + if sensor_name and sensor_name in self.binary_states: + self.binary_states[sensor_name].append(state.state) + self._check_waiters() + + def _check_waiters(self) -> None: + """Check all pending waiters and resolve any whose condition is met.""" + for condition, future in self._waiters: + if not future.done() and condition(): + future.set_result(True) + + def _all_have_values(self) -> bool: + """Check if all sensor and binary sensor lists have at least one value.""" + return all(len(v) >= 1 for v in self.sensor_states.values()) and all( + len(v) >= 1 for v in self.binary_states.values() + ) + + async def wait_for_all(self, timeout: float = 3.0) -> None: + """Wait until all sensors and binary sensors have at least one value.""" + if self._all_have_values(): + return + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self._waiters.append((self._all_have_values, future)) + await asyncio.wait_for(future, timeout=timeout) + + def add_waiter(self, condition: Callable[[], bool]) -> asyncio.Future[bool]: + """Add a custom waiter that resolves when condition returns True. + + Returns: + A future that resolves when the condition is met. + """ + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + if condition(): + future.set_result(True) + else: + self._waiters.append((condition, future)) + return future diff --git a/tests/integration/test_uart_mock_ld2410.py b/tests/integration/test_uart_mock_ld2410.py index e01d6ff8e82..ce0e1bb7ec2 100644 --- a/tests/integration/test_uart_mock_ld2410.py +++ b/tests/integration/test_uart_mock_ld2410.py @@ -21,16 +21,10 @@ from __future__ import annotations import asyncio from pathlib import Path -from aioesphomeapi import ( - BinarySensorInfo, - BinarySensorState, - EntityState, - SensorInfo, - SensorState, -) +from aioesphomeapi import ButtonInfo import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -64,100 +58,65 @@ async def test_uart_mock_ld2410( if "uart_mock" in line and "TX " in line: tx_log_lines.append(line) - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "moving_distance": [], - "still_distance": [], - "moving_energy": [], - "still_energy": [], - "detection_distance": [], - } - binary_states: dict[str, list[bool]] = { - "has_target": [], - "has_moving_target": [], - "has_still_target": [], - } + collector = SensorStateCollector( + sensor_names=[ + "moving_distance", + "still_distance", + "moving_energy", + "still_energy", + "detection_distance", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + ], + ) # Signal when we see recovery frame values - recovery_received = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is the recovery frame (moving_distance = 50) - if ( - sensor_name == "moving_distance" - and state.state == pytest.approx(50.0) - and not recovery_received.done() - ): - recovery_received.set_result(True) - elif isinstance(state, BinarySensorState): - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in binary_states: - binary_states[sensor_name].append(state.state) + recovery_received = collector.add_waiter( + lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"] + ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() - - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) + list(binary_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) + collector.build_key_mapping(entities) # Set up initial state helper initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + 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") - # Phase 1 values are in the initial states (swallowed by InitialStateHelper). - # Verify them via initial_states dict. - moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo) - assert moving_dist_entity is not None - initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key) - assert initial_moving is not None and isinstance(initial_moving, SensorState) - assert initial_moving.state == pytest.approx(100.0), ( - f"Initial moving distance should be 100, got {initial_moving.state}" - ) + # 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) - still_dist_entity = find_entity(entities, "still_distance", SensorInfo) - assert still_dist_entity is not None - initial_still = initial_state_helper.initial_states.get(still_dist_entity.key) - assert initial_still is not None and isinstance(initial_still, SensorState) - assert initial_still.state == pytest.approx(120.0), ( - f"Initial still distance should be 120, got {initial_still.state}" - ) + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) - moving_energy_entity = find_entity(entities, "moving_energy", SensorInfo) - assert moving_energy_entity is not None - initial_me = initial_state_helper.initial_states.get(moving_energy_entity.key) - assert initial_me is not None and isinstance(initial_me, SensorState) - assert initial_me.state == pytest.approx(50.0), ( - f"Initial moving energy should be 50, got {initial_me.state}" - ) - - still_energy_entity = find_entity(entities, "still_energy", SensorInfo) - assert still_energy_entity is not None - initial_se = initial_state_helper.initial_states.get(still_energy_entity.key) - assert initial_se is not None and isinstance(initial_se, SensorState) - assert initial_se.state == pytest.approx(25.0), ( - f"Initial still energy should be 25, got {initial_se.state}" - ) - - detect_dist_entity = find_entity(entities, "detection_distance", SensorInfo) - assert detect_dist_entity is not None - initial_dd = initial_state_helper.initial_states.get(detect_dist_entity.key) - assert initial_dd is not None and isinstance(initial_dd, SensorState) - assert initial_dd.state == pytest.approx(300.0), ( - f"Initial detection distance should be 300, got {initial_dd.state}" - ) + # Phase 1 values: moving=100, still=120, energy=50/25, detect=300 + assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0) + assert collector.sensor_states["still_distance"][0] == pytest.approx(120.0) + assert collector.sensor_states["moving_energy"][0] == pytest.approx(50.0) + assert collector.sensor_states["still_energy"][0] == pytest.approx(25.0) + assert collector.sensor_states["detection_distance"][0] == pytest.approx(300.0) # Wait for the recovery frame (Phase 5) to be parsed # This proves the component survived garbage + truncated + overflow @@ -165,12 +124,8 @@ async def test_uart_mock_ld2410( await asyncio.wait_for(recovery_received, timeout=15.0) except TimeoutError: pytest.fail( - f"Timeout waiting for recovery frame. Received sensor states:\n" - f" moving_distance: {sensor_states['moving_distance']}\n" - f" still_distance: {sensor_states['still_distance']}\n" - f" moving_energy: {sensor_states['moving_energy']}\n" - f" still_energy: {sensor_states['still_energy']}\n" - f" detection_distance: {sensor_states['detection_distance']}" + f"Timeout waiting for recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" ) # Verify overflow warning was logged @@ -183,67 +138,36 @@ async def test_uart_mock_ld2410( # A5 (MAC), AB (distance res), AE (light), 61 (params), FE (config off) assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) - # Verify command frame header appears (FD:FC:FB:FA) assert "FD:FC:FB:FA" in tx_data, ( "Expected LD2410 command frame header FD:FC:FB:FA in TX log" ) - # Verify command frame footer appears (04:03:02:01) assert "04:03:02:01" in tx_data, ( "Expected LD2410 command frame footer 04:03:02:01 in TX log" ) - # Recovery frame values (Phase 5, after overflow) - assert len(sensor_states["moving_distance"]) >= 1, ( - f"Expected recovery moving_distance, got: {sensor_states['moving_distance']}" - ) - # Find the recovery value (moving_distance = 50) - recovery_values = [ - v for v in sensor_states["moving_distance"] if v == pytest.approx(50.0) - ] - assert len(recovery_values) >= 1, ( - f"Expected moving_distance=50 in recovery, got: {sensor_states['moving_distance']}" - ) - # Recovery frame: moving=50, still=75, energy=100/80, detect=127 recovery_idx = next( i - for i, v in enumerate(sensor_states["moving_distance"]) + for i, v in enumerate(collector.sensor_states["moving_distance"]) if v == pytest.approx(50.0) ) - assert sensor_states["still_distance"][recovery_idx] == pytest.approx(75.0), ( - f"Recovery still distance should be 75, got {sensor_states['still_distance'][recovery_idx]}" + assert collector.sensor_states["still_distance"][recovery_idx] == pytest.approx( + 75.0 ) - assert sensor_states["moving_energy"][recovery_idx] == pytest.approx(100.0), ( - f"Recovery moving energy should be 100, got {sensor_states['moving_energy'][recovery_idx]}" + assert collector.sensor_states["moving_energy"][recovery_idx] == pytest.approx( + 100.0 ) - assert sensor_states["still_energy"][recovery_idx] == pytest.approx(80.0), ( - f"Recovery still energy should be 80, got {sensor_states['still_energy'][recovery_idx]}" - ) - assert sensor_states["detection_distance"][recovery_idx] == pytest.approx( - 127.0 - ), ( - f"Recovery detection distance should be 127, got {sensor_states['detection_distance'][recovery_idx]}" + assert collector.sensor_states["still_energy"][recovery_idx] == pytest.approx( + 80.0 ) + assert collector.sensor_states["detection_distance"][ + recovery_idx + ] == pytest.approx(127.0) - # Verify binary sensors detected targets - # Binary sensors could be in initial states or forwarded states - has_target_entity = find_entity(entities, "has_target", BinarySensorInfo) - assert has_target_entity is not None - initial_ht = initial_state_helper.initial_states.get(has_target_entity.key) - assert initial_ht is not None and isinstance(initial_ht, BinarySensorState) - assert initial_ht.state is True, "Has target should be True" - - has_moving_entity = find_entity(entities, "has_moving_target", BinarySensorInfo) - assert has_moving_entity is not None - initial_hm = initial_state_helper.initial_states.get(has_moving_entity.key) - assert initial_hm is not None and isinstance(initial_hm, BinarySensorState) - assert initial_hm.state is True, "Has moving target should be True" - - has_still_entity = find_entity(entities, "has_still_target", BinarySensorInfo) - assert has_still_entity is not None - initial_hs = initial_state_helper.initial_states.get(has_still_entity.key) - assert initial_hs is not None and isinstance(initial_hs, BinarySensorState) - assert initial_hs.state is True, "Has still target should be True" + # Verify binary sensors detected targets (from Phase 1 frame) + assert collector.binary_states["has_target"][0] is True + assert collector.binary_states["has_moving_target"][0] is True + assert collector.binary_states["has_still_target"][0] is True @pytest.mark.asyncio @@ -260,133 +184,82 @@ async def test_uart_mock_ld2410_engineering( "EXTERNAL_COMPONENT_PATH", external_components_path ) - loop = asyncio.get_running_loop() + collector = SensorStateCollector( + sensor_names=[ + "moving_distance", + "still_distance", + "moving_energy", + "still_energy", + "detection_distance", + "light", + "gate_0_move_energy", + "gate_1_move_energy", + "gate_2_move_energy", + "gate_0_still_energy", + "gate_1_still_energy", + "gate_2_still_energy", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + "out_pin_presence", + ], + ) - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "moving_distance": [], - "still_distance": [], - "moving_energy": [], - "still_energy": [], - "detection_distance": [], - "light": [], - "gate_0_move_energy": [], - "gate_1_move_energy": [], - "gate_2_move_energy": [], - "gate_0_still_energy": [], - "gate_1_still_energy": [], - "gate_2_still_energy": [], - } - binary_states: dict[str, list[bool]] = { - "has_target": [], - "has_moving_target": [], - "has_still_target": [], - "out_pin_presence": [], - } - - # Signal when we see Phase 3 frame (still_distance = 291) - phase3_received = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - if ( - sensor_name == "still_distance" - and state.state == pytest.approx(291.0) - and not phase3_received.done() - ): - phase3_received.set_result(True) - elif isinstance(state, BinarySensorState): - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in binary_states: - binary_states[sensor_name].append(state.state) + # Signal when we see Phase 3 frame values + phase3_received = collector.add_waiter( + lambda: pytest.approx(291.0) in collector.sensor_states["still_distance"] + ) async with ( run_compiled(yaml_config), api_client_connected() as client, ): entities, _ = await client.list_entities_services() - - all_names = list(sensor_states.keys()) + list(binary_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) + collector.build_key_mapping(entities) initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + 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") - # Phase 1 initial values (engineering mode frame): + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) + + # Phase 1 values (engineering mode frame): # moving=30, energy=100, still=30, energy=100, detect=0 - moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo) - assert moving_dist_entity is not None - initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key) - assert initial_moving is not None and isinstance(initial_moving, SensorState) - assert initial_moving.state == pytest.approx(30.0), ( - f"Initial moving distance should be 30, got {initial_moving.state}" - ) - - still_dist_entity = find_entity(entities, "still_distance", SensorInfo) - assert still_dist_entity is not None - initial_still = initial_state_helper.initial_states.get(still_dist_entity.key) - assert initial_still is not None and isinstance(initial_still, SensorState) - assert initial_still.state == pytest.approx(30.0), ( - f"Initial still distance should be 30, got {initial_still.state}" - ) - - # Verify engineering mode sensors from initial state - # Gate 0 moving energy = 0x64 = 100 - gate0_move_entity = find_entity(entities, "gate_0_move_energy", SensorInfo) - assert gate0_move_entity is not None - initial_g0m = initial_state_helper.initial_states.get(gate0_move_entity.key) - assert initial_g0m is not None and isinstance(initial_g0m, SensorState) - assert initial_g0m.state == pytest.approx(100.0), ( - f"Gate 0 move energy should be 100, got {initial_g0m.state}" - ) - - # Gate 1 moving energy = 0x41 = 65 - gate1_move_entity = find_entity(entities, "gate_1_move_energy", SensorInfo) - assert gate1_move_entity is not None - initial_g1m = initial_state_helper.initial_states.get(gate1_move_entity.key) - assert initial_g1m is not None and isinstance(initial_g1m, SensorState) - assert initial_g1m.state == pytest.approx(65.0), ( - f"Gate 1 move energy should be 65, got {initial_g1m.state}" - ) - - # Light sensor = 0x57 = 87 - light_entity = find_entity(entities, "light", SensorInfo) - assert light_entity is not None - initial_light = initial_state_helper.initial_states.get(light_entity.key) - assert initial_light is not None and isinstance(initial_light, SensorState) - assert initial_light.state == pytest.approx(87.0), ( - f"Light sensor should be 87, got {initial_light.state}" - ) - - # Out pin presence = 0x01 = True - out_pin_entity = find_entity(entities, "out_pin_presence", BinarySensorInfo) - assert out_pin_entity is not None - initial_out = initial_state_helper.initial_states.get(out_pin_entity.key) - assert initial_out is not None and isinstance(initial_out, BinarySensorState) - assert initial_out.state is True, "Out pin presence should be True" + assert collector.sensor_states["moving_distance"][0] == pytest.approx(30.0) + assert collector.sensor_states["still_distance"][0] == pytest.approx(30.0) + assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0) + assert collector.sensor_states["gate_1_move_energy"][0] == pytest.approx(65.0) + assert collector.sensor_states["light"][0] == pytest.approx(87.0) + assert collector.binary_states["out_pin_presence"][0] is True # Wait for Phase 3 frame (still_distance = 291cm, multi-byte) try: await asyncio.wait_for(phase3_received, timeout=15.0) except TimeoutError: pytest.fail( - f"Timeout waiting for Phase 3 frame. Received sensor states:\n" - f" still_distance: {sensor_states['still_distance']}\n" - f" moving_distance: {sensor_states['moving_distance']}" + f"Timeout waiting for Phase 3 frame. Received:\n" + f" still_distance: {collector.sensor_states['still_distance']}" ) - # Phase 3: still distance = 0x0123 = 291cm (multi-byte distance test) - phase3_still = [ - v for v in sensor_states["still_distance"] if v == pytest.approx(291.0) - ] - assert len(phase3_still) >= 1, ( - f"Expected still_distance=291, got: {sensor_states['still_distance']}" - ) + assert pytest.approx(291.0) in collector.sensor_states["still_distance"] diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index cf7324ceed3..a964ba00738 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -21,16 +21,10 @@ from __future__ import annotations import asyncio from pathlib import Path -from aioesphomeapi import ( - BinarySensorInfo, - BinarySensorState, - EntityState, - SensorInfo, - SensorState, -) +from aioesphomeapi import ButtonInfo import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -64,104 +58,65 @@ async def test_uart_mock_ld2412( if "uart_mock" in line and "TX " in line: tx_log_lines.append(line) - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "moving_distance": [], - "still_distance": [], - "moving_energy": [], - "still_energy": [], - "detection_distance": [], - } - binary_states: dict[str, list[bool]] = { - "has_target": [], - "has_moving_target": [], - "has_still_target": [], - } + collector = SensorStateCollector( + sensor_names=[ + "moving_distance", + "still_distance", + "moving_energy", + "still_energy", + "detection_distance", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + ], + ) # Signal when we see recovery frame values - recovery_received = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is the recovery frame (moving_distance = 50) - if ( - sensor_name == "moving_distance" - and state.state == pytest.approx(50.0) - and not recovery_received.done() - ): - recovery_received.set_result(True) - elif isinstance(state, BinarySensorState): - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in binary_states: - binary_states[sensor_name].append(state.state) + recovery_received = collector.add_waiter( + lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"] + ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() - - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) + list(binary_states.keys()) - # Sort by descending length to avoid substring collisions - # (e.g., "still_energy" matching "gate_0_still_energy") - all_names.sort(key=len, reverse=True) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) + collector.build_key_mapping(entities) # Set up initial state helper initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + 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") - # Phase 1 values are in the initial states (swallowed by InitialStateHelper). - # Verify them via initial_states dict. - moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo) - assert moving_dist_entity is not None - initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key) - assert initial_moving is not None and isinstance(initial_moving, SensorState) - assert initial_moving.state == pytest.approx(100.0), ( - f"Initial moving distance should be 100, got {initial_moving.state}" - ) + # 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) - still_dist_entity = find_entity(entities, "still_distance", SensorInfo) - assert still_dist_entity is not None - initial_still = initial_state_helper.initial_states.get(still_dist_entity.key) - assert initial_still is not None and isinstance(initial_still, SensorState) - assert initial_still.state == pytest.approx(120.0), ( - f"Initial still distance should be 120, got {initial_still.state}" - ) + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) - moving_energy_entity = find_entity(entities, "moving_energy", SensorInfo) - assert moving_energy_entity is not None - initial_me = initial_state_helper.initial_states.get(moving_energy_entity.key) - assert initial_me is not None and isinstance(initial_me, SensorState) - assert initial_me.state == pytest.approx(50.0), ( - f"Initial moving energy should be 50, got {initial_me.state}" - ) - - still_energy_entity = find_entity(entities, "still_energy", SensorInfo) - assert still_energy_entity is not None - initial_se = initial_state_helper.initial_states.get(still_energy_entity.key) - assert initial_se is not None and isinstance(initial_se, SensorState) - assert initial_se.state == pytest.approx(25.0), ( - f"Initial still energy should be 25, got {initial_se.state}" - ) - - # LD2412 detection_distance = moving_distance when MOVE_BITMASK is set - detect_dist_entity = find_entity(entities, "detection_distance", SensorInfo) - assert detect_dist_entity is not None - initial_dd = initial_state_helper.initial_states.get(detect_dist_entity.key) - assert initial_dd is not None and isinstance(initial_dd, SensorState) - assert initial_dd.state == pytest.approx(100.0), ( - f"Initial detection distance should be 100, got {initial_dd.state}" - ) + # Phase 1 values: moving=100, still=120, energy=50/25, detect=100 + assert collector.sensor_states["moving_distance"][0] == pytest.approx(100.0) + assert collector.sensor_states["still_distance"][0] == pytest.approx(120.0) + assert collector.sensor_states["moving_energy"][0] == pytest.approx(50.0) + assert collector.sensor_states["still_energy"][0] == pytest.approx(25.0) + assert collector.sensor_states["detection_distance"][0] == pytest.approx(100.0) # Wait for the recovery frame (Phase 5) to be parsed # This proves the component survived garbage + truncated + overflow @@ -169,12 +124,8 @@ async def test_uart_mock_ld2412( await asyncio.wait_for(recovery_received, timeout=3.0) except TimeoutError: pytest.fail( - f"Timeout waiting for recovery frame. Received sensor states:\n" - f" moving_distance: {sensor_states['moving_distance']}\n" - f" still_distance: {sensor_states['still_distance']}\n" - f" moving_energy: {sensor_states['moving_energy']}\n" - f" still_energy: {sensor_states['still_energy']}\n" - f" detection_distance: {sensor_states['detection_distance']}" + f"Timeout waiting for recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" ) # Verify overflow warning was logged @@ -185,67 +136,36 @@ async def test_uart_mock_ld2412( # Verify LD2412 sent setup commands (TX logging) assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) - # Verify command frame header appears (FD:FC:FB:FA) assert "FD:FC:FB:FA" in tx_data, ( "Expected LD2412 command frame header FD:FC:FB:FA in TX log" ) - # Verify command frame footer appears (04:03:02:01) assert "04:03:02:01" in tx_data, ( "Expected LD2412 command frame footer 04:03:02:01 in TX log" ) - # Recovery frame values (Phase 5, after overflow) - assert len(sensor_states["moving_distance"]) >= 1, ( - f"Expected recovery moving_distance, got: {sensor_states['moving_distance']}" - ) - # Find the recovery value (moving_distance = 50) - recovery_values = [ - v for v in sensor_states["moving_distance"] if v == pytest.approx(50.0) - ] - assert len(recovery_values) >= 1, ( - f"Expected moving_distance=50 in recovery, got: {sensor_states['moving_distance']}" - ) - # Recovery frame: moving=50, still=75, energy=100/80, detect=50 recovery_idx = next( i - for i, v in enumerate(sensor_states["moving_distance"]) + for i, v in enumerate(collector.sensor_states["moving_distance"]) if v == pytest.approx(50.0) ) - assert sensor_states["still_distance"][recovery_idx] == pytest.approx(75.0), ( - f"Recovery still distance should be 75, got {sensor_states['still_distance'][recovery_idx]}" + assert collector.sensor_states["still_distance"][recovery_idx] == pytest.approx( + 75.0 ) - assert sensor_states["moving_energy"][recovery_idx] == pytest.approx(100.0), ( - f"Recovery moving energy should be 100, got {sensor_states['moving_energy'][recovery_idx]}" + assert collector.sensor_states["moving_energy"][recovery_idx] == pytest.approx( + 100.0 ) - assert sensor_states["still_energy"][recovery_idx] == pytest.approx(80.0), ( - f"Recovery still energy should be 80, got {sensor_states['still_energy'][recovery_idx]}" - ) - # LD2412 detection_distance = moving_distance when MOVE_BITMASK set - assert sensor_states["detection_distance"][recovery_idx] == pytest.approx( - 50.0 - ), ( - f"Recovery detection distance should be 50, got {sensor_states['detection_distance'][recovery_idx]}" + assert collector.sensor_states["still_energy"][recovery_idx] == pytest.approx( + 80.0 ) + assert collector.sensor_states["detection_distance"][ + recovery_idx + ] == pytest.approx(50.0) - # Verify binary sensors detected targets - has_target_entity = find_entity(entities, "has_target", BinarySensorInfo) - assert has_target_entity is not None - initial_ht = initial_state_helper.initial_states.get(has_target_entity.key) - assert initial_ht is not None and isinstance(initial_ht, BinarySensorState) - assert initial_ht.state is True, "Has target should be True" - - has_moving_entity = find_entity(entities, "has_moving_target", BinarySensorInfo) - assert has_moving_entity is not None - initial_hm = initial_state_helper.initial_states.get(has_moving_entity.key) - assert initial_hm is not None and isinstance(initial_hm, BinarySensorState) - assert initial_hm.state is True, "Has moving target should be True" - - has_still_entity = find_entity(entities, "has_still_target", BinarySensorInfo) - assert has_still_entity is not None - initial_hs = initial_state_helper.initial_states.get(has_still_entity.key) - assert initial_hs is not None and isinstance(initial_hs, BinarySensorState) - assert initial_hs.state is True, "Has still target should be True" + # Verify binary sensors detected targets (from Phase 1 frame) + assert collector.binary_states["has_target"][0] is True + assert collector.binary_states["has_moving_target"][0] is True + assert collector.binary_states["has_still_target"][0] is True @pytest.mark.asyncio @@ -262,120 +182,75 @@ async def test_uart_mock_ld2412_engineering( "EXTERNAL_COMPONENT_PATH", external_components_path ) - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "moving_distance": [], - "still_distance": [], - "moving_energy": [], - "still_energy": [], - "detection_distance": [], - "light": [], - "gate_0_move_energy": [], - "gate_1_move_energy": [], - "gate_2_move_energy": [], - "gate_0_still_energy": [], - "gate_1_still_energy": [], - "gate_2_still_energy": [], - } - binary_states: dict[str, list[bool]] = { - "has_target": [], - "has_moving_target": [], - "has_still_target": [], - } + collector = SensorStateCollector( + sensor_names=[ + "moving_distance", + "still_distance", + "moving_energy", + "still_energy", + "detection_distance", + "light", + "gate_0_move_energy", + "gate_1_move_energy", + "gate_2_move_energy", + "gate_0_still_energy", + "gate_1_still_energy", + "gate_2_still_energy", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + ], + ) # Signal when we see Phase 3 frame values - phase3_still_received = loop.create_future() - phase3_detect_received = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - if ( - sensor_name == "still_distance" - and state.state == pytest.approx(291.0) - and not phase3_still_received.done() - ): - phase3_still_received.set_result(True) - if ( - sensor_name == "detection_distance" - and state.state == pytest.approx(291.0) - and not phase3_detect_received.done() - ): - phase3_detect_received.set_result(True) - elif isinstance(state, BinarySensorState): - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in binary_states: - binary_states[sensor_name].append(state.state) + phase3_still_received = collector.add_waiter( + lambda: pytest.approx(291.0) in collector.sensor_states["still_distance"] + ) + phase3_detect_received = collector.add_waiter( + lambda: pytest.approx(291.0) in collector.sensor_states["detection_distance"] + ) async with ( run_compiled(yaml_config), api_client_connected() as client, ): entities, _ = await client.list_entities_services() - - all_names = list(sensor_states.keys()) + list(binary_states.keys()) - # Sort by descending length to avoid substring collisions - # (e.g., "still_energy" matching "gate_0_still_energy") - all_names.sort(key=len, reverse=True) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) + collector.build_key_mapping(entities) initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + 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") - # Phase 1 initial values (engineering mode frame): + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) + + # Phase 1 values (engineering mode frame): # moving=30, energy=100, still=30, energy=100, detect=30 - moving_dist_entity = find_entity(entities, "moving_distance", SensorInfo) - assert moving_dist_entity is not None - initial_moving = initial_state_helper.initial_states.get(moving_dist_entity.key) - assert initial_moving is not None and isinstance(initial_moving, SensorState) - assert initial_moving.state == pytest.approx(30.0), ( - f"Initial moving distance should be 30, got {initial_moving.state}" - ) - - still_dist_entity = find_entity(entities, "still_distance", SensorInfo) - assert still_dist_entity is not None - initial_still = initial_state_helper.initial_states.get(still_dist_entity.key) - assert initial_still is not None and isinstance(initial_still, SensorState) - assert initial_still.state == pytest.approx(30.0), ( - f"Initial still distance should be 30, got {initial_still.state}" - ) - - # Verify engineering mode sensors from initial state - # Gate 0 moving energy = 0x64 = 100 - gate0_move_entity = find_entity(entities, "gate_0_move_energy", SensorInfo) - assert gate0_move_entity is not None - initial_g0m = initial_state_helper.initial_states.get(gate0_move_entity.key) - assert initial_g0m is not None and isinstance(initial_g0m, SensorState) - assert initial_g0m.state == pytest.approx(100.0), ( - f"Gate 0 move energy should be 100, got {initial_g0m.state}" - ) - - # Gate 1 moving energy = 0x41 = 65 - gate1_move_entity = find_entity(entities, "gate_1_move_energy", SensorInfo) - assert gate1_move_entity is not None - initial_g1m = initial_state_helper.initial_states.get(gate1_move_entity.key) - assert initial_g1m is not None and isinstance(initial_g1m, SensorState) - assert initial_g1m.state == pytest.approx(65.0), ( - f"Gate 1 move energy should be 65, got {initial_g1m.state}" - ) - - # Light sensor = 0x57 = 87 - light_entity = find_entity(entities, "light", SensorInfo) - assert light_entity is not None - initial_light = initial_state_helper.initial_states.get(light_entity.key) - assert initial_light is not None and isinstance(initial_light, SensorState) - assert initial_light.state == pytest.approx(87.0), ( - f"Light sensor should be 87, got {initial_light.state}" - ) + assert collector.sensor_states["moving_distance"][0] == pytest.approx(30.0) + assert collector.sensor_states["still_distance"][0] == pytest.approx(30.0) + assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0) + assert collector.sensor_states["gate_1_move_energy"][0] == pytest.approx(65.0) + assert collector.sensor_states["light"][0] == pytest.approx(87.0) # Wait for Phase 3 frame: still_distance = 291cm (multi-byte) try: @@ -383,25 +258,18 @@ async def test_uart_mock_ld2412_engineering( except TimeoutError: pytest.fail( f"Timeout waiting for Phase 3 still_distance. Received:\n" - f" still_distance: {sensor_states['still_distance']}\n" - f" moving_distance: {sensor_states['moving_distance']}" + f" still_distance: {collector.sensor_states['still_distance']}" ) - assert pytest.approx(291.0) in sensor_states["still_distance"], ( - f"Expected still_distance=291, got: {sensor_states['still_distance']}" - ) + assert pytest.approx(291.0) in collector.sensor_states["still_distance"] # Wait for Phase 3: detection_distance = 291 (still-only target) - # target_state=0x02 so LD2412 uses still_distance for detection_distance. - # The throttle_with_priority filter may delay this value. try: await asyncio.wait_for(phase3_detect_received, timeout=3.0) except TimeoutError: pytest.fail( - f"Timeout waiting for detection_distance=291 (still-only target). " - f"Received: {sensor_states['detection_distance']}" + f"Timeout waiting for detection_distance=291. " + f"Received: {collector.sensor_states['detection_distance']}" ) - assert pytest.approx(291.0) in sensor_states["detection_distance"], ( - f"Expected detection_distance=291, got: {sensor_states['detection_distance']}" - ) + assert pytest.approx(291.0) in collector.sensor_states["detection_distance"] diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 309cb56dc99..bf3c0697502 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -12,10 +12,10 @@ from __future__ import annotations import asyncio from pathlib import Path -from aioesphomeapi import EntityState, SensorState +from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -74,6 +74,11 @@ async def test_uart_mock_modbus( except TimeoutError: pytest.fail("Timeout waiting for initial states") + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + # Wait for basic register to be updated with successful parse try: await asyncio.wait_for(basic_register_changed, timeout=15.0) @@ -143,6 +148,11 @@ async def test_uart_mock_modbus_timing( except TimeoutError: pytest.fail("Timeout waiting for initial states") + # Start the UART mock scenario now that we're subscribed + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + # Wait for voltage to be updated with successful parse try: await asyncio.wait_for(voltage_changed, timeout=15.0) From 0ff5270632f59a8fd4d7ed25cd71c2ce92ae5b07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Mar 2026 16:57:19 -1000 Subject: [PATCH 06/24] [ci] Fix codeowner approval label workflow for fork PRs (#14490) Co-authored-by: Claude Opus 4.6 --- .github/scripts/codeowners.js | 88 +++++++++- .../codeowner-approved-label-update.yml | 95 +++++++++++ .../workflows/codeowner-approved-label.yml | 153 +++++------------- 3 files changed, 217 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/codeowner-approved-label-update.yml diff --git a/.github/scripts/codeowners.js b/.github/scripts/codeowners.js index 9a10391699e..5d69c11b1a2 100644 --- a/.github/scripts/codeowners.js +++ b/.github/scripts/codeowners.js @@ -2,7 +2,7 @@ // // Used by: // - codeowner-review-request.yml -// - codeowner-approved-label.yml +// - codeowner-approved-label.yml + codeowner-approved-label-update.yml // - auto-label-pr/detectors.js (detectCodeOwner) /** @@ -133,11 +133,95 @@ function loadCodeowners(repoRoot = '.') { return parseCodeowners(content); } +/** Possible label actions returned by determineLabelAction. */ +const LabelAction = Object.freeze({ + ADD: 'add', + REMOVE: 'remove', + NONE: 'none', +}); + +/** + * Determine what label action is needed for a PR based on codeowner approvals. + * + * Checks changed files against CODEOWNERS patterns, reviews, and current labels + * to decide if the label should be added, removed, or left unchanged. + * + * @param {object} github - octokit instance from actions/github-script + * @param {string} owner - repo owner + * @param {string} repo - repo name + * @param {number} pr_number - pull request number + * @param {Array} codeownersPatterns - from loadCodeowners / fetchCodeowners + * @param {string} labelName - label to manage + * @returns {Promise} + */ +async function determineLabelAction(github, owner, repo, pr_number, codeownersPatterns, labelName) { + // Get the list of changed files in this PR + const prFiles = await github.paginate( + github.rest.pulls.listFiles, + { owner, repo, pull_number: pr_number } + ); + + const changedFiles = prFiles.map(file => file.filename); + console.log(`Found ${changedFiles.length} changed files`); + + if (changedFiles.length === 0) { + console.log('No changed files found'); + return LabelAction.NONE; + } + + // Get effective owners using last-match-wins semantics + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + const componentCodeowners = effective.users; + + console.log(`Component-specific codeowners: ${Array.from(componentCodeowners).join(', ') || '(none)'}`); + + // Get current labels + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: pr_number + }); + const hasLabel = currentLabels.some(label => label.name === labelName); + + if (componentCodeowners.size === 0) { + console.log('No component-specific codeowners found'); + return hasLabel ? LabelAction.REMOVE : LabelAction.NONE; + } + + // Get all reviews and find latest per user + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { owner, repo, pull_number: pr_number } + ); + + const latestReviewByUser = new Map(); + for (const review of reviews) { + if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue; + latestReviewByUser.set(review.user.login, review); + } + + // Check if any component-specific codeowner has an active approval + let hasCodeownerApproval = false; + for (const [login, review] of latestReviewByUser) { + if (review.state === 'APPROVED' && componentCodeowners.has(login)) { + console.log(`Codeowner '${login}' has approved`); + hasCodeownerApproval = true; + break; + } + } + + if (hasCodeownerApproval && !hasLabel) return LabelAction.ADD; + if (!hasCodeownerApproval && hasLabel) return LabelAction.REMOVE; + + console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`); + return LabelAction.NONE; +} + module.exports = { globToRegex, parseCodeowners, fetchCodeowners, loadCodeowners, classifyOwners, - getEffectiveOwners + getEffectiveOwners, + LabelAction, + determineLabelAction }; diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml new file mode 100644 index 00000000000..9168cce1d6b --- /dev/null +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -0,0 +1,95 @@ +# Fallback for fork PRs: phase 1 (codeowner-approved-label.yml) handles +# non-fork PRs directly but can't write labels on fork PRs (read-only token). +# This workflow re-determines the action and applies it if needed. + +name: Codeowner Approved Label Update + +on: + workflow_run: + workflows: ["Codeowner Approved Label"] + types: [completed] + +permissions: + issues: write + pull-requests: read + contents: read + +jobs: + update-label: + name: Run + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request_review' + runs-on: ubuntu-latest + steps: + - name: Get PR details + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: | + pr_data=$(gh pr list --repo "$REPO" --state open --search "$HEAD_SHA" \ + --json number,baseRefName --jq '.[0] // empty') + + if [ -z "$pr_data" ]; then + echo "No open PR found for SHA $HEAD_SHA, skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pr_number=$(echo "$pr_data" | jq -r '.number') + base_ref=$(echo "$pr_data" | jq -r '.baseRefName') + + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT" + echo "Found PR #$pr_number targeting $base_ref" + + - name: Checkout base repository + if: steps.pr.outputs.skip != 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ github.repository }} + ref: ${{ steps.pr.outputs.base_ref }} + sparse-checkout: | + .github/scripts/codeowners.js + CODEOWNERS + + - name: Update label + if: steps.pr.outputs.skip != 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + script: | + const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr_number = parseInt(process.env.PR_NUMBER, 10); + const LABEL_NAME = 'code-owner-approved'; + + console.log(`Processing PR #${pr_number} for codeowner approval label`); + + const codeownersPatterns = loadCodeowners(); + const action = await determineLabelAction( + github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME + ); + + if (action === LabelAction.ADD) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr_number, labels: [LABEL_NAME] + }); + console.log(`Added '${LABEL_NAME}' label`); + } else if (action === LabelAction.REMOVE) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr_number, name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label`); + } catch (error) { + if (error.status !== 404) throw error; + } + } else { + console.log('No label change needed'); + } diff --git a/.github/workflows/codeowner-approved-label.yml b/.github/workflows/codeowner-approved-label.yml index 200f18f5448..12199bd0b04 100644 --- a/.github/workflows/codeowner-approved-label.yml +++ b/.github/workflows/codeowner-approved-label.yml @@ -1,9 +1,9 @@ -# This workflow adds/removes a 'code-owner-approved' label when a -# component-specific codeowner approves (or dismisses) a PR. -# This helps maintainers prioritize PRs that have codeowner sign-off. +# Adds/removes a 'code-owner-approved' label when a component-specific +# codeowner approves (or dismisses) a PR. # -# Only component-specific codeowners count — the catch-all @esphome/core -# team is excluded so the label reflects domain-expert approval. +# Handles non-fork PRs directly. For fork PRs the GITHUB_TOKEN is read-only, +# so label writes are deferred to codeowner-approved-label-update.yml which +# triggers via workflow_run with write permissions. name: Codeowner Approved Label @@ -26,134 +26,53 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: | + .github/scripts/codeowners.js + CODEOWNERS - name: Check codeowner approval and update label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} with: script: | - const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); + const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js'); const owner = context.repo.owner; const repo = context.repo.repo; - const pr_number = context.payload.pull_request.number; + const pr_number = parseInt(process.env.PR_NUMBER, 10); const LABEL_NAME = 'code-owner-approved'; console.log(`Processing PR #${pr_number} for codeowner approval label`); + const codeownersPatterns = loadCodeowners(); + const action = await determineLabelAction( + github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME + ); + + if (action === LabelAction.NONE) { + console.log('No label change needed'); + return; + } + try { - // Get the list of changed files in this PR (with pagination) - const prFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner, - repo, - pull_number: pr_number - } - ); - - const changedFiles = prFiles.map(file => file.filename); - console.log(`Found ${changedFiles.length} changed files`); - - if (changedFiles.length === 0) { - console.log('No changed files found, skipping'); - return; - } - - // Parse CODEOWNERS from the checked-out base branch - const codeownersPatterns = loadCodeowners(); - - // Get effective owners using last-match-wins semantics - const effective = getEffectiveOwners(changedFiles, codeownersPatterns); - - // Only keep individual component-specific codeowners (exclude teams) - const componentCodeowners = effective.users; - - console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`); - - if (componentCodeowners.size === 0) { - console.log('No component-specific codeowners found for changed files'); - // Remove label if present since there are no component codeowners - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr_number, - name: LABEL_NAME - }); - console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`); - } catch (error) { - if (error.status !== 404) { - console.log(`Failed to remove label: ${error.message}`); - } - } - return; - } - - // Get all reviews on the PR - const reviews = await github.paginate( - github.rest.pulls.listReviews, - { - owner, - repo, - pull_number: pr_number - } - ); - - // Get the latest review per user (reviews are returned chronologically) - const latestReviewByUser = new Map(); - for (const review of reviews) { - // Skip bot reviews and comment-only reviews - if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue; - latestReviewByUser.set(review.user.login, review); - } - - // Check if any component-specific codeowner has an active approval - let hasCodeownerApproval = false; - for (const [login, review] of latestReviewByUser) { - if (review.state === 'APPROVED' && componentCodeowners.has(login)) { - console.log(`Codeowner '${login}' has approved`); - hasCodeownerApproval = true; - break; - } - } - - // Get current labels to check if label is already present - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: pr_number - }); - const hasLabel = currentLabels.some(label => label.name === LABEL_NAME); - - if (hasCodeownerApproval && !hasLabel) { - // Add the label + if (action === LabelAction.ADD) { await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr_number, - labels: [LABEL_NAME] + owner, repo, issue_number: pr_number, labels: [LABEL_NAME] }); console.log(`Added '${LABEL_NAME}' label`); - } else if (!hasCodeownerApproval && hasLabel) { - // Remove the label - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr_number, - name: LABEL_NAME - }); - console.log(`Removed '${LABEL_NAME}' label`); - } catch (error) { - if (error.status !== 404) { - console.log(`Failed to remove label: ${error.message}`); - } - } - } else { - console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`); + } else if (action === LabelAction.REMOVE) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr_number, name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label`); } - } catch (error) { - console.error(error); - core.setFailed(`Failed to process codeowner approval label: ${error.message}`); + if (error.status === 403) { + console.log('Fork PR: deferring label write to phase 2 workflow'); + } else if (error.status === 404) { + console.log('Label already removed'); + } else { + throw error; + } } From f5c37bf486d1a8905b7518478fec23a2e6e3b9ae Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:24:01 +1100 Subject: [PATCH 07/24] [packet_transport] Minimise heap allocations (#14482) --- .../packet_transport/packet_transport.cpp | 79 ++-- .../packet_transport/packet_transport.h | 19 +- script/cpp_unit_test.py | 44 +- tests/components/packet_transport/common.h | 98 ++++ .../components/packet_transport/cpp_test.yaml | 11 + .../packet_transport_test.cpp | 445 ++++++++++++++++++ 6 files changed, 659 insertions(+), 37 deletions(-) create mode 100644 tests/components/packet_transport/common.h create mode 100644 tests/components/packet_transport/cpp_test.yaml create mode 100644 tests/components/packet_transport/packet_transport_test.cpp diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index d2c59200017..f241cc61142 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "packet_transport.h" +#include + #include "esphome/components/xxtea/xxtea.h" namespace esphome { @@ -77,7 +79,7 @@ enum DecodeResult { DECODE_EMPTY, }; -static const size_t MAX_PING_KEYS = 4; +static constexpr size_t MAX_PING_KEYS = 4; static inline void add(std::vector &vec, uint32_t data) { vec.push_back(data & 0xFF); @@ -168,7 +170,7 @@ class PacketDecoder { return true; } - bool decrypt(const uint32_t *key) { + bool decrypt(const uint32_t *key) const { if (this->get_remaining_size() % 4 != 0) { return false; } @@ -249,9 +251,9 @@ void PacketTransport::init_data_() { } else { add(this->data_, DATA_KEY); } - for (const auto &pkey : this->ping_keys_) { + for (auto &value : this->ping_keys_ | std::views::values) { add(this->data_, PING_KEY); - add(this->data_, pkey.second); + add(this->data_, value); } } @@ -331,7 +333,7 @@ void PacketTransport::update() { auto now = millis() / 1000; if (this->last_key_time_ + this->ping_pong_recyle_time_ < now) { this->resend_ping_key_ = this->ping_pong_enable_; - ESP_LOGV(TAG, "Ping request, age %u", now - this->last_key_time_); + ESP_LOGV(TAG, "Ping request, age %" PRIu32, now - this->last_key_time_); this->last_key_time_ = now; } for (const auto &provider : this->providers_) { @@ -339,24 +341,32 @@ void PacketTransport::update() { if (key_response_age > (this->ping_pong_recyle_time_ * 2u)) { #ifdef USE_STATUS_SENSOR if (provider.second.status_sensor != nullptr && provider.second.status_sensor->state) { - ESP_LOGI(TAG, "Ping status for %s timeout at %u with age %u", provider.first.c_str(), now, key_response_age); + ESP_LOGI(TAG, "Ping status for %s timeout at %" PRIu32 " with age %" PRIu32, provider.first.c_str(), now, + key_response_age); provider.second.status_sensor->publish_state(false); } #endif #ifdef USE_SENSOR - for (auto &sensor : this->remote_sensors_[provider.first]) { - sensor.second->publish_state(NAN); + auto it = this->remote_sensors_.find(provider.first); + if (it != this->remote_sensors_.end()) { + for (auto &val : it->second | std::views::values) { + val->publish_state(NAN); + } } #endif #ifdef USE_BINARY_SENSOR - for (auto &sensor : this->remote_binary_sensors_[provider.first]) { - sensor.second->invalidate_state(); + auto bs_it = this->remote_binary_sensors_.find(provider.first); + if (bs_it != this->remote_binary_sensors_.end()) { + for (auto &val : bs_it->second | std::views::values) { + val->invalidate_state(); + } } #endif } else { #ifdef USE_STATUS_SENSOR if (provider.second.status_sensor != nullptr && !provider.second.status_sensor->state) { - ESP_LOGI(TAG, "Ping status for %s restored at %u with age %u", provider.first.c_str(), now, key_response_age); + ESP_LOGI(TAG, "Ping status for %s restored at %" PRIu32 " with age %" PRIu32, provider.first.c_str(), now, + key_response_age); provider.second.status_sensor->publish_state(true); } #endif @@ -367,11 +377,16 @@ void PacketTransport::update() { void PacketTransport::add_key_(const char *name, uint32_t key) { if (!this->is_encrypted_()) return; - if (this->ping_keys_.count(name) == 0 && this->ping_keys_.size() == MAX_PING_KEYS) { - ESP_LOGW(TAG, "Ping key from %s discarded", name); - return; + auto it = this->ping_keys_.find(name); + if (it == this->ping_keys_.end()) { + if (this->ping_keys_.size() == MAX_PING_KEYS) { + ESP_LOGW(TAG, "Ping key from %s discarded", name); + return; + } + this->ping_keys_.emplace(name, key); // allocates string key once only + } else { + it->second = key; // key string already exists in map, no allocation } - this->ping_keys_[name] = key; this->updated_ = true; ESP_LOGV(TAG, "Ping key from %s now %X", name, (unsigned) key); } @@ -431,17 +446,19 @@ void PacketTransport::process_(std::span data) { return; } - if (this->providers_.count(namebuf) == 0) { + auto it = this->providers_.find(namebuf); + if (it == this->providers_.end()) { ESP_LOGVV(TAG, "Unknown hostname %s", namebuf); return; } + auto &provider = it->second; ESP_LOGV(TAG, "Found hostname %s", namebuf); #ifdef USE_SENSOR - auto &sensors = this->remote_sensors_[namebuf]; + auto &sensors = this->remote_sensors_.try_emplace(namebuf).first->second; #endif #ifdef USE_BINARY_SENSOR - auto &binary_sensors = this->remote_binary_sensors_[namebuf]; + auto &binary_sensors = this->remote_binary_sensors_.try_emplace(namebuf).first->second; #endif if (!decoder.bump_to(4)) { @@ -453,7 +470,6 @@ void PacketTransport::process_(std::span data) { return; } - auto &provider = this->providers_[namebuf]; // if encryption not used with this host, ping check is pointless since it would be easily spoofed. if (provider.encryption_key.empty()) ping_key_seen = true; @@ -495,16 +511,19 @@ void PacketTransport::process_(std::span data) { if (decoder.decode(BINARY_SENSOR_KEY, namebuf, sizeof(namebuf), byte) == DECODE_OK) { ESP_LOGV(TAG, "Got binary sensor %s %d", namebuf, byte); #ifdef USE_BINARY_SENSOR - if (binary_sensors.count(namebuf) != 0) - binary_sensors[namebuf]->publish_state(byte != 0); + auto bs = binary_sensors.find(namebuf); + if (bs != binary_sensors.end()) { + bs->second->publish_state(byte != 0); + } #endif continue; } if (decoder.decode(SENSOR_KEY, namebuf, sizeof(namebuf), rdata.u32) == DECODE_OK) { ESP_LOGV(TAG, "Got sensor %s %f", namebuf, rdata.f32); #ifdef USE_SENSOR - if (sensors.count(namebuf) != 0) - sensors[namebuf]->publish_state(rdata.f32); + auto sensor_it = sensors.find(namebuf); + if (sensor_it != sensors.end()) + sensor_it->second->publish_state(rdata.f32); #endif continue; } @@ -537,12 +556,18 @@ void PacketTransport::dump_config() { ESP_LOGCONFIG(TAG, " Remote host: %s", host.first.c_str()); ESP_LOGCONFIG(TAG, " Encrypted: %s", YESNO(!host.second.encryption_key.empty())); #ifdef USE_SENSOR - for (const auto &sensor : this->remote_sensors_[host.first.c_str()]) - ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.first.c_str()); + auto rs = this->remote_sensors_.find(host.first.c_str()); + if (rs != this->remote_sensors_.end()) { + for (const auto &key : rs->second | std::views::keys) + ESP_LOGCONFIG(TAG, " Sensor: %s", key.c_str()); + } #endif #ifdef USE_BINARY_SENSOR - for (const auto &sensor : this->remote_binary_sensors_[host.first.c_str()]) - ESP_LOGCONFIG(TAG, " Binary Sensor: %s", sensor.first.c_str()); + auto rbs = this->remote_binary_sensors_.find(host.first.c_str()); + if (rbs != this->remote_binary_sensors_.end()) { + for (const auto &key : rbs->second | std::views::keys) + ESP_LOGCONFIG(TAG, " Binary Sensor: %s", key.c_str()); + } #endif } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2367442317..b3798738e2c 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -24,6 +24,9 @@ namespace esphome { namespace packet_transport { +// std::less provides allocation-free comparison with const char * +template using string_map_t = std::map>; + struct Provider { std::vector encryption_key; const char *name; @@ -79,15 +82,15 @@ class PacketTransport : public PollingComponent { #endif void add_provider(const char *hostname) { - if (this->providers_.count(hostname) == 0) { + if (!this->providers_.contains(hostname)) { Provider provider{}; provider.name = hostname; this->providers_[hostname] = provider; #ifdef USE_SENSOR - this->remote_sensors_[hostname] = std::map(); + this->remote_sensors_[hostname] = string_map_t(); #endif #ifdef USE_BINARY_SENSOR - this->remote_binary_sensors_[hostname] = std::map(); + this->remote_binary_sensors_[hostname] = string_map_t(); #endif } } @@ -139,23 +142,23 @@ class PacketTransport : public PollingComponent { #ifdef USE_SENSOR std::vector sensors_{}; - std::map> remote_sensors_{}; + string_map_t> remote_sensors_{}; #endif #ifdef USE_BINARY_SENSOR std::vector binary_sensors_{}; - std::map> remote_binary_sensors_{}; + string_map_t> remote_binary_sensors_{}; #endif - std::map providers_{}; + string_map_t providers_{}; std::vector ping_header_{}; std::vector header_{}; std::vector data_{}; - std::map ping_keys_{}; + string_map_t ping_keys_{}; const char *platform_name_{""}; void add_key_(const char *name, uint32_t key); void send_ping_pong_request_(); - inline bool is_encrypted_() { return !this->encryption_key_.empty(); } + bool is_encrypted_() const { return !this->encryption_key_.empty(); } }; } // namespace packet_transport diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 02b133060aa..b87261ab332 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -12,6 +12,7 @@ from esphome.__main__ import command_compile, parse_args from esphome.config import validate_config from esphome.core import CORE from esphome.platformio_api import get_idedata +from esphome.yaml_util import load_yaml # This must coincide with the version in /platformio.ini PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" @@ -44,6 +45,38 @@ def filter_components_without_tests(components: list[str]) -> list[str]: return filtered_components +# Name of optional per-component YAML config merged into the test build +# before validation so that platform defines (USE_SENSOR, etc.) are generated. +CPP_TEST_CONFIG_FILE = "cpp_test.yaml" + + +def load_component_test_configs(components: list[str]) -> dict: + """Load cpp_test.yaml files from test component directories. + + These configs are merged into the base test config *before* validation + so that entity registration runs during code generation, which causes + the corresponding USE_* defines to be emitted. + """ + merged: dict = {} + for component in components: + config_file = COMPONENTS_TESTS_DIR / component / CPP_TEST_CONFIG_FILE + if not config_file.exists(): + continue + component_config = load_yaml(config_file) + if not component_config: + continue + for key, value in component_config.items(): + if ( + key in merged + and isinstance(merged[key], list) + and isinstance(value, list) + ): + merged[key].extend(value) + else: + merged[key] = value + return merged + + def create_test_config(config_name: str, includes: list[str]) -> dict: """Create ESPHome test configuration for C++ unit tests. @@ -115,6 +148,11 @@ def run_tests(selected_components: list[str]) -> int: config = create_test_config(config_name, includes) + # Merge component-specific test configs (e.g. sensor instances) before + # validation so that entity registration and USE_* defines work. + extra_config = load_component_test_configs(components) + config.update(extra_config) + CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml" CORE.dashboard = None @@ -122,8 +160,10 @@ def run_tests(selected_components: list[str]) -> int: config = validate_config(config, {}) # Add all components and dependencies to the base configuration after validation, so their files - # are added to the build. - config.update({key: {} for key in components_with_dependencies}) + # are added to the build. Use setdefault to avoid overwriting entries that were + # already validated (e.g. sensor instances from cpp_test.yaml). + for key in components_with_dependencies: + config.setdefault(key, {}) print(f"Testing components: {', '.join(components)}") CORE.config = config diff --git a/tests/components/packet_transport/common.h b/tests/components/packet_transport/common.h new file mode 100644 index 00000000000..f8caa7bb683 --- /dev/null +++ b/tests/components/packet_transport/common.h @@ -0,0 +1,98 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/packet_transport/packet_transport.h" + +namespace esphome::packet_transport::testing { + +// Protocol constants mirrored from packet_transport.cpp for test packet construction. +static constexpr uint16_t MAGIC_NUMBER = 0x4553; +static constexpr uint16_t MAGIC_PING = 0x5048; + +// Concrete testable implementation of PacketTransport. +// Captures sent packets and exposes protected members for verification. +// +// Sensor round-trip tests require USE_SENSOR / USE_BINARY_SENSOR to be defined, +// which happens when 'sensor' and 'binary_sensor' components are in the build. +// Run with --all or include those components to enable the full test suite. +class TestablePacketTransport : public PacketTransport { + public: + using PacketTransport::add_key_; + using PacketTransport::data_; + using PacketTransport::encryption_key_; + using PacketTransport::flush_; + using PacketTransport::header_; + using PacketTransport::increment_code_; + using PacketTransport::init_data_; + using PacketTransport::is_encrypted_; + using PacketTransport::is_provider_; + using PacketTransport::name_; + using PacketTransport::ping_key_; + using PacketTransport::ping_keys_; + using PacketTransport::ping_pong_enable_; + using PacketTransport::ping_pong_recyle_time_; + using PacketTransport::process_; + using PacketTransport::providers_; + using PacketTransport::rolling_code_; + using PacketTransport::rolling_code_enable_; + using PacketTransport::send_data_; + using PacketTransport::updated_; +#ifdef USE_SENSOR + using PacketTransport::add_data_; + using PacketTransport::remote_sensors_; + using PacketTransport::sensors_; +#endif +#ifdef USE_BINARY_SENSOR + using PacketTransport::add_binary_data_; + using PacketTransport::binary_sensors_; + using PacketTransport::remote_binary_sensors_; +#endif + + // NOTE: std::vector is used here for test convenience. For production code, + // consider using StaticVector or FixedVector from esphome/core/helpers.h instead. + mutable std::vector> sent_packets; + size_t max_packet_size{512}; + bool send_enabled{true}; + + void send_packet(const std::vector &buf) const override { this->sent_packets.push_back(buf); } + size_t get_max_packet_size() override { return this->max_packet_size; } + bool should_send() override { return this->send_enabled; } + + /// Build the packet header for testing without requiring App or global_preferences. + void init_for_test(const char *name) { + this->name_ = name; + this->header_.clear(); + // MAGIC_NUMBER as uint16_t little-endian + this->header_.push_back(MAGIC_NUMBER & 0xFF); + this->header_.push_back((MAGIC_NUMBER >> 8) & 0xFF); + // Length-prefixed hostname + auto len = strlen(name); + this->header_.push_back(static_cast(len)); + for (size_t i = 0; i < len; i++) + this->header_.push_back(name[i]); + // Pad to 4-byte boundary + while (this->header_.size() & 0x3) + this->header_.push_back(0); + } +}; + +/// Build a MAGIC_PING packet for testing add_key_ / ping-pong flows. +inline std::vector build_ping_packet(const char *hostname, uint32_t key) { + std::vector packet; + packet.push_back(MAGIC_PING & 0xFF); + packet.push_back((MAGIC_PING >> 8) & 0xFF); + auto len = strlen(hostname); + packet.push_back(static_cast(len)); + for (size_t i = 0; i < len; i++) + packet.push_back(hostname[i]); + packet.push_back(key & 0xFF); + packet.push_back((key >> 8) & 0xFF); + packet.push_back((key >> 16) & 0xFF); + packet.push_back((key >> 24) & 0xFF); + return packet; +} + +} // namespace esphome::packet_transport::testing diff --git a/tests/components/packet_transport/cpp_test.yaml b/tests/components/packet_transport/cpp_test.yaml new file mode 100644 index 00000000000..fa39df3c0ae --- /dev/null +++ b/tests/components/packet_transport/cpp_test.yaml @@ -0,0 +1,11 @@ +# Extra component configuration required by C++ unit tests. +# Loaded by cpp_unit_test.py and merged into the test build config +# before validation, so that platform defines (USE_SENSOR, etc.) are generated. + +sensor: + - platform: template + id: test_cpp_sensor + +binary_sensor: + - platform: template + id: test_cpp_binary_sensor diff --git a/tests/components/packet_transport/packet_transport_test.cpp b/tests/components/packet_transport/packet_transport_test.cpp new file mode 100644 index 00000000000..d8f11ca6072 --- /dev/null +++ b/tests/components/packet_transport/packet_transport_test.cpp @@ -0,0 +1,445 @@ +#include "common.h" + +namespace esphome::packet_transport::testing { + +// --- Configuration setter tests --- + +TEST(PacketTransportTest, SetIsProvider) { + TestablePacketTransport transport; + transport.set_is_provider(true); + EXPECT_TRUE(transport.is_provider_); +} + +TEST(PacketTransportTest, SetEncryptionKey) { + TestablePacketTransport transport; + std::vector key(32, 0xAB); + transport.set_encryption_key(key); + EXPECT_EQ(transport.encryption_key_, key); + EXPECT_TRUE(transport.is_encrypted_()); +} + +TEST(PacketTransportTest, NoEncryptionByDefault) { + TestablePacketTransport transport; + EXPECT_FALSE(transport.is_encrypted_()); +} + +TEST(PacketTransportTest, SetRollingCodeEnable) { + TestablePacketTransport transport; + transport.set_rolling_code_enable(true); + EXPECT_TRUE(transport.rolling_code_enable_); +} + +TEST(PacketTransportTest, SetPingPongEnable) { + TestablePacketTransport transport; + transport.set_ping_pong_enable(true); + EXPECT_TRUE(transport.ping_pong_enable_); +} + +TEST(PacketTransportTest, SetPingPongRecycleTime) { + TestablePacketTransport transport; + transport.set_ping_pong_recycle_time(600); + EXPECT_EQ(transport.ping_pong_recyle_time_, 600u); +} + +// --- Provider management --- + +TEST(PacketTransportTest, AddProvider) { + TestablePacketTransport transport; + transport.add_provider("host1"); + EXPECT_TRUE(transport.providers_.contains("host1")); + EXPECT_EQ(transport.providers_.size(), 1u); +} + +TEST(PacketTransportTest, AddProviderDuplicate) { + TestablePacketTransport transport; + transport.add_provider("host1"); + transport.add_provider("host1"); + EXPECT_EQ(transport.providers_.size(), 1u); +} + +TEST(PacketTransportTest, SetProviderEncryption) { + TestablePacketTransport transport; + transport.add_provider("host1"); + std::vector key(32, 0xCD); + transport.set_provider_encryption("host1", key); + EXPECT_EQ(transport.providers_["host1"].encryption_key, key); +} + +// --- Sensor management (requires USE_SENSOR / USE_BINARY_SENSOR) --- + +#ifdef USE_SENSOR +TEST(PacketTransportTest, AddSensor) { + TestablePacketTransport transport; + sensor::Sensor s; + transport.add_sensor("temp", &s); + ASSERT_EQ(transport.sensors_.size(), 1u); + EXPECT_STREQ(transport.sensors_[0].id, "temp"); + EXPECT_EQ(transport.sensors_[0].sensor, &s); + EXPECT_TRUE(transport.sensors_[0].updated); +} + +TEST(PacketTransportTest, AddRemoteSensor) { + TestablePacketTransport transport; + sensor::Sensor s; + transport.add_remote_sensor("host1", "remote_temp", &s); + EXPECT_TRUE(transport.providers_.contains("host1")); + EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s); +} +#endif + +#ifdef USE_BINARY_SENSOR +TEST(PacketTransportTest, AddBinarySensor) { + TestablePacketTransport transport; + binary_sensor::BinarySensor bs; + transport.add_binary_sensor("motion", &bs); + ASSERT_EQ(transport.binary_sensors_.size(), 1u); + EXPECT_STREQ(transport.binary_sensors_[0].id, "motion"); + EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs); +} + +TEST(PacketTransportTest, AddRemoteBinarySensor) { + TestablePacketTransport transport; + binary_sensor::BinarySensor bs; + transport.add_remote_binary_sensor("host1", "remote_motion", &bs); + EXPECT_TRUE(transport.providers_.contains("host1")); + EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs); +} +#endif + +// --- Unencrypted round-trip tests (require USE_SENSOR / USE_BINARY_SENSOR) --- + +#ifdef USE_SENSOR +TEST(PacketTransportTest, UnencryptedSensorRoundTrip) { + // Encoder + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + sensor::Sensor local_sensor; + local_sensor.state = 42.5f; + encoder.add_sensor("temp", &local_sensor); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + // Decoder + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; // sentinel + decoder.add_remote_sensor("sender", "temp", &remote_sensor); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f); +} +#endif + +#ifdef USE_BINARY_SENSOR +TEST(PacketTransportTest, UnencryptedBinarySensorRoundTrip) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + binary_sensor::BinarySensor local_bs; + local_bs.state = true; + encoder.add_binary_sensor("motion", &local_bs); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + binary_sensor::BinarySensor remote_bs; + decoder.add_remote_binary_sensor("sender", "motion", &remote_bs); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_TRUE(remote_bs.state); +} +#endif + +#if defined(USE_SENSOR) && defined(USE_BINARY_SENSOR) +TEST(PacketTransportTest, MultipleSensorsRoundTrip) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + + sensor::Sensor s1, s2; + s1.state = 10.0f; + s2.state = 20.0f; + encoder.add_sensor("s1", &s1); + encoder.add_sensor("s2", &s2); + + binary_sensor::BinarySensor bs1; + bs1.state = true; + encoder.add_binary_sensor("bs1", &bs1); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor rs1, rs2; + binary_sensor::BinarySensor rbs1; + rs1.state = -999.0f; + rs2.state = -999.0f; + decoder.add_remote_sensor("sender", "s1", &rs1); + decoder.add_remote_sensor("sender", "s2", &rs2); + decoder.add_remote_binary_sensor("sender", "bs1", &rbs1); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + + EXPECT_FLOAT_EQ(rs1.state, 10.0f); + EXPECT_FLOAT_EQ(rs2.state, 20.0f); + EXPECT_TRUE(rbs1.state); +} +#endif + +// --- Encrypted round-trip --- + +#ifdef USE_SENSOR +TEST(PacketTransportTest, EncryptedSensorRoundTrip) { + std::vector key(32); + for (int i = 0; i < 32; i++) + key[i] = i; + + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + encoder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 99.9f; + encoder.add_sensor("temp", &local_sensor); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + decoder.add_remote_sensor("sender", "temp", &remote_sensor); + decoder.set_provider_encryption("sender", key); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f); +} + +// --- Selective send --- + +TEST(PacketTransportTest, SendDataOnlyUpdated) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + + sensor::Sensor s1, s2; + s1.state = 1.0f; + s2.state = 2.0f; + encoder.add_sensor("s1", &s1); + encoder.add_sensor("s2", &s2); + + // Mark s1 as not updated, only s2 as updated + encoder.sensors_[0].updated = false; + encoder.sensors_[1].updated = true; + + encoder.send_data_(false); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor rs1, rs2; + rs1.state = -999.0f; + rs2.state = -999.0f; + decoder.add_remote_sensor("sender", "s1", &rs1); + decoder.add_remote_sensor("sender", "s2", &rs2); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + + EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent + EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent +} +#endif + +// --- Ping key tests --- + +TEST(PacketTransportTest, PingKeyStoredWhenEncrypted) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + transport.set_encryption_key(std::vector(32, 0xAA)); + + auto ping = build_ping_packet("requester", 0xDEADBEEF); + transport.process_({ping.data(), ping.size()}); + + ASSERT_EQ(transport.ping_keys_.size(), 1u); + EXPECT_EQ(transport.ping_keys_["requester"], 0xDEADBEEFu); +} + +TEST(PacketTransportTest, PingKeyIgnoredWhenNotEncrypted) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + // No encryption key — add_key_ should be a no-op + + auto ping = build_ping_packet("requester", 0xDEADBEEF); + transport.process_({ping.data(), ping.size()}); + + EXPECT_TRUE(transport.ping_keys_.empty()); +} + +TEST(PacketTransportTest, PingKeyUpdatedOnRepeat) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + transport.set_encryption_key(std::vector(32, 0xAA)); + + auto ping1 = build_ping_packet("host1", 0x1111); + transport.process_({ping1.data(), ping1.size()}); + EXPECT_EQ(transport.ping_keys_["host1"], 0x1111u); + + // Same host, new key value — should update in place + auto ping2 = build_ping_packet("host1", 0x2222); + transport.process_({ping2.data(), ping2.size()}); + EXPECT_EQ(transport.ping_keys_.size(), 1u); + EXPECT_EQ(transport.ping_keys_["host1"], 0x2222u); +} + +TEST(PacketTransportTest, PingKeyMaxLimit) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + transport.set_encryption_key(std::vector(32, 0xAA)); + + // Fill to MAX_PING_KEYS (4) + for (int i = 0; i < 4; i++) { + char name[16]; + snprintf(name, sizeof(name), "host%d", i); + auto ping = build_ping_packet(name, 0x1000 + i); + transport.process_({ping.data(), ping.size()}); + } + EXPECT_EQ(transport.ping_keys_.size(), 4u); + + // 5th key should be discarded + auto ping = build_ping_packet("host4", 0x9999); + transport.process_({ping.data(), ping.size()}); + EXPECT_EQ(transport.ping_keys_.size(), 4u); + EXPECT_FALSE(transport.ping_keys_.contains("host4")); +} + +#ifdef USE_SENSOR +TEST(PacketTransportTest, PingKeyIncludedInTransmittedPacket) { + std::vector key(32, 0xBB); + + // Responder: encrypted, owns a sensor + TestablePacketTransport responder; + responder.init_for_test("responder"); + responder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 77.7f; + responder.add_sensor("temp", &local_sensor); + + // Requester sends a MAGIC_PING that the responder processes + auto ping = build_ping_packet("requester", 0xDEADBEEF); + responder.process_({ping.data(), ping.size()}); + ASSERT_EQ(responder.ping_keys_.size(), 1u); + + // Responder sends sensor data — ping key should be embedded + responder.send_data_(true); + ASSERT_EQ(responder.sent_packets.size(), 1u); + + // Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF + TestablePacketTransport requester; + requester.init_for_test("requester"); + requester.set_ping_pong_enable(true); + requester.ping_key_ = 0xDEADBEEF; + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + requester.add_remote_sensor("responder", "temp", &remote_sensor); + requester.set_provider_encryption("responder", key); + + // The requester decrypts the packet and finds its ping key echoed back, + // which gates the sensor data — if the key is missing, data is blocked. + auto &packet = responder.sent_packets[0]; + requester.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f); +} + +TEST(PacketTransportTest, MissingPingKeyBlocksSensorData) { + std::vector key(32, 0xBB); + + // Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys + TestablePacketTransport responder; + responder.init_for_test("responder"); + responder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 77.7f; + responder.add_sensor("temp", &local_sensor); + responder.send_data_(true); + ASSERT_EQ(responder.sent_packets.size(), 1u); + + // Requester with ping-pong enabled expects a key that isn't in the packet + TestablePacketTransport requester; + requester.init_for_test("requester"); + requester.set_ping_pong_enable(true); + requester.ping_key_ = 0xDEADBEEF; + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + requester.add_remote_sensor("responder", "temp", &remote_sensor); + requester.set_provider_encryption("responder", key); + + auto &packet = responder.sent_packets[0]; + requester.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found +} +#endif + +// --- Process error handling --- + +TEST(PacketTransportTest, ProcessShortBuffer) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + uint8_t buf[] = {0x53}; + // Too short for a magic number - should return safely + transport.process_({buf, 1}); +} + +TEST(PacketTransportTest, ProcessBadMagic) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + uint8_t buf[] = {0xFF, 0xFF, 0x00, 0x00}; + // Wrong magic - should return safely + transport.process_({buf, sizeof(buf)}); +} + +TEST(PacketTransportTest, ProcessOwnHostname) { + TestablePacketTransport transport; + transport.init_for_test("myself"); + // Build a packet from "myself" using a separate encoder + TestablePacketTransport fake_sender; + fake_sender.init_for_test("myself"); + fake_sender.send_data_(true); + ASSERT_EQ(fake_sender.sent_packets.size(), 1u); + + auto &packet = fake_sender.sent_packets[0]; + // Should be silently ignored because hostname matches our own + transport.process_({packet.data(), packet.size()}); +} + +TEST(PacketTransportTest, ProcessUnknownHostname) { + TestablePacketTransport transport; + transport.init_for_test("receiver"); + // No providers registered - "unknown" will not be found + TestablePacketTransport sender; + sender.init_for_test("unknown"); + sender.send_data_(true); + ASSERT_EQ(sender.sent_packets.size(), 1u); + + auto &packet = sender.sent_packets[0]; + // Should return safely without crash + transport.process_({packet.data(), packet.size()}); +} + +// --- Send disabled --- + +TEST(PacketTransportTest, NoSendWhenDisabled) { + TestablePacketTransport transport; + transport.init_for_test("sender"); + transport.send_enabled = false; + transport.send_data_(true); + EXPECT_TRUE(transport.sent_packets.empty()); +} + +} // namespace esphome::packet_transport::testing From 0e2a10c5f02cd4ba37ed53dde370bdd0a54c43ac Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 4 Mar 2026 19:34:13 -0800 Subject: [PATCH 08/24] [openthread] Cache is_connected() for cheap inline access (#14484) Co-authored-by: J. Nick Koston --- esphome/components/openthread/openthread.cpp | 25 ++++++------------- esphome/components/openthread/openthread.h | 5 +++- .../components/openthread/openthread_esp.cpp | 3 +++ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 92897a7e96e..9452f5a41eb 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -1,9 +1,7 @@ #include "esphome/core/defines.h" #ifdef USE_OPENTHREAD #include "openthread.h" -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) #include "esp_openthread.h" -#endif #include @@ -48,22 +46,15 @@ void OpenThreadComponent::dump_config() { } } -bool OpenThreadComponent::is_connected() { - auto lock = InstanceLock::try_acquire(100); - if (!lock) { - ESP_LOGW(TAG, "Failed to acquire OpenThread lock in is_connected"); - return false; +void OpenThreadComponent::on_state_changed_(otChangedFlags flags, void *context) { + if (flags & OT_CHANGED_THREAD_ROLE) { + auto *self = static_cast(context); + // This runs on the OpenThread task thread with the OT lock held, + // so we can safely call otThreadGetDeviceRole directly. + otInstance *instance = esp_openthread_get_instance(); + otDeviceRole role = otThreadGetDeviceRole(instance); + self->connected_ = role >= OT_DEVICE_ROLE_CHILD; } - - otInstance *instance = lock->get_instance(); - if (instance == nullptr) { - return false; - } - - otDeviceRole role = otThreadGetDeviceRole(instance); - - // TODO: If we're a leader, check that there is at least 1 known peer - return role >= OT_DEVICE_ROLE_CHILD; } // Gets the off-mesh routable address diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 728847afa54..d853c58f958 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -26,7 +27,7 @@ class OpenThreadComponent : public Component { bool teardown() override; float get_setup_priority() const override { return setup_priority::WIFI; } - bool is_connected(); + bool is_connected() const { return this->connected_; } network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); void ot_main(); @@ -42,6 +43,7 @@ class OpenThreadComponent : public Component { protected: std::optional get_omr_address_(InstanceLock &lock); + static void on_state_changed_(otChangedFlags flags, void *context); std::function factory_reset_external_callback_; #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; @@ -49,6 +51,7 @@ class OpenThreadComponent : public Component { std::optional output_power_{}; bool teardown_started_{false}; bool teardown_complete_{false}; + bool connected_{false}; private: // Stores a pointer to a string literal (static storage duration). diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 2af78b729f3..2296e32b7f7 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -175,6 +175,9 @@ void OpenThreadComponent::ot_main() { // Pass the existing dataset, or NULL which will use the preprocessor definitions ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + // Register state change callback to update connected_ reactively instead of polling + otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed_, this); + esp_openthread_launch_mainloop(); // Clean up From c49c23d5d90ef0d4463479ffe8a2038252405d4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Mar 2026 07:21:04 -1000 Subject: [PATCH 09/24] [network] Inline network::is_connected() and ethernet is_connected() (#14464) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ethernet/ethernet_component.cpp | 2 - .../components/ethernet/ethernet_component.h | 2 +- esphome/components/network/util.cpp | 44 +----------------- esphome/components/network/util.h | 45 ++++++++++++++++++- 4 files changed, 46 insertions(+), 47 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index f855bc89cc7..098f7be972f 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -687,8 +687,6 @@ void EthernetComponent::start_connect_() { this->status_set_warning(); } -bool EthernetComponent::is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } - void EthernetComponent::dump_connect_params_() { esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 1cd44d2b2cf..f5a31d78ebf 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -76,7 +76,7 @@ class EthernetComponent : public Component { void dump_config() override; float get_setup_priority() const override; void on_powerdown() override { powerdown(); } - bool is_connected(); + bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } #ifdef USE_ETHERNET_SPI void set_clk_pin(uint8_t clk_pin); diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index e397d770775..226b11b8cd3 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,53 +1,11 @@ #include "util.h" #include "esphome/core/defines.h" #ifdef USE_NETWORK -#ifdef USE_WIFI -#include "esphome/components/wifi/wifi_component.h" -#endif - -#ifdef USE_ETHERNET -#include "esphome/components/ethernet/ethernet_component.h" -#endif - -#ifdef USE_OPENTHREAD -#include "esphome/components/openthread/openthread.h" -#endif - -#ifdef USE_MODEM -#include "esphome/components/modem/modem_component.h" -#endif namespace esphome::network { // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as -// an AP that use a previous interface for NAT). - -bool is_connected() { -#ifdef USE_ETHERNET - if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) - return true; -#endif - -#ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); -#endif - -#ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); -#endif - -#ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); -#endif - -#ifdef USE_HOST - return true; // Assume its connected -#endif - return false; -} +// an AP that uses a previous interface for NAT). bool is_disabled() { #ifdef USE_MODEM diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index ae949ab0a85..4b700fe74c0 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -2,12 +2,55 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK #include +#include "esphome/core/helpers.h" #include "ip_address.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_MODEM +#include "esphome/components/modem/modem_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif + namespace esphome::network { +// The order of the components is important: WiFi should come after any possible main interfaces (it may be used as +// an AP that uses a previous interface for NAT). + /// Return whether the node is connected to the network (through wifi, eth, ...) -bool is_connected(); +ESPHOME_ALWAYS_INLINE inline bool is_connected() { +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) + return true; +#endif + +#ifdef USE_MODEM + if (modem::global_modem_component != nullptr) + return modem::global_modem_component->is_connected(); +#endif + +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) + return wifi::global_wifi_component->is_connected(); +#endif + +#ifdef USE_OPENTHREAD + if (openthread::global_openthread_component != nullptr) + return openthread::global_openthread_component->is_connected(); +#endif + +#ifdef USE_HOST + return true; // Assume it's connected +#endif + return false; +} + /// Return whether the network is disabled (only wifi for now) bool is_disabled(); /// Get the active network hostname From 2777d359904e117190e10f353c3601e67012478f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Mar 2026 07:21:44 -1000 Subject: [PATCH 10/24] [api] Devirtualize frame helper calls when protocol is fixed at compile time (#14468) --- esphome/components/api/api_connection.cpp | 5 +++-- esphome/components/api/api_connection.h | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 59476fac253..98ba1abe0b5 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -114,9 +114,10 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa this->helper_ = std::unique_ptr{new APIPlaintextFrameHelper(std::move(sock))}; } #elif defined(USE_API_PLAINTEXT) - this->helper_ = std::unique_ptr{new APIPlaintextFrameHelper(std::move(sock))}; + this->helper_ = std::unique_ptr{new APIPlaintextFrameHelper(std::move(sock))}; #elif defined(USE_API_NOISE) - this->helper_ = std::unique_ptr{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())}; + this->helper_ = + std::unique_ptr{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())}; #else #error "No frame helper defined" #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 37855b2482a..aae8db3c688 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -3,6 +3,12 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_frame_helper.h" +#ifdef USE_API_NOISE +#include "api_frame_helper_noise.h" +#endif +#ifdef USE_API_PLAINTEXT +#include "api_frame_helper_plaintext.h" +#endif #include "api_pb2.h" #include "api_pb2_service.h" #include "api_server.h" @@ -489,7 +495,13 @@ class APIConnection final : public APIServerConnectionBase { // === Optimal member ordering for 32-bit systems === // Group 1: Pointers (4 bytes each on 32-bit) +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) std::unique_ptr helper_; +#elif defined(USE_API_NOISE) + std::unique_ptr helper_; +#elif defined(USE_API_PLAINTEXT) + std::unique_ptr helper_; +#endif APIServer *parent_; // Group 2: Iterator union (saves ~16 bytes vs separate iterators) From a061397469da387ea8086fb494fcfe61861c222b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:16:06 -0500 Subject: [PATCH 11/24] [dfrobot_sen0395][sx1509] Fix structural bugs (#14494) Co-authored-by: Claude Opus 4.6 --- esphome/components/dfrobot_sen0395/commands.h | 14 ++++++-------- esphome/components/sx1509/sx1509.cpp | 6 +++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/commands.h b/esphome/components/dfrobot_sen0395/commands.h index 3b0551b1843..95167efb4db 100644 --- a/esphome/components/dfrobot_sen0395/commands.h +++ b/esphome/components/dfrobot_sen0395/commands.h @@ -30,11 +30,9 @@ class Command { class ReadStateCommand : public Command { public: + ReadStateCommand() { timeout_ms_ = 500; } uint8_t execute(DfrobotSen0395Component *parent) override; uint8_t on_message(std::string &message) override; - - protected: - uint32_t timeout_ms_{500}; }; class PowerCommand : public Command { @@ -99,12 +97,12 @@ class ResetSystemCommand : public Command { class SaveCfgCommand : public Command { public: - SaveCfgCommand() { cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; } + SaveCfgCommand() { + cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; + cmd_duration_ms_ = 3000; + timeout_ms_ = 3500; + } uint8_t on_message(std::string &message) override; - - protected: - uint32_t cmd_duration_ms_{3000}; - uint32_t timeout_ms_{3500}; }; class LedModeCommand : public Command { diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 746ec9cda35..dfe1277297a 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -56,11 +56,11 @@ void SX1509Component::loop() { return; } int row, col; - for (row = 0; row < 7; row++) { + for (row = 0; row < 8; row++) { if (key_data & (1 << row)) break; } - for (col = 8; col < 15; col++) { + for (col = 8; col < 16; col++) { if (key_data & (1 << col)) break; } @@ -229,7 +229,7 @@ void SX1509Component::setup_keypad_() { this->read_byte_16(REG_DIR_B, &this->ddr_mask_); for (int i = 0; i < this->rows_; i++) this->ddr_mask_ &= ~(1 << i); - for (int i = 8; i < (this->cols_ * 2); i++) + for (int i = 8; i < (8 + this->cols_); i++) this->ddr_mask_ |= (1 << i); this->write_byte_16(REG_DIR_B, this->ddr_mask_); From 01f4275202a8e9e82b2b7486ceefe18f5ffa1238 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:16:33 -0500 Subject: [PATCH 12/24] [veml7700] Fix initial settling timeout using raw enum instead of milliseconds (#14487) Co-authored-by: Claude Opus 4.6 --- esphome/components/veml7700/veml7700.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index eb286ba21b8..1ed484119bd 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -141,7 +141,7 @@ void VEML7700Component::loop() { // Datasheet: 2.5 ms before the first measurement is needed, allowing for the correct start of the signal processor // and oscillator. // Reality: wait for couple integration times to have first samples captured - this->set_timeout(2 * this->integration_time_, [this]() { this->state_ = State::IDLE; }); + this->set_timeout(2 * get_itime_ms(this->integration_time_), [this]() { this->state_ = State::IDLE; }); } if (this->is_ready()) { From 3df4ef9362cb7843f91c28cb2cfef1691dac8c52 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:31:26 -0500 Subject: [PATCH 13/24] [ssd1322][ssd1325][ssd1327] Fix nibble mask bug in grayscale draw_pixel (#14496) Co-authored-by: Claude Opus 4.6 --- esphome/components/ssd1322_base/ssd1322_base.cpp | 2 +- esphome/components/ssd1325_base/ssd1325_base.cpp | 2 +- esphome/components/ssd1327_base/ssd1327_base.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ssd1322_base/ssd1322_base.cpp b/esphome/components/ssd1322_base/ssd1322_base.cpp index 23576e7b2c4..1fce826ad9d 100644 --- a/esphome/components/ssd1322_base/ssd1322_base.cpp +++ b/esphome/components/ssd1322_base/ssd1322_base.cpp @@ -169,7 +169,7 @@ void HOT SSD1322::draw_absolute_pixel_internal(int x, int y, Color color) { // ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary color4 = (color4 & SSD1322_COLORMASK) << shift; // first mask off the nibble we must change... - this->buffer_[pos] &= (~SSD1322_COLORMASK >> shift); + this->buffer_[pos] &= (static_cast(~SSD1322_COLORMASK) >> shift); // ...then lay the new nibble back on top. done! this->buffer_[pos] |= color4; } diff --git a/esphome/components/ssd1325_base/ssd1325_base.cpp b/esphome/components/ssd1325_base/ssd1325_base.cpp index e7d2386ac71..fe7df9674b6 100644 --- a/esphome/components/ssd1325_base/ssd1325_base.cpp +++ b/esphome/components/ssd1325_base/ssd1325_base.cpp @@ -202,7 +202,7 @@ void HOT SSD1325::draw_absolute_pixel_internal(int x, int y, Color color) { // ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary color4 = (color4 & SSD1325_COLORMASK) << shift; // first mask off the nibble we must change... - this->buffer_[pos] &= (~SSD1325_COLORMASK >> shift); + this->buffer_[pos] &= (static_cast(~SSD1325_COLORMASK) >> shift); // ...then lay the new nibble back on top. done! this->buffer_[pos] |= color4; } diff --git a/esphome/components/ssd1327_base/ssd1327_base.cpp b/esphome/components/ssd1327_base/ssd1327_base.cpp index 2498bfcd67c..87e52206f2d 100644 --- a/esphome/components/ssd1327_base/ssd1327_base.cpp +++ b/esphome/components/ssd1327_base/ssd1327_base.cpp @@ -145,7 +145,7 @@ void HOT SSD1327::draw_absolute_pixel_internal(int x, int y, Color color) { // ensure 'color4' is valid (only 4 bits aka 1 nibble) and shift the bits left when necessary color4 = (color4 & SSD1327_COLORMASK) << shift; // first mask off the nibble we must change... - this->buffer_[pos] &= (~SSD1327_COLORMASK >> shift); + this->buffer_[pos] &= (static_cast(~SSD1327_COLORMASK) >> shift); // ...then lay the new nibble back on top. done! this->buffer_[pos] |= color4; } From 4a5d8449fd28aa80b801dc270a3f2e9e20344794 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:33:12 -0500 Subject: [PATCH 14/24] [sht4x][grove_tb6612fng] Fix logic bugs (#14497) Co-authored-by: Claude Opus 4.6 --- esphome/components/grove_tb6612fng/grove_tb6612fng.cpp | 2 +- esphome/components/sht4x/sht4x.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index a2499846473..428c8ec4a8c 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -131,7 +131,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, buffer_[4] = ms_per_step; buffer_[5] = (ms_per_step >> 8); - if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_RUN, buffer_, 1) != i2c::ERROR_OK) { + if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_RUN, buffer_, 6) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Run stepper failed!"); this->status_set_warning(); return; diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 9d29746f0bf..42be3262029 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -10,7 +10,7 @@ static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; void SHT4XComponent::start_heater_() { - uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]}; + uint8_t cmd[] = {this->heater_command_}; ESP_LOGD(TAG, "Heater turning on"); if (this->write(cmd, 1) != i2c::ERROR_OK) { From 9518d88a2aa7a5f7d5b263ffabeddeebd8f9d17d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 5 Mar 2026 10:35:20 -0800 Subject: [PATCH 15/24] [openthread] static log level code quality improvement (#14456) Co-authored-by: J. Nick Koston --- esphome/components/openthread/__init__.py | 21 +++++++++++++++++++ .../openthread/test.esp32-c6-idf.yaml | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 5c64cf31dce..21373b77dfb 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -14,9 +14,12 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, CONF_ENABLE_IPV6, + CONF_FRAMEWORK, CONF_ID, + CONF_LOG_LEVEL, CONF_OUTPUT_POWER, CONF_USE_ADDRESS, + PLATFORM_ESP32, ) from esphome.core import CORE, TimePeriodMilliseconds import esphome.final_validate as fv @@ -46,6 +49,15 @@ AUTO_LOAD = ["network"] CONFLICTS_WITH = ["wifi"] DEPENDENCIES = ["esp32"] +IDF_TO_OT_LOG_LEVEL = { + "NONE": "NONE", + "ERROR": "CRIT", + "WARN": "WARN", + "INFO": "NOTE", + "DEBUG": "INFO", + "VERBOSE": "DEBG", +} + CONF_DEVICE_TYPES = [ "FTD", "MTD", @@ -198,6 +210,15 @@ def _final_validate(_): "Please set `enable_ipv6: true` in the `network` configuration." ) + if ( + (esp32_config := full_config.get(PLATFORM_ESP32)) is not None + and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None + and (log_level := fw_config.get(CONF_LOG_LEVEL)) is not None + ): + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_LOG_LEVEL_DYNAMIC", False) + ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level) + add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True) + FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 77abc433c14..008edd53972 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -1,3 +1,9 @@ +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + log_level: DEBUG + network: enable_ipv6: true From e1d0c6da09ecad433c77ff8efdad60c730f020d8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:09:23 -0500 Subject: [PATCH 16/24] [dfplayer][ufire_ise][ufire_ec][qmp6988][atm90e26] Fix wrong operators and masks (#14491) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- esphome/components/atm90e26/atm90e26.cpp | 2 +- esphome/components/dfplayer/dfplayer.cpp | 1 + esphome/components/qmp6988/qmp6988.cpp | 2 +- esphome/components/ufire_ec/ufire_ec.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index 2203dd0d713..e6602411bb8 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -197,7 +197,7 @@ float ATM90E26Component::get_reactive_power_() { float ATM90E26Component::get_power_factor_() { const uint16_t val = this->read16_(ATM90E26_REGISTER_POWERF); // signed if (val & 0x8000) { - return -(val & 0x7FF) / 1000.0f; + return -(val & 0x7FFF) / 1000.0f; } else { return val / 1000.0f; } diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 79f8fd03c3e..1e1c33adaf3 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -260,6 +260,7 @@ void DFPlayer::loop() { ESP_LOGV(TAG, "Playback finished (USB drive)"); this->is_playing_ = false; this->on_finished_playback_callback_.call(); + break; case 0x3D: ESP_LOGV(TAG, "Playback finished (SD card)"); this->is_playing_ = false; diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 24fe34e7852..17d91c36330 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -251,7 +251,7 @@ void QMP6988Component::set_power_mode_(uint8_t power_mode) { void QMP6988Component::write_filter_(QMP6988IIRFilter filter) { uint8_t data; - data = (filter & 0x03); + data = (filter & QMP6988_CONFIG_REG_FILTER_MSK); this->write_byte(QMP6988_CONFIG_REG, data); delay(10); } diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 3868dc92b7f..a1c3568a1a3 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "ufire_ec"; void UFireECComponent::setup() { uint8_t version; - if (!this->read_byte(REGISTER_VERSION, &version) && version != 0xFF) { + if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { this->mark_failed(); return; } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index 486a5063913..e967fc53c37 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "ufire_ise"; void UFireISEComponent::setup() { uint8_t version; - if (!this->read_byte(REGISTER_VERSION, &version) && version != 0xFF) { + if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { this->mark_failed(); return; } From cce7a09fa995692dae38a4fdecc34bd297c167a0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:09:34 -0500 Subject: [PATCH 17/24] [pn532_spi] Fix preamble check logic and OOB access when full_len is zero (#14486) Co-authored-by: Claude Opus 4.6 --- esphome/components/pn532_spi/pn532_spi.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 118421c47f3..553c6d26a6a 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -88,9 +88,10 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { #endif ESP_LOGV(TAG, "Header data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), header.data(), header.size())); - if (header[0] != 0x00 && header[1] != 0x00 && header[2] != 0xFF) { + if (header[0] != 0x00 || header[1] != 0x00 || header[2] != 0xFF) { // invalid packet ESP_LOGV(TAG, "read data invalid preamble!"); + this->disable(); return false; } @@ -100,15 +101,20 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { if (!valid_header) { ESP_LOGV(TAG, "read data invalid header!"); + this->disable(); return false; } - // full length of message, including command response + // full length of message, including command response (minimum 2: TFI + command response) uint8_t full_len = header[3]; + if (full_len < 2) { + ESP_LOGV(TAG, "read data has no payload"); + this->disable(); + return false; + } + // length of data, excluding command response uint8_t len = full_len - 1; - if (full_len == 0) - len = 0; ESP_LOGV(TAG, "Reading response of length %d", len); From e210e414bd0ed93c678ecdc3e8895918a517716d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Mar 2026 09:15:02 -1000 Subject: [PATCH 18/24] [ota] Devirtualize OTA backend calls (#14473) --- esphome/components/esphome/ota/ota_esphome.h | 4 +-- .../http_request/ota/ota_http_request.cpp | 7 +---- .../http_request/ota/ota_http_request.h | 4 +-- esphome/components/ota/ota_backend.h | 13 --------- .../ota/ota_backend_arduino_libretiny.cpp | 2 +- .../ota/ota_backend_arduino_libretiny.h | 16 ++++++----- .../ota/ota_backend_arduino_rp2040.cpp | 2 +- .../ota/ota_backend_arduino_rp2040.h | 16 ++++++----- .../components/ota/ota_backend_esp8266.cpp | 2 +- esphome/components/ota/ota_backend_esp8266.h | 16 ++++++----- .../components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 16 ++++++----- esphome/components/ota/ota_backend_factory.h | 27 +++++++++++++++++++ esphome/components/ota/ota_backend_host.cpp | 2 +- esphome/components/ota/ota_backend_host.h | 16 ++++++----- .../web_server/ota/ota_web_server.cpp | 4 +-- 16 files changed, 84 insertions(+), 65 deletions(-) create mode 100644 esphome/components/ota/ota_backend_factory.h diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 08edacad92f..f3a5952398f 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" #ifdef USE_OTA -#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_factory.h" #include "esphome/components/socket/socket.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -86,7 +86,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; - std::unique_ptr backend_; + ota::OTABackendPtr backend_; uint32_t client_connect_time_{0}; uint16_t port_; diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 0db3a50b47d..5dd21c314c6 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -8,10 +8,6 @@ #include "esphome/components/md5/md5.h" #include "esphome/components/watchdog/watchdog.h" -#include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_esp8266.h" -#include "esphome/components/ota/ota_backend_arduino_rp2040.h" -#include "esphome/components/ota/ota_backend_esp_idf.h" namespace esphome { namespace http_request { @@ -69,8 +65,7 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(std::unique_ptr backend, - const std::shared_ptr &container) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { if (this->update_started_) { ESP_LOGV(TAG, "Aborting OTA backend"); backend->abort(); diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index 6d39b0d466c..70e4559fa7c 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_factory.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -39,7 +39,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(std::unique_ptr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); uint8_t do_ota_(); std::string get_url_with_auth_(const std::string &url); bool http_get_md5_(); diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index e03afd4fc6f..bc603a6e9e2 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -49,17 +49,6 @@ enum OTAState { OTA_ERROR, }; -class OTABackend { - public: - virtual ~OTABackend() = default; - virtual OTAResponseTypes begin(size_t image_size) = 0; - virtual void set_update_md5(const char *md5) = 0; - virtual OTAResponseTypes write(uint8_t *data, size_t len) = 0; - virtual OTAResponseTypes end() = 0; - virtual void abort() = 0; - virtual bool supports_compression() = 0; -}; - /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates @@ -130,7 +119,5 @@ OTAGlobalCallback *get_global_ota_callback(); // - notify_state_deferred_() when in separate task (e.g., web_server OTA) // This ensures proper listener execution in all contexts. #endif -std::unique_ptr make_ota_backend(); - } // namespace ota } // namespace esphome diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index b4ecad1227e..d364f750074 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -12,7 +12,7 @@ namespace ota { static const char *const TAG = "ota.arduino_libretiny"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 8f9d268eec6..4514bf84bda 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -7,19 +7,21 @@ namespace esphome { namespace ota { -class ArduinoLibreTinyOTABackend final : public OTABackend { +class ArduinoLibreTinyOTABackend final { public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } + OTAResponseTypes begin(size_t image_size); + void set_update_md5(const char *md5); + OTAResponseTypes write(uint8_t *data, size_t len); + OTAResponseTypes end(); + void abort(); + bool supports_compression() { return false; } private: bool md5_set_{false}; }; +std::unique_ptr make_ota_backend(); + } // namespace ota } // namespace esphome diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index ee1ba48d504..e2a57ec665b 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -14,7 +14,7 @@ namespace ota { static const char *const TAG = "ota.arduino_rp2040"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { // OTA size of 0 is not currently handled, but diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 6a708f9c574..0956cb4b4b9 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -9,19 +9,21 @@ namespace esphome { namespace ota { -class ArduinoRP2040OTABackend final : public OTABackend { +class ArduinoRP2040OTABackend final { public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } + OTAResponseTypes begin(size_t image_size); + void set_update_md5(const char *md5); + OTAResponseTypes write(uint8_t *data, size_t len); + OTAResponseTypes end(); + void abort(); + bool supports_compression() { return false; } private: bool md5_set_{false}; }; +std::unique_ptr make_ota_backend(); + } // namespace ota } // namespace esphome diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 4b84708cd91..1f9a77e4261 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -48,7 +48,7 @@ namespace esphome::ota { static const char *const TAG = "ota.esp8266"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { // Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 52f657f0065..6213289accb 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -12,15 +12,15 @@ namespace esphome::ota { /// OTA backend for ESP8266 using native SDK functions. /// This implementation bypasses the Arduino Updater library to save ~228 bytes of RAM /// by not having a global Update object in .bss. -class ESP8266OTABackend final : public OTABackend { +class ESP8266OTABackend final { public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; + OTAResponseTypes begin(size_t image_size); + void set_update_md5(const char *md5); + OTAResponseTypes write(uint8_t *data, size_t len); + OTAResponseTypes end(); + void abort(); // Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0) - bool supports_compression() override { return true; } + bool supports_compression() { return true; } protected: /// Erase flash sector if current address is at sector boundary @@ -54,5 +54,7 @@ class ESP8266OTABackend final : public OTABackend { bool md5_set_{false}; }; +std::unique_ptr make_ota_backend(); + } // namespace esphome::ota #endif // USE_ESP8266 diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 93c65a9624e..925bb396454 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -11,7 +11,7 @@ namespace esphome { namespace ota { -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size) { #ifdef USE_OTA_ROLLBACK diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 7f7f6115c50..a0f538afc02 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,14 +10,14 @@ namespace esphome { namespace ota { -class IDFOTABackend final : public OTABackend { +class IDFOTABackend final { public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } + OTAResponseTypes begin(size_t image_size); + void set_update_md5(const char *md5); + OTAResponseTypes write(uint8_t *data, size_t len); + OTAResponseTypes end(); + void abort(); + bool supports_compression() { return false; } private: esp_ota_handle_t update_handle_{0}; @@ -27,6 +27,8 @@ class IDFOTABackend final : public OTABackend { bool md5_set_{false}; }; +std::unique_ptr make_ota_backend(); + } // namespace ota } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h new file mode 100644 index 00000000000..7c79f027027 --- /dev/null +++ b/esphome/components/ota/ota_backend_factory.h @@ -0,0 +1,27 @@ +#pragma once + +#include "ota_backend.h" + +#include + +#ifdef USE_ESP8266 +#include "ota_backend_esp8266.h" +#elif defined(USE_ESP32) +#include "ota_backend_esp_idf.h" +#elif defined(USE_RP2040) +#include "ota_backend_arduino_rp2040.h" +#elif defined(USE_LIBRETINY) +#include "ota_backend_arduino_libretiny.h" +#elif defined(USE_HOST) +#include "ota_backend_host.h" +#else +// Stub for static analysis when no platform is defined +namespace esphome::ota { +struct StubOTABackend {}; +std::unique_ptr make_ota_backend(); +} // namespace esphome::ota +#endif + +namespace esphome::ota { +using OTABackendPtr = decltype(make_ota_backend()); +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index ddab174bed7..2e2132418d8 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -8,7 +8,7 @@ namespace esphome::ota { // Stub implementation - OTA is not supported on host platform. // All methods return error codes to allow compilation of configs with OTA triggers. -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; } diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 5a2dcfcf39b..300facf72f9 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -7,15 +7,17 @@ namespace esphome::ota { /// Stub OTA backend for host platform - allows compilation but does not implement OTA. /// All operations return error codes immediately. This enables configurations with /// OTA triggers to compile for host platform during development. -class HostOTABackend final : public OTABackend { +class HostOTABackend final { public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } + OTAResponseTypes begin(size_t image_size); + void set_update_md5(const char *md5); + OTAResponseTypes write(uint8_t *data, size_t len); + OTAResponseTypes end(); + void abort(); + bool supports_compression() { return false; } }; +std::unique_ptr make_ota_backend(); + } // namespace esphome::ota #endif diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 4be162ccd32..95b166901ad 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -1,7 +1,7 @@ #include "ota_web_server.h" #ifdef USE_WEBSERVER_OTA -#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_factory.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -71,7 +71,7 @@ class OTARequestHandler : public AsyncWebHandler { bool ota_success_{false}; private: - std::unique_ptr ota_backend_{nullptr}; + ota::OTABackendPtr ota_backend_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { From 44d314d069503831849c5d0de8e7839898f7eddd Mon Sep 17 00:00:00 2001 From: Olivier ARCHER Date: Thu, 5 Mar 2026 20:22:37 +0100 Subject: [PATCH 19/24] [GPS] fix component Python declaration to match C++ implementation (#14519) --- esphome/components/gps/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index 045a5a6c847..ab48417a4e7 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -34,7 +34,7 @@ AUTO_LOAD = ["sensor"] CODEOWNERS = ["@coogle", "@ximex"] gps_ns = cg.esphome_ns.namespace("gps") -GPS = gps_ns.class_("GPS", cg.Component, uart.UARTDevice) +GPS = gps_ns.class_("GPS", cg.PollingComponent, uart.UARTDevice) GPSListener = gps_ns.class_("GPSListener") MULTI_CONF = True From 6f0460b0ee3b34469888ac249b18c5a269648d51 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:46:47 -0500 Subject: [PATCH 20/24] [sim800l][tormatic][tx20] Fix OOB access, div-by-zero, and off-by-one (#14512) Co-authored-by: J. Nick Koston --- esphome/components/sim800l/sim800l.cpp | 5 +++-- esphome/components/tormatic/tormatic_cover.cpp | 3 +++ esphome/components/tx20/tx20.cpp | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 2115c72cefa..913d920c94e 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -196,7 +196,8 @@ void Sim800LComponent::parse_cmd_(std::string message) { case STATE_CREG_WAIT: { // Response: "+CREG: 0,1" -- the one there means registered ok // "+CREG: -,-" means not registered ok - bool registered = message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5'); + bool registered = + message.size() > 9 && message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5'); if (registered) { if (!this->registered_) { ESP_LOGD(TAG, "Registered OK"); @@ -205,7 +206,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->expect_ack_ = true; } else { ESP_LOGW(TAG, "Registration Fail"); - if (message[7] == '0') { // Network registration is disable, enable it + if (message.size() > 7 && message[7] == '0') { // Network registration is disabled, enable it send_cmd_("AT+CREG=1"); this->expect_ack_ = true; this->state_ = STATE_SETUP_CMGF; diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index f567be0674f..37a269088e6 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -183,6 +183,9 @@ void Tormatic::recompute_position_() { duration = this->close_duration_; } + if (duration == 0) + return; + auto delta = direction * diff / duration; this->position = clamp(this->position + delta, COVER_CLOSED, COVER_OPEN); diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 6bc5f0bb514..3e0234fac04 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -191,7 +191,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) { arg->tx20_available = true; return; } - if (index <= MAX_BUFFER_SIZE) { + if (index < MAX_BUFFER_SIZE) { arg->buffer[index] = delay; } arg->spent_time += delay; From 05ddc85412c8baf985e76015ed750407faa3d4e0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:46:56 -0500 Subject: [PATCH 21/24] [rc522][sml][kamstrup_kmp] Fix buffer bounds checks (#14515) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 4 ++-- esphome/components/rc522/rc522.cpp | 1 + esphome/components/sml/sml_parser.cpp | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 00c65a19379..29de6512559 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -136,7 +136,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { int timeout = 250; // ms // Read the data from the UART - while (timeout > 0) { + while (timeout > 0 && buffer_len < static_cast(sizeof(buffer))) { if (this->available()) { data = this->read(); if (data > -1) { @@ -246,7 +246,7 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_ } void KamstrupKMPComponent::set_sensor_value_(uint16_t command, float value, uint8_t unit_idx) { - const char *unit = UNITS[unit_idx]; + const char *unit = unit_idx < sizeof(UNITS) / sizeof(UNITS[0]) ? UNITS[unit_idx] : ""; // Standard sensors if (command == CMD_HEAT_ENERGY && this->heat_energy_sensor_ != nullptr) { diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 91fae7fa345..c5f7ec2cd41 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -169,6 +169,7 @@ void RC522::loop() { default: ESP_LOGE(TAG, "uid_idx_ invalid, uid_idx_ = %d", uid_idx_); state_ = STATE_DONE; + return; } buffer_[1] = 32; pcd_transceive_data_(2); diff --git a/esphome/components/sml/sml_parser.cpp b/esphome/components/sml/sml_parser.cpp index 16e37949dc7..ed086e385d2 100644 --- a/esphome/components/sml/sml_parser.cpp +++ b/esphome/components/sml/sml_parser.cpp @@ -35,6 +35,8 @@ bool SmlFile::setup_node(SmlNode *node) { // Check if we need additional length bytes if (overlength) { + if (this->pos_ + 1 >= this->buffer_.size()) + return false; // Shift the current length to the higher nibble // and add the lower nibble of the next byte to the length length = (length << 4) + (this->buffer_[this->pos_ + 1] & 0x0f); From d6f3186b3d11d6d6aa330751ff634c0d1889e911 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:47:10 -0500 Subject: [PATCH 22/24] [haier][bedjet][vbus][lightwaverf] Fix buffer overflow bugs (#14493) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- esphome/components/bedjet/bedjet_codec.cpp | 28 +++++++++++++------ .../components/haier/smartair2_climate.cpp | 2 +- esphome/components/lightwaverf/LwTx.cpp | 4 +++ esphome/components/vbus/vbus.cpp | 4 ++- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/esphome/components/bedjet/bedjet_codec.cpp b/esphome/components/bedjet/bedjet_codec.cpp index 9a312e226c1..7a959390f31 100644 --- a/esphome/components/bedjet/bedjet_codec.cpp +++ b/esphome/components/bedjet/bedjet_codec.cpp @@ -1,4 +1,5 @@ #include "bedjet_codec.h" +#include #include #include @@ -68,6 +69,10 @@ BedjetPacket *BedjetCodec::get_set_runtime_remaining_request(const uint8_t hour, /** Decodes the extra bytes that were received after being notified with a partial packet. */ void BedjetCodec::decode_extra(const uint8_t *data, uint16_t length) { + if (length < 5) { + ESP_LOGVV(TAG, "Received extra: %d bytes (too short)", length); + return; + } ESP_LOGVV(TAG, "Received extra: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]); uint8_t offset = this->last_buffer_size_; if (offset > 0 && length + offset <= sizeof(BedjetStatusPacket)) { @@ -90,14 +95,19 @@ void BedjetCodec::decode_extra(const uint8_t *data, uint16_t length) { * @return `true` if the packet was decoded and represents a "partial" packet; `false` otherwise. */ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) { + if (length < 5) { + ESP_LOGW(TAG, "Received short packet: %d bytes", length); + return false; + } ESP_LOGV(TAG, "Received: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]); if (data[1] == PACKET_FORMAT_V3_HOME && data[3] == PACKET_TYPE_STATUS) { // Clear old buffer memset(&this->buf_, 0, sizeof(BedjetStatusPacket)); // Copy new data into buffer - memcpy(&this->buf_, data, length); - this->last_buffer_size_ = length; + size_t copy_len = std::min(static_cast(length), sizeof(BedjetStatusPacket)); + memcpy(&this->buf_, data, copy_len); + this->last_buffer_size_ = copy_len; // TODO: validate the packet checksum? if (this->buf_.mode < 7 && this->buf_.target_temp_step >= 38 && this->buf_.target_temp_step <= 86 && @@ -113,13 +123,15 @@ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) { } } else if (data[1] == PACKET_FORMAT_DEBUG || data[3] == PACKET_TYPE_DEBUG) { // We don't actually know the packet format for this. Dump packets to log, in case a pattern presents itself. - ESP_LOGVV(TAG, - "received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF; [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, " - "[12]=%d, [-1]=%d", - bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8], - data[9], data[10], data[11], data[12], data[length - 1]); + if (length >= 13) { + ESP_LOGVV(TAG, + "received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF; [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, " + "[12]=%d, [-1]=%d", + bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8], + data[9], data[10], data[11], data[12], data[length - 1]); + } - if (this->has_status()) { + if (this->has_status() && length >= 7) { this->status_packet_->ambient_temp_step = data[6]; } } else { diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index d24f8ad8498..e91224e2d8e 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -385,7 +385,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { } haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { - if (size < sizeof(smartair2_protocol::HaierStatus)) + if (size != sizeof(smartair2_protocol::HaierStatus)) return haier_protocol::HandlerError::WRONG_MESSAGE_STRUCTURE; smartair2_protocol::HaierStatus packet; memcpy(&packet, packet_buffer, size); diff --git a/esphome/components/lightwaverf/LwTx.cpp b/esphome/components/lightwaverf/LwTx.cpp index f5ef6ddb2c0..b69b93b978d 100644 --- a/esphome/components/lightwaverf/LwTx.cpp +++ b/esphome/components/lightwaverf/LwTx.cpp @@ -108,6 +108,10 @@ bool LwTx::lwtx_free() { return !this->tx_msg_active; } Send a LightwaveRF message (10 nibbles in bytes) **/ void LwTx::lwtx_send(const std::vector &msg) { + if (msg.size() < TX_MSGLEN) { + ESP_LOGW("lightwaverf.sensor", "Message too short: %zu < %u", msg.size(), static_cast(TX_MSGLEN)); + return; + } if (this->tx_translate) { for (uint8_t i = 0; i < TX_MSGLEN; i++) { this->tx_buf[i] = TX_NIBBLE[msg[i] & 0xF]; diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index b9496a08dec..8616da010d1 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -1,6 +1,7 @@ #include "vbus.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include namespace esphome { @@ -106,9 +107,10 @@ void VBus::loop() { continue; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)]; + size_t log_bytes = std::min(this->buffer_.size(), static_cast(VBUS_MAX_LOG_BYTES)); #endif ESP_LOGV(TAG, "P2 C%04x %04x->%04x: %s", this->command_, this->source_, this->dest_, - format_hex_to(hex_buf, this->buffer_.data(), this->buffer_.size())); + format_hex_to(hex_buf, this->buffer_.data(), log_bytes)); for (auto &listener : this->listeners_) listener->on_message(this->command_, this->source_, this->dest_, this->buffer_); this->state_ = 0; From 9961c8180aec582a856b455d35a2e0ae19e2d3e4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:47:32 -0500 Subject: [PATCH 23/24] [alpha3][mpu6886][emc2101] Fix copy-paste bugs (#14492) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- esphome/components/alpha3/alpha3.cpp | 2 +- esphome/components/emc2101/emc2101.cpp | 2 +- esphome/components/mpu6886/mpu6886.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index f22a8e24446..6e82ec047db 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -125,7 +125,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc this->current_sensor_->publish_state(NAN); if (this->speed_sensor_ != nullptr) this->speed_sensor_->publish_state(NAN); - if (this->speed_sensor_ != nullptr) + if (this->voltage_sensor_ != nullptr) this->voltage_sensor_->publish_state(NAN); break; } diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 7d85cd31cfd..068e25568f1 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -72,7 +72,7 @@ void Emc2101Component::setup() { config |= EMC2101_DAC_BIT; } if (this->inverted_) { - config |= EMC2101_POLARITY_BIT; + reg(EMC2101_REGISTER_FAN_CONFIG) |= EMC2101_POLARITY_BIT; } if (this->dac_mode_) { // DAC mode configurations diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index 68b77b59c99..02747da3064 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -80,7 +80,7 @@ void MPU6886Component::setup() { accel_config &= 0b11100111; accel_config |= (MPU6886_RANGE_2G << 3); ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); - if (!this->write_byte(MPU6886_REGISTER_GYRO_CONFIG, gyro_config)) { + if (!this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config)) { this->mark_failed(); return; } From 22d90d702d36fb7f91a6fbfc3d69dd554aa2cbe9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:47:55 -0500 Subject: [PATCH 24/24] [usb_cdc_acm][scd4x][pulse_counter][mopeka_std_check][ruuvi_ble] Fix assorted one-liner bugs (#14495) Co-authored-by: Claude Opus 4.6 --- .../components/mopeka_std_check/mopeka_std_check.cpp | 6 +++--- .../components/mopeka_std_check/mopeka_std_check.h | 2 +- .../components/pulse_counter/pulse_counter_sensor.cpp | 3 ++- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 11 +++++++---- esphome/components/scd4x/scd4x.cpp | 3 ++- esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 2 +- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 6322b550c94..88bd7b02fdb 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -108,7 +108,7 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } // Get temperature of sensor - uint8_t temp_in_c = this->parse_temperature_(mopeka_data); + int8_t temp_in_c = this->parse_temperature_(mopeka_data); if (this->temperature_ != nullptr) { this->temperature_->publish_state(temp_in_c); } @@ -223,12 +223,12 @@ uint8_t MopekaStdCheck::parse_battery_level_(const mopeka_std_package *message) return (uint8_t) percent; } -uint8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { +int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { uint8_t tmp = message->raw_temp; if (tmp == 0x0) { return -40; } else { - return (uint8_t) ((tmp - 25.0f) * 1.776964f); + return static_cast((tmp - 25.0f) * 1.776964f); } } diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index 897b5414ed0..45588988c53 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -71,7 +71,7 @@ class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi float get_lpg_speed_of_sound_(float temperature); uint8_t parse_battery_level_(const mopeka_std_package *message); - uint8_t parse_temperature_(const mopeka_std_package *message); + int8_t parse_temperature_(const mopeka_std_package *message); }; } // namespace mopeka_std_check diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index 5e62c0a4107..ec00bd024e0 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -175,7 +175,8 @@ void PulseCounterSensor::setup() { void PulseCounterSensor::set_total_pulses(uint32_t pulses) { this->current_total_ = pulses; - this->total_sensor_->publish_state(pulses); + if (this->total_sensor_ != nullptr) + this->total_sensor_->publish_state(pulses); } void PulseCounterSensor::dump_config() { diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index 1b126bdef0e..bf088873ce0 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -63,10 +63,13 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP result.acceleration_x = data[6] == 0xFF && data[7] == 0xFF ? NAN : acceleration_x; result.acceleration_y = data[8] == 0xFF && data[9] == 0xFF ? NAN : acceleration_y; result.acceleration_z = data[10] == 0xFF && data[11] == 0xFF ? NAN : acceleration_z; - result.acceleration = result.acceleration_x == NAN || result.acceleration_y == NAN || result.acceleration_z == NAN - ? NAN - : sqrtf(acceleration_x * acceleration_x + acceleration_y * acceleration_y + - acceleration_z * acceleration_z); + if ((data[6] != 0xFF || data[7] != 0xFF) && (data[8] != 0xFF || data[9] != 0xFF) && + (data[10] != 0xFF || data[11] != 0xFF)) { + result.acceleration = + sqrtf(acceleration_x * acceleration_x + acceleration_y * acceleration_y + acceleration_z * acceleration_z); + } else { + result.acceleration = NAN; + } result.battery_voltage = (power_info >> 5) == 0x7FF ? NAN : battery_voltage; result.tx_power = (power_info & 0x1F) == 0x1F ? NAN : tx_power; result.movement_counter = movement_counter; diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index a265386cc2f..0c108fba9d6 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -307,7 +307,7 @@ bool SCD4XComponent::start_measurement_() { break; } - static uint8_t remaining_retries = 3; + uint8_t remaining_retries = 3; while (remaining_retries) { if (!this->write_command(measurement_command)) { ESP_LOGE(TAG, "Error starting measurements"); @@ -316,6 +316,7 @@ bool SCD4XComponent::start_measurement_() { if (--remaining_retries == 0) return false; delay(50); // NOLINT wait 50 ms and try again + continue; } this->status_clear_warning(); return true; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index d33fb80f781..44de986f9a3 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -161,7 +161,7 @@ void USBCDCACMInstance::setup() { // Create a simple, unique task name per interface char task_name[] = "usb_tx_0"; - task_name[sizeof(task_name) - 1] = format_hex_char(static_cast(this->itf_)); + task_name[sizeof(task_name) - 2] = format_hex_char(static_cast(this->itf_)); xTaskCreate(usb_tx_task_fn, task_name, stack_size, this, 4, &this->usb_tx_task_handle_); if (this->usb_tx_task_handle_ == nullptr) {