From f979ddcf0de00b868f7c1556bd366820ccbb4608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 9a27cffd043..4e9b1f9c5fd 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines)