Compare commits

..
21 changed files with 357 additions and 406 deletions

No files matched your search

+4 -3
View File
@@ -216,9 +216,10 @@ file does, and it is the authority when they disagree. The most useful starting
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; I2C has `update_register`,
`write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`.
`if (!this->status_comm_result(ok)) return;` sets or clears the warning. A child sensor that skips repeated
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
@@ -1,6 +1,5 @@
#include "ds248x_one_wire_bus.h"
#include "ds248x.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ds248x {
@@ -152,7 +151,11 @@ uint64_t DS248xOneWireBus::search_int() {
}
// Update address based on direction taken
assign_bits(address, bit_mask, dir_taken);
if (dir_taken) {
address |= bit_mask;
} else {
address &= ~bit_mask;
}
}
// Search successful
+6 -3
View File
@@ -1,7 +1,6 @@
#include "htu21d.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome::htu21d {
@@ -113,7 +112,11 @@ void HTU21DComponent::set_heater(bool status) {
this->status_set_warning();
return;
}
assign_bits(raw_heater, 1 << HTU21D_REG_HTRE_BIT, status);
if (status) {
raw_heater |= (1 << HTU21D_REG_HTRE_BIT);
} else {
raw_heater &= ~(1 << HTU21D_REG_HTRE_BIT);
}
if (this->write_register(HTU21D_WRITERHT_REG_CMD, &raw_heater, 1) != i2c::ERROR_OK) {
this->status_set_warning();
return;
+7 -2
View File
@@ -1,7 +1,6 @@
#pragma once
#include "light_color_values.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -230,7 +229,13 @@ class LightCall {
inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; }
// Helper to set flag - defaults to true for common case
void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { assign_bits(this->flags_, flag, value); }
void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE {
if (value) {
this->flags_ |= flag;
} else {
this->flags_ &= ~flag;
}
}
// Helper to clear flag - reduces code size for common case
void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; }
@@ -1,7 +1,6 @@
#include "media_player.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -78,12 +77,19 @@ const char *media_player_command_to_string(MediaPlayerCommand command) {
}
void MediaPlayerTraits::set_supports_pause(bool supports_pause) {
assign_bits(this->feature_flags_, MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY, supports_pause);
if (supports_pause) {
this->feature_flags_ |= MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY;
} else {
this->feature_flags_ &= ~(MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY);
}
}
void MediaPlayerTraits::set_supports_turn_off_on(bool supports_turn_off_on) {
assign_bits(this->feature_flags_, MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON,
supports_turn_off_on);
if (supports_turn_off_on) {
this->feature_flags_ |= MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON;
} else {
this->feature_flags_ &= ~(MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON);
}
}
void MediaPlayerCall::validate_() {
+5 -2
View File
@@ -3,7 +3,6 @@
#include <cstdint>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::mpr121 {
@@ -85,7 +84,11 @@ bool MPR121Component::digital_read(uint8_t pin) { return (this->gpio_input_ & (1
void MPR121Component::digital_write(uint8_t pin, bool value) {
const uint8_t ionum = pin - 4;
assign_bits(this->gpio_output_, 1 << ionum, value);
if (value) {
this->gpio_output_ |= (1 << ionum);
} else {
this->gpio_output_ &= ~(1 << ionum);
}
this->flush_gpio_();
}
@@ -46,7 +46,11 @@ class ProvisioningManager : public Component {
void set_source_provisioned(uint8_t source, bool provisioned) {
if (source >= MAX_SOURCES)
return;
assign_bits(this->provisioned_mask_, 1UL << source, provisioned);
if (provisioned) {
this->provisioned_mask_ |= (1UL << source);
} else {
this->provisioned_mask_ &= ~(1UL << source);
}
}
// True once every registered source is provisioned. Config validation guarantees
@@ -62,7 +62,11 @@ int BSDSocketImpl::setblocking(bool blocking) {
if (fl < 0) {
return fl;
}
assign_bits(fl, O_NONBLOCK, !blocking);
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
return ::fcntl(this->fd_, F_SETFL, fl);
}
@@ -52,7 +52,11 @@ int LwIPSocketImpl::setblocking(bool blocking) {
if (fl < 0) {
return fl;
}
assign_bits(fl, O_NONBLOCK, !blocking);
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
return lwip_fcntl(this->fd_, F_SETFL, fl);
}
@@ -162,7 +162,11 @@ void HostUartComponent::setup() {
break;
};
// Set stop bits
assign_bits(options.c_cflag, CSTOPB, this->stop_bits_ == 2);
if (this->stop_bits_ == 2) {
options.c_cflag |= CSTOPB;
} else {
options.c_cflag &= ~CSTOPB;
}
cfsetispeed(&options, baud);
cfsetospeed(&options, baud);
tcsetattr(this->file_descriptor_, TCSANOW, &options);
+5 -3
View File
@@ -1,4 +1,3 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "ufire_ec.h"
#include <cmath>
@@ -20,8 +19,11 @@ void UFireECComponent::setup() {
// Write option for temperature adjustments
uint8_t config;
this->read_byte(REGISTER_CONFIG, &config);
assign_bits(config, CONFIG_TEMP_COMPENSATION,
this->temperature_sensor_ != nullptr || this->temperature_sensor_external_ != nullptr);
if (this->temperature_sensor_ == nullptr && this->temperature_sensor_external_ == nullptr) {
config &= ~CONFIG_TEMP_COMPENSATION;
} else {
config |= CONFIG_TEMP_COMPENSATION;
}
this->write_byte(REGISTER_CONFIG, config);
// Update temperature compensation
+5 -3
View File
@@ -1,4 +1,3 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "ufire_ise.h"
@@ -21,8 +20,11 @@ void UFireISEComponent::setup() {
// Write option for temperature adjustments
uint8_t config;
this->read_byte(REGISTER_CONFIG, &config);
assign_bits(config, CONFIG_TEMP_COMPENSATION,
this->temperature_sensor_ != nullptr || this->temperature_sensor_external_ != nullptr);
if (this->temperature_sensor_ == nullptr && this->temperature_sensor_external_ == nullptr) {
config &= ~CONFIG_TEMP_COMPENSATION;
} else {
config |= CONFIG_TEMP_COMPENSATION;
}
this->write_byte(REGISTER_CONFIG, config);
}
@@ -5,7 +5,6 @@
#include "esphome/core/application.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/progmem.h"
#include "esphome/core/helpers.h"
#include <cmath>
#include <cstring>
@@ -60,13 +59,21 @@ WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature)
}
WaterHeaterCall &WaterHeaterCall::set_away(bool away) {
assign_bits(this->state_, WATER_HEATER_STATE_AWAY, away);
if (away) {
this->state_ |= WATER_HEATER_STATE_AWAY;
} else {
this->state_ &= ~WATER_HEATER_STATE_AWAY;
}
this->state_mask_ |= WATER_HEATER_STATE_AWAY;
return *this;
}
WaterHeaterCall &WaterHeaterCall::set_on(bool on) {
assign_bits(this->state_, WATER_HEATER_STATE_ON, on);
if (on) {
this->state_ |= WATER_HEATER_STATE_ON;
} else {
this->state_ &= ~WATER_HEATER_STATE_ON;
}
this->state_mask_ |= WATER_HEATER_STATE_ON;
return *this;
}
@@ -252,7 +252,13 @@ class WaterHeater : public EntityBase {
/// Set the state flags. Should only be called from control().
void set_state_(uint32_t state) { this->state_ = state; }
/// Set or clear a state flag. Should only be called from control().
void set_state_flag_(uint32_t flag, bool value) { assign_bits(this->state_, flag, value); }
void set_state_flag_(uint32_t flag, bool value) {
if (value) {
this->state_ |= flag;
} else {
this->state_ &= ~flag;
}
}
WaterHeaterMode mode_{WATER_HEATER_MODE_OFF};
float current_temperature_{NAN};
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <type_traits>
namespace esphome {
/// Collects fixed length binary frames of N bytes that start with the header bytes HEADER...; the binary twin of
/// LineReader. Bytes before a header are skipped; checksums are left to the caller.
template<size_t N, uint8_t... HEADER> class FrameReader {
using pos_t = std::conditional_t<(N <= 0xFF), uint8_t, uint16_t>;
static constexpr size_t HEADER_LEN = sizeof...(HEADER);
static_assert(HEADER_LEN >= 1 && HEADER_LEN <= 8, "FrameReader takes 1 to 8 header bytes"); // bounds replay_() depth
static_assert(HEADER_LEN < N && N <= 0xFFFF, "FrameReader size out of range");
public:
/// Feed one byte; returns the completed frame, header included, valid until the next call, or nullptr.
const uint8_t *feed(uint8_t c) {
pos_t pos = this->pos_;
if (pos < HEADER_LEN && c != header_byte(pos)) {
// A later byte of the broken header, or c, may start the real one
this->buf_[pos] = c;
this->replay_(pos);
return nullptr;
}
this->buf_[pos++] = c;
if (pos < N) {
this->pos_ = pos;
return nullptr;
}
this->pos_ = 0;
return this->buf_;
}
/// Number of bytes of the partial frame; 0 after a frame is returned and while looking for a header.
size_t length() const { return this->pos_; }
/// Forget the partial frame and look for a header from the next byte.
void reset() { this->pos_ = 0; }
/// For a frame the caller rejected (bad checksum): drop its first byte and realign on a header in the rest.
/// Call only right after feed() returned that frame; use reset() otherwise.
void resync() { this->replay_(N - 1); }
protected:
// A fold, not an array, so no header table lands in RAM on ESP8266
static constexpr uint8_t header_byte(size_t i) {
size_t j = 0;
uint8_t r = 0;
((r = (j++ == i) ? HEADER : r), ...);
return r;
}
// Start over and feed buf_[1..last] again; never completes a frame as last < N
void replay_(size_t last) {
this->pos_ = 0;
for (size_t i = 1; i <= last; i++) {
this->feed(this->buf_[i]);
}
}
uint8_t buf_[N];
pos_t pos_{0};
};
} // namespace esphome
+143
View File
@@ -0,0 +1,143 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/core/frame_reader.h"
namespace esphome::core::testing {
using Bytes = std::vector<uint8_t>;
using Frames = std::vector<Bytes>;
// Feeds bytes and collects every completed frame.
template<size_t N, uint8_t... H> static Frames feed_all(FrameReader<N, H...> &reader, const Bytes &input) {
Frames frames;
for (uint8_t c : input) {
const uint8_t *frame = reader.feed(c);
if (frame != nullptr) {
EXPECT_EQ(reader.length(), 0u);
frames.emplace_back(frame, frame + N);
}
}
return frames;
}
static_assert(sizeof(FrameReader<4, 0xFF>) == 5);
static_assert(sizeof(FrameReader<9, 0x55, 0x01, 0x01, 0x04>) == 10);
static_assert(sizeof(FrameReader<0xFF, 0xAA>) == 0x100);
static_assert(sizeof(FrameReader<0x100, 0xAA>) == 0x102);
TEST(FrameReaderTest, SingleByteHeader) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0x01, 0x02, 0x02, 0xFF, 0x03, 0x04, 0x06}),
(Frames{{0xFF, 0x01, 0x02, 0x02}, {0xFF, 0x03, 0x04, 0x06}}));
}
TEST(FrameReaderTest, SkipsBytesBeforeHeader) {
FrameReader<3, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0x01, 0x02, 0xFF, 0x10, 0x20}), (Frames{{0xFF, 0x10, 0x20}}));
}
TEST(FrameReaderTest, HeaderByteInsidePayloadIsData) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0xFF, 0xFF, 0x00}), (Frames{{0xFF, 0xFF, 0xFF, 0x00}}));
}
TEST(FrameReaderTest, LengthTracksPartialFrame) {
FrameReader<4, 0xAA, 0xBB> reader;
EXPECT_EQ(reader.length(), 0u);
reader.feed(0x00);
EXPECT_EQ(reader.length(), 0u);
reader.feed(0xAA);
EXPECT_EQ(reader.length(), 1u);
reader.feed(0xBB);
reader.feed(0x01);
EXPECT_EQ(reader.length(), 3u);
reader.reset();
EXPECT_EQ(reader.length(), 0u);
EXPECT_EQ(feed_all(reader, {0x02, 0xAA, 0xBB, 0x03, 0x04}), (Frames{{0xAA, 0xBB, 0x03, 0x04}}));
}
TEST(FrameReaderTest, MultiByteHeaderMismatchRestarts) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x02, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, MismatchedByteCanStartHeader) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
// The second 0x55 breaks the first header and starts the real one
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, RepeatedHeaderPrefixIsKept) {
// AA AA AA BB: the mismatch at the third byte still leaves AA AA matched
FrameReader<4, 0xAA, 0xAA, 0xBB> reader;
EXPECT_EQ(feed_all(reader, {0xAA, 0xAA, 0xAA, 0xBB, 0x01}), (Frames{{0xAA, 0xAA, 0xBB, 0x01}}));
}
TEST(FrameReaderTest, OverlappingHeaderPrefix) {
// A1 B2 A1 B2: the mismatch on the last B2 keeps the trailing A1 B2 as the start of the header
FrameReader<5, 0xA1, 0xB2, 0xA1, 0xC3> reader;
EXPECT_EQ(feed_all(reader, {0xA1, 0xB2, 0xA1, 0xB2, 0xA1, 0xC3, 0x07}), (Frames{{0xA1, 0xB2, 0xA1, 0xC3, 0x07}}));
}
TEST(FrameReaderTest, ResyncFindsHeaderInRejectedFrame) {
FrameReader<4, 0xFF> reader;
// Started mid-frame: the 0xFF taken as a header was payload; the real frame starts at the second 0xFF
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x12, 0xFF, 0x01}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 2u);
EXPECT_EQ(feed_all(reader, {0x02, 0x02}), (Frames{{0xFF, 0x01, 0x02, 0x02}}));
}
TEST(FrameReaderTest, ResyncWithoutHeaderStartsOver) {
FrameReader<4, 0xFF> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x01, 0x02, 0x03}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 0u);
}
TEST(FrameReaderTest, ResyncKeepsPartialHeaderAtEnd) {
FrameReader<5, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x00, 0x00, 0x3C}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 1u);
EXPECT_EQ(feed_all(reader, {0x32, 0x01, 0x02, 0x03}), (Frames{{0x3C, 0x32, 0x01, 0x02, 0x03}}));
}
TEST(FrameReaderTest, ResyncMultiByteHeader) {
FrameReader<6, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x3C, 0x3C, 0x32, 0x09}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 3u);
EXPECT_EQ(feed_all(reader, {0x0A, 0x0B, 0x0C}), (Frames{{0x3C, 0x32, 0x09, 0x0A, 0x0B, 0x0C}}));
}
TEST(FrameReaderTest, SixteenBitIndex) {
FrameReader<300, 0xAA, 0x55> reader;
Bytes input{0x00, 0xAA, 0x55};
input.resize(input.size() + 298, 0x42);
Frames frames = feed_all(reader, input);
ASSERT_EQ(frames.size(), 1u);
EXPECT_EQ(frames[0][0], 0xAA);
EXPECT_EQ(frames[0][1], 0x55);
EXPECT_EQ(frames[0][299], 0x42);
}
} // namespace esphome::core::testing
+59 -55
View File
@@ -58,30 +58,8 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
- Provides debug logging to track state reception (use `--log-cli-level=DEBUG`)
- Safe for concurrent use with multiple entity types
#### Subscribing after the initial states (`state_utils.py`)
`subscribe_after_initial_states()` does the `InitialStateHelper` setup in one call: it subscribes, waits
for every entity's connect time state, and fails the test with the entities still missing on timeout.
It returns a `StateSubscription` with `initial_states` (by key) and a `waiter`, a `StateWaiter` that sees
every later state. `waiter.next_state(key=..., state_type=...)` arms a wait for the next matching state;
call it before sending the command. `press_and_wait()` presses a button and returns the next state of a
type, from one entity when `key=` is given:
```python
from .state_utils import press_and_wait, subscribe_after_initial_states
entities, _ = await client.list_entities_services()
sub = await subscribe_after_initial_states(client, entities)
state = await press_and_wait(client, entities, sub.waiter, "set_position", CoverState)
assert state.position == pytest.approx(0.5, abs=0.01)
next_light = sub.waiter.next_state(key=light.key, state_type=LightState)
client.light_command(key=light.key, state=True)
assert (await next_light).state is True
```
Pass `on_state=` as well when the test also needs to collect every state.
**Future work:**
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
#### Entity Lookup Helpers (`state_utils.py`)
@@ -225,24 +203,46 @@ async def test_my_sensor(
##### State Subscription Pattern
**Recommended: Using subscribe_after_initial_states**
**Recommended: Using InitialStateHelper**
When an API client connects, ESPHome automatically sends the current state of all entities.
`subscribe_after_initial_states()` (from `state_utils.py`) swallows these initial states and only forwards
subsequent state changes, to your callback and to the returned `waiter`:
When an API client connects, ESPHome automatically sends the current state of all entities. The `InitialStateHelper` (from `state_utils.py`) handles this by swallowing these initial states and only forwarding subsequent state changes to your test callback:
```python
from .state_utils import subscribe_after_initial_states
from .state_utils import InitialStateHelper
# Track state changes with futures
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""This callback only receives NEW state changes, not initial states."""
states[state.key] = state
# Check for specific condition using isinstance
if isinstance(state, SensorState) and state.state == expected_value:
if not state_future.done():
state_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
sub = await subscribe_after_initial_states(client, entities)
initial_state_helper = InitialStateHelper(entities)
# Arm the wait before the action that produces the state
expected = sub.waiter.expect(
lambda s: isinstance(s, SensorState) and s.state == expected_value
)
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast; on timeout this fails the test
# and lists the entities that never sent one
await initial_state_helper.wait_for_initial_states()
# Now perform your test actions - on_state will only receive new changes
# ... trigger state changes ...
result = await expected
# Wait for expected state; on timeout the test fails and shows the states received
result = await wait_or_fail(
state_future, 5.0, "Expected state not received", states=states
)
```
**Legacy: Manual State Tracking**
@@ -386,33 +386,37 @@ async with run_compiled(yaml_config, line_callback=on_log_line):
# Test implementation
```
To wait for log lines, use `LineWaiter` (`log_utils.py`) instead of a hand written
callback with one future per pattern:
Example using futures for specific log patterns:
```python
import re
from .log_utils import LineWaiter
loop = asyncio.get_running_loop()
connected_future = loop.create_future()
service_future = loop.create_future()
log = LineWaiter()
# Arm expectations before the action that logs them; any number may be pending.
connected = log.expect(re.compile(r"Client .* connected from"))
value = log.expect(re.compile(r"value=(\d+)"))
# Patterns to match
connected_pattern = re.compile(r"Client .* connected from")
service_pattern = re.compile(r"Service called")
async with run_compiled(yaml_config, line_callback=log.callback):
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
connected_future.set_result(True)
elif not service_future.done() and service_pattern.search(line):
service_future.set_result(True)
async with run_compiled(yaml_config, line_callback=check_output):
async with api_client_connected() as client:
await log.wait(connected)
match = await log.wait(value, timeout=5.0, msg="no value logged")
assert match.group(1) == "42"
assert log.count("Service called") == 1
```
# Wait for specific log message
await asyncio.wait_for(connected_future, timeout=5.0)
- `expect(pattern)` only matches lines that arrive after the call. A `str` matches as a
substring and resolves to the line; a compiled regex is searched and resolves to its match.
Each expectation resolves on the next matching line, so two armed for the same text both
resolve on its first occurrence: arm the second after the first resolves, or use `count`.
- `wait(future)` fails the test on timeout with the pattern and the last 20 log lines.
- `wait_for(*needles)` also matches lines seen before the call, but only one may be pending.
- `count(pattern)` counts the lines seen so far.
# Do test actions...
# Wait for service log
await asyncio.wait_for(service_future, timeout=5.0)
```
**Note**: Tests that monitor log messages typically have fewer race conditions compared to state-based testing, making them more reliable. However, be aware that the host platform currently does not have a thread-safe logger, so logging from threads will not work correctly.
+3 -62
View File
@@ -3,47 +3,23 @@
from __future__ import annotations
import asyncio
import re
from typing import Any, overload
from .wait_utils import wait_or_fail
type LinePattern = str | re.Pattern[str]
type LineMatch = str | re.Match[str]
def _match(pattern: LinePattern, line: str) -> LineMatch | None:
"""Return ``line`` if a str pattern is in it, or the search match of a regex."""
if isinstance(pattern, str):
return line if pattern in line else None
return pattern.search(line)
class LineWaiter:
"""Collects log lines and lets a test await lines that match.
"""Collects log lines and lets a test await one containing all needles.
Pass ``callback`` as ``run_compiled``'s ``line_callback``; the callback runs
on the test's own event loop, so futures are resolved directly. ``wait_for``
also matches lines seen before the call, and only one may be outstanding at a
time. ``expect`` matches only later lines, and any number may be armed at once;
each resolves on the next matching line, so two armed for the same text both
resolve on its first occurrence. Arm the second after the first, or use ``count``.
on the test's own event loop, so futures are resolved directly. Only one
``wait_for`` may be outstanding at a time (tests await sequentially).
"""
def __init__(self) -> None:
self.lines: list[str] = []
self._needles: tuple[str, ...] = ()
self._future: asyncio.Future | None = None
self._expectations: dict[asyncio.Future[Any], LinePattern] = {}
def callback(self, line: str) -> None:
self.lines.append(line)
for future, pattern in list(self._expectations.items()):
if future.done():
del self._expectations[future]
elif (match := _match(pattern, line)) is not None:
future.set_result(match)
del self._expectations[future]
if (
self._future is not None
and not self._future.done()
@@ -70,38 +46,3 @@ class LineWaiter:
finally:
self._future = None
self._needles = ()
@overload
def expect(self, pattern: str) -> asyncio.Future[str]: ... # noqa: E704
@overload
def expect(self, pattern: re.Pattern[str]) -> asyncio.Future[re.Match[str]]: ... # noqa: E704
def expect(self, pattern: LinePattern) -> asyncio.Future[LineMatch]:
"""Arm a wait for the next line matching ``pattern``; earlier lines never match.
A str matches as a substring and resolves to the line; a compiled regex is
searched and resolves to its match. Arm it before the action that logs the line.
"""
future: asyncio.Future[LineMatch] = asyncio.get_running_loop().create_future()
self._expectations[future] = pattern
return future
async def wait[T: str | re.Match[str]](
self,
future: asyncio.Future[T],
timeout: float = 5.0,
msg: str = "log line not seen",
) -> T:
"""Await a future from ``expect``; on timeout fail with its pattern and the last lines."""
return await wait_or_fail(
future,
timeout,
msg,
pattern=self._expectations.get(future),
lines=self.lines,
)
def count(self, pattern: LinePattern) -> int:
"""Return how many lines seen so far match ``pattern``."""
return sum(_match(pattern, line) is not None for line in self.lines)
-69
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import TypeVar
@@ -101,22 +100,6 @@ class StateWaiter:
self._waiters.append(entry)
return self._wait(entry, timeout, label)
def next_state[T: EntityState](
self,
key: int | None = None,
state_type: type[T] = EntityState,
timeout: float = 5.0,
) -> Awaitable[T]:
"""Arm a wait for the next ``state_type`` state, for entity ``key`` if given.
Like :meth:`expect`, call it before sending the command and await the result afterwards.
"""
return self.expect(
lambda s: isinstance(s, state_type) and (key is None or s.key == key),
timeout,
state_type.__name__ if key is None else f"{state_type.__name__} key={key}",
)
async def _wait(
self,
entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]],
@@ -419,58 +402,6 @@ class InitialStateHelper:
)
@dataclass(frozen=True, slots=True)
class StateSubscription:
"""A state subscription that is past the connect time states."""
initial_states: dict[int, EntityState]
waiter: StateWaiter
async def subscribe_after_initial_states(
client: APIClient,
entities: list[EntityInfo],
on_state: Callable[[EntityState], None] | None = None,
timeout: float = 5.0,
) -> StateSubscription:
"""Subscribe to states and wait until every entity has sent its connect time state.
Later states go to ``on_state`` (if given) and to the returned ``waiter``. The connect time
states are kept in ``initial_states`` by key. Fails the test listing the entities still missing.
"""
helper = InitialStateHelper(entities)
waiter = StateWaiter()
def forward(state: EntityState) -> None:
waiter.on_state(state)
if on_state is not None:
on_state(state)
client.subscribe_states(helper.on_state_wrapper(forward))
await helper.wait_for_initial_states(timeout)
return StateSubscription(helper.initial_states, waiter)
async def press_and_wait[T: EntityState](
client: APIClient,
entities: list[EntityInfo],
waiter: StateWaiter,
button: str,
state_type: type[T],
*,
key: int | None = None,
timeout: float = 5.0,
) -> T:
"""Press the button whose object_id contains ``button`` and return the next ``state_type`` state.
Pass the entity's ``key`` when the fixture has more than one entity of that type.
"""
btn = require_entity(entities, button, ButtonInfo)
next_state = waiter.next_state(key=key, state_type=state_type, timeout=timeout)
client.button_command(btn.key)
return await next_state
class SensorStateCollector:
"""Collects sensor, binary sensor, and text sensor state updates with wait helpers.
@@ -1,81 +0,0 @@
"""Tests for the integration test LineWaiter."""
from __future__ import annotations
import asyncio
import re
import pytest
from tests.integration.log_utils import LineWaiter
@pytest.mark.asyncio
async def test_expect_matches_only_later_lines() -> None:
waiter = LineWaiter()
waiter.callback("[I][app]: ready")
ready = waiter.expect("ready")
value = waiter.expect(re.compile(r"value=(\d+)"))
assert not ready.done()
waiter.callback("[D][x]: value=42")
waiter.callback("[I][app]: ready again")
assert await waiter.wait(ready) == "[I][app]: ready again"
match = await waiter.wait(value)
assert isinstance(match, re.Match)
assert match.group(1) == "42"
# wait_for still matches lines seen before the call.
assert await waiter.wait_for("app", "ready") == "[I][app]: ready"
@pytest.mark.asyncio
async def test_wait_timeout_reports_pattern_and_lines() -> None:
waiter = LineWaiter()
for i in range(30):
waiter.callback(f"line {i}")
future = waiter.expect("never")
with pytest.raises(pytest.fail.Exception) as err:
await waiter.wait(future, 0.01, "boot message missing")
message = str(err.value)
assert message.startswith("boot message missing (after 0.01s)")
assert "pattern: 'never'" in message
assert "lines (last 20 of 30):" in message
assert message.endswith("line 29")
assert future.cancelled()
# The cancelled expectation is dropped on the next line.
waiter.callback("never")
assert not waiter._expectations
def test_count() -> None:
waiter = LineWaiter()
for line in ("tick 1", "tock", "tick 2"):
waiter.callback(line)
assert waiter.count("tick") == 2
assert waiter.count(re.compile(r"t\w+k$")) == 1
assert waiter.count("missing") == 0
@pytest.mark.asyncio
async def test_expect_resolves_from_callback_while_waiting() -> None:
waiter = LineWaiter()
first = waiter.expect("first")
second = waiter.expect("second")
loop = asyncio.get_running_loop()
loop.call_soon(waiter.callback, "second")
loop.call_soon(waiter.callback, "first")
assert await waiter.wait(first) == "first"
assert await waiter.wait(second) == "second"
@pytest.mark.asyncio
async def test_line_waiter_expectations_share_a_matching_line() -> None:
waiter = LineWaiter()
first = waiter.expect("tick")
second = waiter.expect("tick")
waiter.callback("tick 1")
assert first.result() == second.result() == "tick 1"
third = waiter.expect("tick")
waiter.callback("tick 2")
assert third.result() == "tick 2"
assert waiter.count("tick") == 2
@@ -1,108 +0,0 @@
"""Tests for the state subscription helpers in tests/integration/state_utils.py."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from aioesphomeapi import ButtonInfo, EntityState, SensorInfo, SensorState, SwitchState
import pytest
from tests.integration.state_utils import (
StateSubscription,
StateWaiter,
press_and_wait,
subscribe_after_initial_states,
)
ENTITIES = [
SensorInfo(object_id="temp", key=1),
SensorInfo(object_id="humidity", key=2),
ButtonInfo(object_id="set_temp", key=3),
]
class FakeClient:
"""Records the state callback and button presses."""
def __init__(self) -> None:
self.callback: Callable[[EntityState], None] | None = None
self.pressed: list[int] = []
def subscribe_states(self, callback: Callable[[EntityState], None]) -> None:
self.callback = callback
def button_command(self, key: int) -> None:
self.pressed.append(key)
self.callback(SensorState(key=1, state=42.0))
async def _subscribe(client: FakeClient, **kwargs: object) -> StateSubscription:
task = asyncio.create_task(
subscribe_after_initial_states(client, ENTITIES, **kwargs)
)
await asyncio.sleep(0)
client.callback(SensorState(key=1, state=1.0))
client.callback(SensorState(key=2, state=2.0))
return await task
@pytest.mark.asyncio
async def test_subscribe_swallows_initial_states_and_forwards_later_ones() -> None:
client = FakeClient()
received: list[EntityState] = []
sub = await _subscribe(client, on_state=received.append)
assert sub.initial_states[1].state == 1.0
assert sub.initial_states[2].state == 2.0
assert received == []
next_state = sub.waiter.next_state(key=2, state_type=SensorState)
client.callback(SensorState(key=1, state=3.0))
client.callback(SensorState(key=2, state=4.0))
assert (await next_state).state == 4.0
assert [s.state for s in received] == [3.0, 4.0]
@pytest.mark.asyncio
async def test_subscribe_timeout_lists_missing_entities() -> None:
client = FakeClient()
task = asyncio.create_task(
subscribe_after_initial_states(client, ENTITIES, timeout=0.01)
)
await asyncio.sleep(0)
client.callback(SensorState(key=1, state=1.0))
with pytest.raises(pytest.fail.Exception, match=r"still_waiting[\s\S]*humidity"):
await task
@pytest.mark.asyncio
async def test_next_state_filters_by_type_and_times_out() -> None:
waiter = StateWaiter()
next_switch = waiter.next_state(state_type=SwitchState)
waiter.on_state(SensorState(key=1, state=1.0))
waiter.on_state(SwitchState(key=5, state=True))
assert (await next_switch).key == 5
with pytest.raises(TimeoutError, match="SwitchState key=9"):
await waiter.next_state(key=9, state_type=SwitchState, timeout=0.01)
@pytest.mark.asyncio
async def test_press_and_wait_presses_button_and_returns_state() -> None:
client = FakeClient()
sub = await _subscribe(client)
state = await press_and_wait(client, ENTITIES, sub.waiter, "set_temp", SensorState)
assert client.pressed == [3]
assert state.state == 42.0
@pytest.mark.asyncio
async def test_press_and_wait_waits_for_the_given_key() -> None:
client = FakeClient()
sub = await _subscribe(client)
pending = asyncio.ensure_future(
press_and_wait(client, ENTITIES, sub.waiter, "set_temp", SensorState, key=2)
)
await asyncio.sleep(0)
assert not pending.done() # the press published key 1
client.callback(SensorState(key=2, state=55.0))
assert (await pending).state == 55.0