mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
Merge branch 'esp8266-native-build-surgery' into esp8266-native-toolchain-plumbing
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
|
||||
"""Config validation for custom_pdu and the deprecated custom_command alias.
|
||||
|
||||
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
|
||||
The old key is still accepted at the schema level and auto-migrated later in final validate (which a
|
||||
bare-schema test can't reach), so these tests only cover what the schema itself enforces: the two keys
|
||||
are mutually exclusive, and custom_pdu takes byte-sized values.
|
||||
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
|
||||
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
|
||||
test cannot: a write-coded custom_pdu polled continuously is rejected there.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller import ModbusItemBaseSchema
|
||||
from esphome.components.modbus_controller import (
|
||||
ModbusItemBaseSchema,
|
||||
validate_custom_pdu_item,
|
||||
)
|
||||
from esphome.components.modbus_controller.const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
CONF_CUSTOM_PDU,
|
||||
CONF_MODBUS_CONTROLLER_ID,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
|
||||
from esphome.core import ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
def test_custom_command_accepted_at_schema_level() -> None:
|
||||
@@ -45,3 +53,48 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
|
||||
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
|
||||
|
||||
|
||||
def _controller_full_config(*, continuous: bool) -> Config:
|
||||
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
|
||||
final-validate to resolve the controller (and its continuous flag) from an item's
|
||||
modbus_controller_id."""
|
||||
ctl_id = ID("ctl", is_declaration=True)
|
||||
config = Config()
|
||||
config["modbus_controller"] = [
|
||||
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
|
||||
]
|
||||
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_full_config():
|
||||
token = fv.full_config.set(Config())
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
|
||||
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
|
||||
rejected at final validate: the hub would strip continuous from the mutating code and warn on
|
||||
every update."""
|
||||
fv.full_config.set(_controller_full_config(continuous=True))
|
||||
with pytest.raises(Invalid, match="can't be polled continuously"):
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
|
||||
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
|
||||
fv.full_config.set(_controller_full_config(continuous=True))
|
||||
validate_custom_pdu_item(
|
||||
{
|
||||
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
|
||||
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,4 +4,5 @@ media_source:
|
||||
- platform: audio_http
|
||||
id: audio_http_source
|
||||
buffer_size: 100000
|
||||
persistent_ring_buffer: true
|
||||
task_stack_in_psram: true
|
||||
|
||||
@@ -15,11 +15,10 @@ struct MitsubishiCN105ClimateTestContext {
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
const auto mapping = TemperatureMapping();
|
||||
|
||||
for (int temperature = 16; temperature <= 31; ++temperature) {
|
||||
EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature);
|
||||
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
|
||||
EXPECT_EQ(context.component.get_temperature_mapping().to_mitsubishi(temperature), temperature);
|
||||
EXPECT_EQ(context.component.get_temperature_mapping().from_mitsubishi(temperature), temperature);
|
||||
}
|
||||
|
||||
const auto traits = context.sut.traits();
|
||||
@@ -32,8 +31,6 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
auto mapping = TemperatureMapping();
|
||||
mapping.set_use_fahrenheit(true);
|
||||
context.component.set_use_fahrenheit(true);
|
||||
|
||||
const std::array cases{
|
||||
@@ -46,8 +43,8 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
|
||||
};
|
||||
|
||||
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
|
||||
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
|
||||
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
|
||||
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().to_mitsubishi(fahrenheit), mitsubishi_celsius);
|
||||
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().from_mitsubishi(mitsubishi_celsius), fahrenheit);
|
||||
}
|
||||
const auto traits = context.sut.traits();
|
||||
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
|
||||
|
||||
@@ -42,11 +42,17 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
|
||||
// All bytes from UART should be consumed
|
||||
EXPECT_TRUE(ctx.uart.rx.empty());
|
||||
// After successful connect we request status, first settings (0x02)
|
||||
// Defer the first settings request (0x02) until the next update.
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
|
||||
ctx.sut.set_current_time(201);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 201);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
@@ -75,15 +81,24 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
|
||||
|
||||
// Now fetch telemetry (0x03)
|
||||
// Defer the telemetry request (0x03) until the next update.
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
|
||||
ctx.sut.set_current_time(301);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 301);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Queue a setting while waiting for telemetry.
|
||||
ctx.sut.set_power(true);
|
||||
|
||||
// Telemetry response
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
|
||||
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
|
||||
@@ -103,6 +118,13 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
|
||||
|
||||
// Apply the pending setting on the next update, outside RX processing.
|
||||
ctx.sut.set_current_time(401);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_FALSE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 401);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
@@ -469,6 +491,36 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, PendingSettingsTakePriorityOverDueTelemetry) {
|
||||
MitsubishiCN105TestsContext ctx;
|
||||
|
||||
ctx.sut.status_.target_temperature = 24.0f;
|
||||
ctx.sut.status_.room_temperature = 21.0f;
|
||||
ASSERT_TRUE(ctx.sut.is_status_initialized());
|
||||
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
ctx.sut.set_current_time(1000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
ctx.sut.set_power(true);
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
|
||||
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
|
||||
|
||||
ctx.sut.set_current_time(1001);
|
||||
ASSERT_TRUE(ctx.sut.update());
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
|
||||
ctx.sut.set_current_time(1002);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
|
||||
MitsubishiCN105TestsContext ctx;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ modbus_controller:
|
||||
- id: modbus_controller1
|
||||
address: 0x2
|
||||
modbus_id: modbus_bus
|
||||
continuous: true
|
||||
on_online:
|
||||
then:
|
||||
logger.log: "Module Online"
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
preferences:
|
||||
id: prefs_syncer
|
||||
flash_write_interval: 20s
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- component.suspend: prefs_syncer
|
||||
- component.resume: prefs_syncer
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
esphome:
|
||||
name: test_suspend_resume_device
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
|
||||
preferences:
|
||||
id: prefs_syncer
|
||||
flash_write_interval: 1s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Save Preference"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// save() only updates the in-memory map; only sync() persists it to disk.
|
||||
ESPPreferenceObject pref = global_preferences->make_preference<uint32_t>(0xBEEF);
|
||||
uint32_t value = 123;
|
||||
if (pref.save(&value)) {
|
||||
ESP_LOGI("test", "Preference saved in memory");
|
||||
} else {
|
||||
ESP_LOGE("test", "Preference save failed");
|
||||
}
|
||||
|
||||
- platform: template
|
||||
name: "Suspend Syncer"
|
||||
on_press:
|
||||
- component.suspend: prefs_syncer
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Syncer suspended");
|
||||
|
||||
- platform: template
|
||||
name: "Resume Syncer"
|
||||
on_press:
|
||||
- component.resume: prefs_syncer
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Syncer resumed");
|
||||
@@ -0,0 +1,115 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-continuous
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
|
||||
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
|
||||
globals:
|
||||
- id: silence_server
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(silence_server);"
|
||||
then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
|
||||
# replies arrive within turnaround_time, so this does not slow the streaming path.
|
||||
send_wait_time: 100ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
# A long update_interval means that without continuous polling only the boot poll would run in the
|
||||
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
|
||||
# bus allows.
|
||||
update_interval: 30s
|
||||
continuous: true
|
||||
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
|
||||
max_cmd_retries: 1
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
# Each read returns the next counter value, so every poll publishes a distinct state the test can
|
||||
# count (proving the read actually ran, not just that the state changed once).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
static uint16_t counter = 0;
|
||||
return counter++;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "continuous_reg"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
|
||||
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
|
||||
on_press:
|
||||
- lambda: "id(modbus_controller_1)->update();"
|
||||
|
||||
switch:
|
||||
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
|
||||
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
|
||||
- platform: template
|
||||
name: "Silence Server"
|
||||
id: silence_server_switch
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- lambda: "id(silence_server) = true;"
|
||||
turn_off_action:
|
||||
- lambda: "id(silence_server) = false;"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Test that suspending/resuming the preferences IntervalSyncer actually stops/starts flash writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityInfo
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "test_suspend_resume_device"
|
||||
|
||||
|
||||
def find_entity_by_name(
|
||||
entities: list[EntityInfo], entity_type: type, name: str
|
||||
) -> Any:
|
||||
"""Helper to find an entity by type and name."""
|
||||
return next(
|
||||
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for(
|
||||
awaitable: Awaitable[Any], message: str, timeout: float = 5.0
|
||||
) -> None:
|
||||
"""Await a future or coroutine, failing the test with a clear message on timeout."""
|
||||
try:
|
||||
await asyncio.wait_for(awaitable, timeout=timeout)
|
||||
except TimeoutError:
|
||||
pytest.fail(message)
|
||||
|
||||
|
||||
async def _poll_until_exists(path: Path) -> None:
|
||||
"""Poll for a file to appear, rather than guessing a sleep duration."""
|
||||
while not path.exists():
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
|
||||
"""Keep host preferences per-test so this test never touches the real
|
||||
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_preferences_suspend_resume(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
isolated_preferences: Path,
|
||||
) -> None:
|
||||
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
|
||||
pref_file = isolated_preferences
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
saved_in_memory = loop.create_future()
|
||||
syncer_suspended = loop.create_future()
|
||||
syncer_resumed = loop.create_future()
|
||||
|
||||
save_pattern = re.compile(r"Preference saved in memory")
|
||||
suspend_pattern = re.compile(r"Syncer suspended")
|
||||
resume_pattern = re.compile(r"Syncer resumed")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if save_pattern.search(line) and not saved_in_memory.done():
|
||||
saved_in_memory.set_result(True)
|
||||
if suspend_pattern.search(line) and not syncer_suspended.done():
|
||||
syncer_suspended.set_result(True)
|
||||
if resume_pattern.search(line) and not syncer_resumed.done():
|
||||
syncer_resumed.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
|
||||
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preference")
|
||||
suspend_button = find_entity_by_name(entities, ButtonInfo, "Suspend Syncer")
|
||||
resume_button = find_entity_by_name(entities, ButtonInfo, "Resume Syncer")
|
||||
assert save_button is not None, "Save Preference button not found"
|
||||
assert suspend_button is not None, "Suspend Syncer button not found"
|
||||
assert resume_button is not None, "Resume Syncer button not found"
|
||||
|
||||
# --- Positive control: a running syncer flushes to disk. Without this,
|
||||
# the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). ---
|
||||
client.button_command(save_button.key)
|
||||
await _wait_for(
|
||||
saved_in_memory, "Preference was not saved to memory within timeout"
|
||||
)
|
||||
await _wait_for(
|
||||
_poll_until_exists(pref_file),
|
||||
"Running syncer never flushed to disk; positive control failed",
|
||||
timeout=10.0,
|
||||
)
|
||||
saved_in_memory = loop.create_future()
|
||||
|
||||
# --- Suspend: a suspended syncer must not flush. ---
|
||||
client.button_command(suspend_button.key)
|
||||
await _wait_for(
|
||||
syncer_suspended, "Syncer suspend command was not processed within timeout"
|
||||
)
|
||||
# Delete only after suspend is confirmed: the poller is now stopped, so
|
||||
# nothing can recreate the file before the negative assertion below.
|
||||
pref_file.unlink()
|
||||
|
||||
client.button_command(save_button.key)
|
||||
await _wait_for(
|
||||
saved_in_memory, "Preference was not saved to memory within timeout"
|
||||
)
|
||||
|
||||
# Wait well past flash_write_interval (1s): a running syncer would
|
||||
# have flushed to disk by now, a suspended one must not have. This is a
|
||||
# negative assertion (proving absence), so a fixed sleep is unavoidable here.
|
||||
await asyncio.sleep(1.5)
|
||||
assert not pref_file.exists(), (
|
||||
"Suspended syncer flushed to disk; component.suspend did not stop the poller"
|
||||
)
|
||||
|
||||
# --- Resume: flushing must restart. ---
|
||||
client.button_command(resume_button.key)
|
||||
await _wait_for(
|
||||
syncer_resumed, "Syncer resume command was not processed within timeout"
|
||||
)
|
||||
await _wait_for(
|
||||
_poll_until_exists(pref_file),
|
||||
"Resumed syncer never flushed to disk; component.resume did not restart the poller",
|
||||
timeout=10.0,
|
||||
)
|
||||
@@ -736,6 +736,68 @@ async def test_uart_mock_modbus_custom_pdu(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_continuous(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that `continuous: true` polls faster than the update_interval.
|
||||
|
||||
The controller's update_interval is 30s, so without continuous polling only the boot poll would
|
||||
run during the short test window. With continuous the read is re-queued after each success, filling
|
||||
idle bus time, so many reads arrive. The server returns an incrementing counter, so every read is a
|
||||
distinct published state the tracker can count. (Bus warnings are not asserted here: continuous
|
||||
polling deliberately saturates the bus, so the occasional timing hiccup is expected and off-topic;
|
||||
the other tests cover clean operation at normal poll rates.)
|
||||
"""
|
||||
|
||||
tracker = SensorTracker(["continuous_reg"])
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# setup_and_start_scenario presses the Start Scenario button, whose on_press triggers the
|
||||
# controller's first update(). With continuous that one read re-queues and streams; without it
|
||||
# the next poll would not run until the 30s update_interval elapses.
|
||||
entities = await tracker.setup_and_start_scenario(client)
|
||||
# Count reads over a window far shorter than the update_interval. Absent continuous polling we
|
||||
# would see ~1 (the triggered poll); continuous re-queues, so the bus fills with reads.
|
||||
await asyncio.sleep(3.0)
|
||||
reads = len(tracker.sensor_states["continuous_reg"])
|
||||
assert reads >= 5, (
|
||||
"expected many continuous reads within the window (update_interval is 30s, so absent "
|
||||
f"continuous polling we would see ~1), got {reads}"
|
||||
)
|
||||
|
||||
# Recovery path: a live continuous poll that starts failing goes offline, and the next update()
|
||||
# re-arms it once the device answers again. Silence the server so the poll's reads time out; with
|
||||
# max_cmd_retries=1 and send_wait_time=100ms the device trips offline quickly and streaming stops.
|
||||
silence = find_entity(entities, "silence_server", SwitchInfo)
|
||||
assert silence is not None, "Silence Server switch not found"
|
||||
start = find_entity(entities, "start_scenario", ButtonInfo)
|
||||
assert start is not None, "Start Scenario button not found"
|
||||
|
||||
client.switch_command(silence.key, True)
|
||||
await asyncio.sleep(1.0) # let the poll fail and the device trip offline
|
||||
plateau = len(tracker.sensor_states["continuous_reg"])
|
||||
await asyncio.sleep(1.0) # offline: no polls should land
|
||||
assert len(tracker.sensor_states["continuous_reg"]) == plateau, (
|
||||
"reads kept arriving after the server was silenced - the failed continuous poll did not stop"
|
||||
)
|
||||
|
||||
# Answer again and trigger update(): the offline probe recovers the device and the continuous
|
||||
# poll re-arms, so streaming resumes.
|
||||
client.switch_command(silence.key, False)
|
||||
client.button_command(start.key)
|
||||
await asyncio.sleep(3.0)
|
||||
resumed = len(tracker.sensor_states["continuous_reg"]) - plateau
|
||||
assert resumed >= 5, (
|
||||
f"continuous polling did not resume after the device recovered (got {resumed} new reads)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_offline(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -1214,6 +1214,56 @@ def test_count_changed_cpp_files_with_branch() -> None:
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected"),
|
||||
[
|
||||
# Core C++ change runs everything
|
||||
(["esphome/core/helpers.cpp"], (True, [])),
|
||||
# Core Python change runs everything too
|
||||
(["esphome/core/config.py"], (True, [])),
|
||||
# Component C++ change: component plus dependents with C++ tests
|
||||
(["esphome/components/time/posix_tz.cpp"], (False, ["sntp", "time"])),
|
||||
# Component Python change shapes the host build (defines, source
|
||||
# filters), so it must trigger the same tests as a C++ change
|
||||
(["esphome/components/time/__init__.py"], (False, ["sntp", "time"])),
|
||||
# Nothing to build when no selected component has C++ tests
|
||||
(["esphome/components/homeassistant/__init__.py"], (False, [])),
|
||||
# Test manifest override changes only that component
|
||||
(["tests/components/time/__init__.py"], (False, ["time"])),
|
||||
# Test source change only that component
|
||||
(["tests/components/time/posix_tz.cpp"], (False, ["time"])),
|
||||
# pytest files and YAML build tests do not affect the test binary
|
||||
(["tests/components/socket/conftest.py"], (False, [])),
|
||||
(["tests/components/time/test.esp32-idf.yaml"], (False, [])),
|
||||
(["README.md", "script/helpers.py"], (False, [])),
|
||||
([], (False, [])),
|
||||
],
|
||||
)
|
||||
def test_determine_cpp_unit_tests(
|
||||
changed_files: list[str],
|
||||
expected: tuple[bool, list[str]],
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test which C++ unit tests a set of changed files selects."""
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
for component in ("time", "sntp"):
|
||||
(tests_dir / component).mkdir(parents=True)
|
||||
(tests_dir / component / f"{component}.cpp").write_text("")
|
||||
(tests_dir / "homeassistant").mkdir()
|
||||
(tests_dir / "socket").mkdir()
|
||||
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
helpers,
|
||||
"create_components_graph",
|
||||
return_value={"time": ["homeassistant", "sntp"]},
|
||||
),
|
||||
):
|
||||
assert determine_jobs.determine_cpp_unit_tests() == expected
|
||||
|
||||
|
||||
def test_main_filters_components_without_tests(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
|
||||
@@ -2031,3 +2031,92 @@ def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None:
|
||||
pytest.raises(Exception, match="maximum number of changed files"),
|
||||
):
|
||||
_get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_path", "expected"),
|
||||
[
|
||||
("esphome/components/time/posix_tz.cpp", True),
|
||||
("esphome/components/time/posix_tz.h", True),
|
||||
("esphome/components/time/__init__.py", True),
|
||||
("esphome/components/sntp/time.py", True),
|
||||
("tests/components/time/posix_tz.cpp", True),
|
||||
("tests/components/time/__init__.py", True),
|
||||
# Platform override: tests/components/<component>/<domain>/__init__.py
|
||||
("tests/components/template/sensor/__init__.py", True),
|
||||
# pytest-only files do not shape the C++ test binary
|
||||
("tests/components/socket/conftest.py", False),
|
||||
("tests/components/socket/test_socket.py", False),
|
||||
("tests/components/time/test.esp32-idf.yaml", False),
|
||||
("esphome/core/time.cpp", False),
|
||||
("esphome/config.py", False),
|
||||
("script/helpers.py", False),
|
||||
("README.md", False),
|
||||
],
|
||||
)
|
||||
def test_filter_cpp_unit_test_files(file_path: str, expected: bool) -> None:
|
||||
"""Test which changed files can affect a component's C++ unit test build."""
|
||||
assert helpers.filter_cpp_unit_test_files(file_path) is expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cpp_unit_test_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Fake repo root where time, sntp and api have C++ unit tests.
|
||||
|
||||
homeassistant depends on time but has no C++ tests, so it must be
|
||||
dropped from the selection; socket has only pytest files.
|
||||
"""
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
for component in ("time", "sntp", "api"):
|
||||
(tests_dir / component).mkdir(parents=True)
|
||||
(tests_dir / component / f"{component}.cpp").write_text("")
|
||||
(tests_dir / "homeassistant").mkdir()
|
||||
(tests_dir / "homeassistant" / "__init__.py").write_text("")
|
||||
(tests_dir / "socket").mkdir()
|
||||
(tests_dir / "socket" / "conftest.py").write_text("")
|
||||
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"create_components_graph",
|
||||
lambda: {"time": ["homeassistant", "sntp"]},
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("files", "expected"),
|
||||
[
|
||||
# Component changes expand to dependents with C++ tests
|
||||
(["esphome/components/time/posix_tz.cpp"], ["sntp", "time"]),
|
||||
(["esphome/components/time/__init__.py"], ["sntp", "time"]),
|
||||
# Dependent without C++ tests is dropped
|
||||
(["esphome/components/homeassistant/__init__.py"], []),
|
||||
# Test changes select only that component
|
||||
(["tests/components/time/posix_tz.cpp"], ["time"]),
|
||||
(["tests/components/time/__init__.py"], ["time"]),
|
||||
(["tests/components/homeassistant/__init__.py"], []),
|
||||
(["tests/components/socket/conftest.py"], []),
|
||||
(["tests/components/time/test.esp32-idf.yaml"], []),
|
||||
(
|
||||
["esphome/components/time/__init__.py", "tests/components/api/api.cpp"],
|
||||
["api", "sntp", "time"],
|
||||
),
|
||||
([], []),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("cpp_unit_test_tree")
|
||||
def test_get_cpp_changed_components(files: list[str], expected: list[str]) -> None:
|
||||
"""Test that C++ and Python component changes select the right unit tests."""
|
||||
assert helpers.get_cpp_changed_components(files) == expected
|
||||
|
||||
|
||||
def test_get_cpp_changed_components_independent_of_cwd(
|
||||
cpp_unit_test_tree: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test directories resolve against root_path, not the current directory."""
|
||||
monkeypatch.chdir(tmp_path_factory.mktemp("elsewhere"))
|
||||
assert helpers.get_cpp_changed_components(
|
||||
["tests/components/time/__init__.py"]
|
||||
) == ["time"]
|
||||
|
||||
Reference in New Issue
Block a user