1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-30 14:43:51 +00:00

Merge branch 'text_sensor_filters' into integration

This commit is contained in:
J. Nick Koston
2025-10-20 13:39:53 -10:00
84 changed files with 3567 additions and 1487 deletions

View File

@@ -0,0 +1,31 @@
switch:
- platform: template
id: climate_heater_switch
optimistic: true
- platform: template
id: climate_cooler_switch
optimistic: true
sensor:
- platform: template
id: climate_temperature_sensor
lambda: |-
return 21.5;
update_interval: 60s
climate:
- platform: bang_bang
id: climate_test_climate
name: Test Climate
sensor: climate_temperature_sensor
default_target_temperature_low: 18°C
default_target_temperature_high: 24°C
idle_action:
- switch.turn_off: climate_heater_switch
- switch.turn_off: climate_cooler_switch
cool_action:
- switch.turn_on: climate_cooler_switch
- switch.turn_off: climate_heater_switch
heat_action:
- switch.turn_on: climate_heater_switch
- switch.turn_off: climate_cooler_switch

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -16,3 +16,4 @@ esp32_improv:
authorizer: io0_button
authorized_duration: 1min
status_indicator: built_in_led
next_url: "https://example.com/setup?device={{device_name}}&ip={{ip_address}}&version={{esphome_version}}"

View File

@@ -1,4 +1,5 @@
espnow:
id: espnow_component
auto_add_peer: false
channel: 1
peers:
@@ -50,3 +51,26 @@ espnow:
- format_mac_address_pretty(info.src_addr).c_str()
- format_hex_pretty(data, size).c_str()
- info.rx_ctrl->rssi
packet_transport:
- platform: espnow
id: transport1
espnow_id: espnow_component
peer_address: "FF:FF:FF:FF:FF:FF"
encryption:
key: "0123456789abcdef0123456789abcdef"
sensors:
- temp_sensor
providers:
- name: test_provider
encryption:
key: "0123456789abcdef0123456789abcdef"
sensor:
- platform: internal_temperature
id: temp_sensor
- platform: packet_transport
provider: test_provider
remote_id: temp_sensor
id: remote_temp

View File

@@ -0,0 +1,33 @@
json:
interval:
- interval: 60s
then:
- lambda: |-
// Test build_json
std::string json_str = esphome::json::build_json([](JsonObject root) {
root["sensor"] = "temperature";
root["value"] = 23.5;
root["unit"] = "°C";
});
ESP_LOGD("test", "Built JSON: %s", json_str.c_str());
// Test parse_json
bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) {
if (root.containsKey("sensor") && root.containsKey("value")) {
const char* sensor = root["sensor"];
float value = root["value"];
ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value);
} else {
ESP_LOGD("test", "Parsed JSON missing required keys");
}
});
ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed");
// Test JsonBuilder class
esphome::json::JsonBuilder builder;
JsonObject obj = builder.root();
obj["test"] = "direct_builder";
obj["count"] = 42;
std::string result = builder.serialize();
ESP_LOGD("test", "JsonBuilder result: %s", result.c_str());

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -99,3 +99,77 @@ sensor:
window_size: 10
send_every: 10
send_first_at: 1 # Send after first value
# ValueListFilter-based filters tests
# FilterOutValueFilter - single value
- platform: copy
source_id: source_sensor
name: "Filter Out Single Value"
filters:
- filter_out: 42.0 # Should filter out exactly 42.0
# FilterOutValueFilter - multiple values
- platform: copy
source_id: source_sensor
name: "Filter Out Multiple Values"
filters:
- filter_out: [0.0, 42.0, 100.0] # List of values to filter
# FilterOutValueFilter - with NaN
- platform: copy
source_id: source_sensor
name: "Filter Out NaN"
filters:
- filter_out: nan # Filter out NaN values
# FilterOutValueFilter - mixed values with NaN
- platform: copy
source_id: source_sensor
name: "Filter Out Mixed with NaN"
filters:
- filter_out: [nan, 0.0, 42.0]
# ThrottleWithPriorityFilter - single priority value
- platform: copy
source_id: source_sensor
name: "Throttle with Single Priority"
filters:
- throttle_with_priority:
timeout: 1000ms
value: 42.0 # Priority value bypasses throttle
# ThrottleWithPriorityFilter - multiple priority values
- platform: copy
source_id: source_sensor
name: "Throttle with Multiple Priorities"
filters:
- throttle_with_priority:
timeout: 500ms
value: [0.0, 42.0, 100.0] # Multiple priority values
# ThrottleWithPriorityFilter - with NaN priority
- platform: copy
source_id: source_sensor
name: "Throttle with NaN Priority"
filters:
- throttle_with_priority:
timeout: 1000ms
value: nan # NaN as priority value
# Combined filters - FilterOutValueFilter + other filters
- platform: copy
source_id: source_sensor
name: "Filter Out Then Throttle"
filters:
- filter_out: [0.0, 100.0]
- throttle: 500ms
# Combined filters - ThrottleWithPriorityFilter + other filters
- platform: copy
source_id: source_sensor
name: "Throttle Priority Then Scale"
filters:
- throttle_with_priority:
timeout: 1000ms
value: [42.0]
- multiply: 2.0

View File

@@ -0,0 +1,66 @@
text_sensor:
- platform: template
name: "Test Substitute Single"
id: test_substitute_single
filters:
- substitute:
- ERROR -> Error
- platform: template
name: "Test Substitute Multiple"
id: test_substitute_multiple
filters:
- substitute:
- ERROR -> Error
- WARN -> Warning
- INFO -> Information
- DEBUG -> Debug
- platform: template
name: "Test Substitute Chained"
id: test_substitute_chained
filters:
- substitute:
- foo -> bar
- to_upper
- substitute:
- BAR -> baz
- platform: template
name: "Test Map Single"
id: test_map_single
filters:
- map:
- ON -> Active
- platform: template
name: "Test Map Multiple"
id: test_map_multiple
filters:
- map:
- ON -> Active
- OFF -> Inactive
- UNKNOWN -> Error
- IDLE -> Standby
- platform: template
name: "Test Map Passthrough"
id: test_map_passthrough
filters:
- map:
- Good -> Excellent
- Bad -> Poor
- platform: template
name: "Test All Filters"
id: test_all_filters
filters:
- to_upper
- to_lower
- append: " suffix"
- prepend: "prefix "
- substitute:
- prefix -> PREFIX
- suffix -> SUFFIX
- map:
- PREFIX text SUFFIX -> mapped

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -11,18 +11,17 @@ time:
- 192.168.178.1
uponor_smatrix:
address: 0x110B
time_id: sntp_time
time_device_address: 0xDE13
time_device_address: 0x110BDE13
climate:
- platform: uponor_smatrix
address: 0xDE13
address: 0x110BDE13
name: Thermostat Living Room
sensor:
- platform: uponor_smatrix
address: 0xDE13
address: 0x110BDE13
humidity:
name: Thermostat Humidity Living Room
temperature:

View File

@@ -0,0 +1,332 @@
esphome:
name: test-value-list-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensors - one for each test to avoid cross-test interference
sensor:
- platform: template
name: "Source Sensor 1"
id: source_sensor_1
accuracy_decimals: 1
- platform: template
name: "Source Sensor 2"
id: source_sensor_2
accuracy_decimals: 1
- platform: template
name: "Source Sensor 3"
id: source_sensor_3
accuracy_decimals: 1
- platform: template
name: "Source Sensor 4"
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: template
name: "Source Sensor 6"
id: source_sensor_6
accuracy_decimals: 2
- platform: template
name: "Source Sensor 7"
id: source_sensor_7
accuracy_decimals: 1
# FilterOutValueFilter - single value
- platform: copy
source_id: source_sensor_1
name: "Filter Out Single"
id: filter_out_single
filters:
- filter_out: 42.0
# FilterOutValueFilter - multiple values
- platform: copy
source_id: source_sensor_2
name: "Filter Out Multiple"
id: filter_out_multiple
filters:
- filter_out: [0.0, 42.0, 100.0]
# FilterOutValueFilter - with NaN
- platform: copy
source_id: source_sensor_1
name: "Filter Out NaN"
id: filter_out_nan
filters:
- filter_out: nan
# ThrottleWithPriorityFilter - single priority value
- platform: copy
source_id: source_sensor_3
name: "Throttle Priority Single"
id: throttle_priority_single
filters:
- throttle_with_priority:
timeout: 200ms
value: 42.0
# ThrottleWithPriorityFilter - multiple priority values
- platform: copy
source_id: source_sensor_4
name: "Throttle Priority Multiple"
id: throttle_priority_multiple
filters:
- throttle_with_priority:
timeout: 200ms
value: [0.0, 42.0, 100.0]
# Edge case: Filter Out NaN explicitly
- platform: copy
source_id: source_sensor_5
name: "Filter Out NaN Test"
id: filter_out_nan_test
filters:
- filter_out: nan
# Edge case: Accuracy decimals - 2 decimals
- platform: copy
source_id: source_sensor_6
name: "Filter Out Accuracy 2"
id: filter_out_accuracy_2
filters:
- filter_out: 42.0
# Edge case: Throttle with NaN priority
- platform: copy
source_id: source_sensor_7
name: "Throttle Priority NaN"
id: throttle_priority_nan
filters:
- throttle_with_priority:
timeout: 200ms
value: nan
# Script to test FilterOutValueFilter
script:
- id: test_filter_out_single
then:
# Should pass through: 1.0, 2.0, 3.0
# Should filter out: 42.0
- sensor.template.publish:
id: source_sensor_1
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_1
state: 3.0
- id: test_filter_out_multiple
then:
# Should filter out: 0.0, 42.0, 100.0
# Should pass through: 1.0, 2.0, 50.0
- sensor.template.publish:
id: source_sensor_2
state: 0.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 42.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 2.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 100.0 # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_2
state: 50.0
- id: test_throttle_priority_single
then:
# 42.0 bypasses throttle, other values are throttled
- sensor.template.publish:
id: source_sensor_3
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 42.0 # Priority - passes immediately
- delay: 50ms
- sensor.template.publish:
id: source_sensor_3
state: 3.0 # Throttled
- delay: 250ms # Wait for throttle to expire
- sensor.template.publish:
id: source_sensor_3
state: 4.0 # Passes after timeout
- id: test_throttle_priority_multiple
then:
# 0.0, 42.0, 100.0 bypass throttle
- sensor.template.publish:
id: source_sensor_4
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 0.0 # Priority - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 3.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 42.0 # Priority - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 4.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_4
state: 100.0 # Priority - passes
- id: test_filter_out_nan
then:
# NaN should be filtered out, regular values pass
- sensor.template.publish:
id: source_sensor_5
state: 1.0 # Pass
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: !lambda 'return NAN;' # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 2.0 # Pass
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: !lambda 'return NAN;' # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 3.0 # Pass
- id: test_filter_out_accuracy_2
then:
# With 2 decimal places, 42.00 filtered, 42.01 and 42.15 pass
- sensor.template.publish:
id: source_sensor_6
state: 42.0 # Filtered (rounds to 42.00)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.01 # Pass (rounds to 42.01)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.15 # Pass (rounds to 42.15)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 42.0 # Filtered (rounds to 42.00)
- id: test_throttle_priority_nan
then:
# NaN bypasses throttle, regular values throttled
- sensor.template.publish:
id: source_sensor_7
state: 1.0 # First value - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: 2.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: !lambda 'return NAN;' # Priority NaN - passes
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: 3.0 # Throttled
- delay: 50ms
- sensor.template.publish:
id: source_sensor_7
state: !lambda 'return NAN;' # Priority NaN - passes
# Buttons to trigger each test
button:
- platform: template
name: "Test Filter Out Single"
id: btn_filter_out_single
on_press:
- script.execute: test_filter_out_single
- platform: template
name: "Test Filter Out Multiple"
id: btn_filter_out_multiple
on_press:
- script.execute: test_filter_out_multiple
- platform: template
name: "Test Throttle Priority Single"
id: btn_throttle_priority_single
on_press:
- script.execute: test_throttle_priority_single
- platform: template
name: "Test Throttle Priority Multiple"
id: btn_throttle_priority_multiple
on_press:
- script.execute: test_throttle_priority_multiple
- platform: template
name: "Test Filter Out NaN"
id: btn_filter_out_nan
on_press:
- script.execute: test_filter_out_nan
- platform: template
name: "Test Filter Out Accuracy 2"
id: btn_filter_out_accuracy_2
on_press:
- script.execute: test_filter_out_accuracy_2
- platform: template
name: "Test Throttle Priority NaN"
id: btn_throttle_priority_nan
on_press:
- script.execute: test_throttle_priority_nan

View File

@@ -281,8 +281,12 @@ async def test_noise_corrupt_encrypted_frame(
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
# Check for the expected warning about decryption failure
# Check for the expected log about decryption failure
# This can appear as either a VV-level log from noise or a W-level log from connection
if (
"[VV][api.noise" in line
and "noise_cipherstate_decrypt failed: MAC_FAILURE" in line
) or (
"[W][api.connection" in line
and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line
):
@@ -322,9 +326,9 @@ async def test_noise_corrupt_encrypted_frame(
assert not process_exited, (
"ESPHome process should not crash on corrupt encrypted frames"
)
# Verify we saw the expected warning message
# Verify we saw the expected log message about decryption failure
assert cipherstate_failed, (
"Expected to see warning about CIPHERSTATE_DECRYPT_FAILED"
"Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED"
)
# Verify we can still reconnect after handling the corrupt frame

View File

@@ -0,0 +1,263 @@
"""Test sensor ValueListFilter functionality (FilterOutValueFilter and ThrottleWithPriorityFilter)."""
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_sensor_filters_value_list(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that ValueListFilter-based filters work correctly."""
loop = asyncio.get_running_loop()
# Track state changes for all sensors
sensor_values: dict[str, list[float]] = {
"filter_out_single": [],
"filter_out_multiple": [],
"throttle_priority_single": [],
"throttle_priority_multiple": [],
"filter_out_nan_test": [],
"filter_out_accuracy_2": [],
"throttle_priority_nan": [],
}
# Futures for each test
filter_out_single_done = loop.create_future()
filter_out_multiple_done = loop.create_future()
throttle_single_done = loop.create_future()
throttle_multiple_done = loop.create_future()
filter_out_nan_done = loop.create_future()
filter_out_accuracy_2_done = loop.create_future()
throttle_nan_done = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track sensor state updates."""
if not isinstance(state, SensorState) or state.missing_state:
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
sensor_name == "filter_out_single"
and len(sensor_values[sensor_name]) == 3
and not filter_out_single_done.done()
):
filter_out_single_done.set_result(True)
elif (
sensor_name == "filter_out_multiple"
and len(sensor_values[sensor_name]) == 3
and not filter_out_multiple_done.done()
):
filter_out_multiple_done.set_result(True)
elif (
sensor_name == "throttle_priority_single"
and len(sensor_values[sensor_name]) == 3
and not throttle_single_done.done()
):
throttle_single_done.set_result(True)
elif (
sensor_name == "throttle_priority_multiple"
and len(sensor_values[sensor_name]) == 4
and not throttle_multiple_done.done()
):
throttle_multiple_done.set_result(True)
elif (
sensor_name == "filter_out_nan_test"
and len(sensor_values[sensor_name]) == 3
and not filter_out_nan_done.done()
):
filter_out_nan_done.set_result(True)
elif (
sensor_name == "filter_out_accuracy_2"
and len(sensor_values[sensor_name]) == 2
and not filter_out_accuracy_2_done.done()
):
filter_out_accuracy_2_done.set_result(True)
elif (
sensor_name == "throttle_priority_nan"
and len(sensor_values[sensor_name]) == 3
and not throttle_nan_done.done()
):
throttle_nan_done.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Get entities and build key mapping
entities, _ = await client.list_entities_services()
key_to_sensor = build_key_to_entity_mapping(
entities,
{
"filter_out_single": "Filter Out Single",
"filter_out_multiple": "Filter Out Multiple",
"throttle_priority_single": "Throttle Priority Single",
"throttle_priority_multiple": "Throttle Priority Multiple",
"filter_out_nan_test": "Filter Out NaN Test",
"filter_out_accuracy_2": "Filter Out Accuracy 2",
"throttle_priority_nan": "Throttle Priority NaN",
},
)
# Set up initial state helper with all entities
initial_state_helper = InitialStateHelper(entities)
# Subscribe to state changes with wrapper
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
await initial_state_helper.wait_for_initial_states()
# Find all buttons
button_name_map = {
"Test Filter Out Single": "filter_out_single",
"Test Filter Out Multiple": "filter_out_multiple",
"Test Throttle Priority Single": "throttle_priority_single",
"Test Throttle Priority Multiple": "throttle_priority_multiple",
"Test Filter Out NaN": "filter_out_nan",
"Test Filter Out Accuracy 2": "filter_out_accuracy_2",
"Test Throttle Priority NaN": "throttle_priority_nan",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 7, f"Expected 7 buttons, found {len(buttons)}"
# Test 1: FilterOutValueFilter - single value
sensor_values["filter_out_single"].clear()
client.button_command(buttons["filter_out_single"])
try:
await asyncio.wait_for(filter_out_single_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 1 timed out. Values: {sensor_values['filter_out_single']}"
)
expected = [1.0, 2.0, 3.0]
assert sensor_values["filter_out_single"] == pytest.approx(expected), (
f"Test 1 failed: expected {expected}, got {sensor_values['filter_out_single']}"
)
# Test 2: FilterOutValueFilter - multiple values
sensor_values["filter_out_multiple"].clear()
filter_out_multiple_done = loop.create_future()
client.button_command(buttons["filter_out_multiple"])
try:
await asyncio.wait_for(filter_out_multiple_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 2 timed out. Values: {sensor_values['filter_out_multiple']}"
)
expected = [1.0, 2.0, 50.0]
assert sensor_values["filter_out_multiple"] == pytest.approx(expected), (
f"Test 2 failed: expected {expected}, got {sensor_values['filter_out_multiple']}"
)
# Test 3: ThrottleWithPriorityFilter - single priority
sensor_values["throttle_priority_single"].clear()
throttle_single_done = loop.create_future()
client.button_command(buttons["throttle_priority_single"])
try:
await asyncio.wait_for(throttle_single_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 3 timed out. Values: {sensor_values['throttle_priority_single']}"
)
expected = [1.0, 42.0, 4.0]
assert sensor_values["throttle_priority_single"] == pytest.approx(expected), (
f"Test 3 failed: expected {expected}, got {sensor_values['throttle_priority_single']}"
)
# Test 4: ThrottleWithPriorityFilter - multiple priorities
sensor_values["throttle_priority_multiple"].clear()
throttle_multiple_done = loop.create_future()
client.button_command(buttons["throttle_priority_multiple"])
try:
await asyncio.wait_for(throttle_multiple_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 4 timed out. Values: {sensor_values['throttle_priority_multiple']}"
)
expected = [1.0, 0.0, 42.0, 100.0]
assert sensor_values["throttle_priority_multiple"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['throttle_priority_multiple']}"
)
# Test 5: FilterOutValueFilter - NaN handling
sensor_values["filter_out_nan_test"].clear()
filter_out_nan_done = loop.create_future()
client.button_command(buttons["filter_out_nan"])
try:
await asyncio.wait_for(filter_out_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_out_nan_test']}"
)
expected = [1.0, 2.0, 3.0]
assert sensor_values["filter_out_nan_test"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_out_nan_test']}"
)
# Test 6: FilterOutValueFilter - Accuracy decimals (2)
sensor_values["filter_out_accuracy_2"].clear()
filter_out_accuracy_2_done = loop.create_future()
client.button_command(buttons["filter_out_accuracy_2"])
try:
await asyncio.wait_for(filter_out_accuracy_2_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 6 timed out. Values: {sensor_values['filter_out_accuracy_2']}"
)
expected = [42.01, 42.15]
assert sensor_values["filter_out_accuracy_2"] == pytest.approx(expected), (
f"Test 6 failed: expected {expected}, got {sensor_values['filter_out_accuracy_2']}"
)
# Test 7: ThrottleWithPriorityFilter - NaN priority
sensor_values["throttle_priority_nan"].clear()
throttle_nan_done = loop.create_future()
client.button_command(buttons["throttle_priority_nan"])
try:
await asyncio.wait_for(throttle_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 7 timed out. Values: {sensor_values['throttle_priority_nan']}"
)
# First value (1.0) + two NaN priority values
# NaN values will be compared using math.isnan
assert len(sensor_values["throttle_priority_nan"]) == 3, (
f"Test 7 failed: expected 3 values, got {len(sensor_values['throttle_priority_nan'])}"
)
assert sensor_values["throttle_priority_nan"][0] == pytest.approx(1.0), (
f"Test 7 failed: first value should be 1.0, got {sensor_values['throttle_priority_nan'][0]}"
)
assert math.isnan(sensor_values["throttle_priority_nan"][1]), (
f"Test 7 failed: second value should be NaN, got {sensor_values['throttle_priority_nan'][1]}"
)
assert math.isnan(sensor_values["throttle_priority_nan"][2]), (
f"Test 7 failed: third value should be NaN, got {sensor_values['throttle_priority_nan'][2]}"
)

View File

@@ -107,6 +107,7 @@ def test_main_all_tests_should_run(
assert output["integration_tests"] is True
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["changed_components"] == ["wifi", "api", "sensor"]
@@ -117,6 +118,9 @@ def test_main_all_tests_should_run(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false" # No files changed
@@ -156,11 +160,14 @@ def test_main_no_tests_should_run(
assert output["integration_tests"] is False
assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
# changed_cpp_file_count should be 0
assert output["changed_cpp_file_count"] == 0
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -239,6 +246,7 @@ def test_main_with_branch_argument(
assert output["integration_tests"] is False
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
assert output["python_linters"] is True
assert output["changed_components"] == ["mqtt"]
@@ -249,6 +257,9 @@ def test_main_with_branch_argument(
assert output["component_test_count"] == len(
output["changed_components_with_tests"]
)
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -433,6 +444,40 @@ def test_should_run_clang_format_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_count"),
[
(["esphome/core.cpp"], 1),
(["esphome/core.h"], 1),
(["test.hpp"], 1),
(["test.cc"], 1),
(["test.cxx"], 1),
(["test.c"], 1),
(["test.tcc"], 1),
(["esphome/core.cpp", "esphome/core.h"], 2),
(["esphome/core.cpp", "esphome/core.h", "test.cc"], 3),
(["README.md"], 0),
(["esphome/config.py"], 0),
(["README.md", "esphome/config.py"], 0),
(["esphome/core.cpp", "README.md", "esphome/config.py"], 1),
([], 0),
],
)
def test_count_changed_cpp_files(changed_files: list[str], expected_count: int) -> None:
"""Test count_changed_cpp_files function."""
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
result = determine_jobs.count_changed_cpp_files()
assert result == expected_count
def test_count_changed_cpp_files_with_branch() -> None:
"""Test count_changed_cpp_files with branch argument."""
with patch.object(determine_jobs, "changed_files") as mock_changed:
mock_changed.return_value = []
determine_jobs.count_changed_cpp_files("release")
mock_changed.assert_called_once_with("release")
def test_main_filters_components_without_tests(
mock_should_run_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
@@ -501,6 +546,9 @@ def test_main_filters_components_without_tests(
assert set(output["changed_components_with_tests"]) == {"wifi", "sensor"}
# component_test_count should be based on components with tests
assert output["component_test_count"] == 2
# changed_cpp_file_count should be present
assert "changed_cpp_file_count" in output
assert isinstance(output["changed_cpp_file_count"], int)
# memory_impact should be present
assert "memory_impact" in output
assert output["memory_impact"]["should_run"] == "false"
@@ -545,7 +593,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non
def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
"""Test memory impact detection with core-only changes (no component changes)."""
"""Test memory impact detection with core C++ changes (no component changes)."""
# Create test directory structure with fallback component
tests_dir = tmp_path / "tests" / "components"
@@ -554,7 +602,7 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
api_dir.mkdir(parents=True)
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
# Mock changed_files to return only core files (no component files)
# Mock changed_files to return only core C++ files (no component files)
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
@@ -574,6 +622,35 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
assert result["use_merged_config"] == "true"
def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> None:
"""Test that Python-only core changes don't trigger memory impact analysis."""
# Create test directory structure with fallback component
tests_dir = tmp_path / "tests" / "components"
# api component (fallback component) with esp32-idf test
api_dir = tests_dir / "api"
api_dir.mkdir(parents=True)
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
# Mock changed_files to return only core Python files (no C++ files)
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch.object(determine_jobs, "changed_files") as mock_changed_files,
):
mock_changed_files.return_value = [
"esphome/__main__.py",
"esphome/config.py",
"esphome/core/config.py",
]
determine_jobs._component_has_tests.cache_clear()
result = determine_jobs.detect_memory_impact_config()
# Python-only changes should NOT trigger memory impact analysis
assert result["should_run"] == "false"
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
"""Test memory impact detection when components have no common platform."""
# Create test directory structure

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25
numberOne: 1
var1: 79
double_width: 14
test_list:
- The area is 56
- 56
@@ -25,3 +26,4 @@ test_list:
- ord("a") = 97
- chr(97) = a
- len([1,2,3]) = 3
- width = 7, double_width = 14

View File

@@ -8,6 +8,7 @@ substitutions:
area: 25
numberOne: 1
var1: 79
double_width: ${width * 2}
test_list:
- "The area is ${width * height}"
@@ -23,3 +24,4 @@ test_list:
- ord("a") = ${ ord("a") }
- chr(97) = ${ chr(97) }
- len([1,2,3]) = ${ len([1,2,3]) }
- width = ${width}, double_width = ${double_width}

View File

@@ -17,10 +17,12 @@ from esphome import platformio_api
from esphome.__main__ import (
Purpose,
choose_upload_log_host,
command_analyze_memory,
command_clean_all,
command_rename,
command_update_all,
command_wizard,
detect_external_components,
get_port_type,
has_ip_address,
has_mqtt,
@@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]:
@pytest.fixture
def mock_run_external_command() -> Generator[Mock]:
"""Mock run_external_command for testing."""
def mock_run_external_command_main() -> Generator[Mock]:
"""Mock run_external_command in __main__ module (different from platformio_api)."""
with patch("esphome.__main__.run_external_command") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_write_cpp() -> Generator[Mock]:
"""Mock write_cpp for testing."""
with patch("esphome.__main__.write_cpp") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_compile_program() -> Generator[Mock]:
"""Mock compile_program for testing."""
with patch("esphome.__main__.compile_program") as mock:
mock.return_value = 0 # Default to success
yield mock
@pytest.fixture
def mock_get_esphome_components() -> Generator[Mock]:
"""Mock get_esphome_components for testing."""
with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock:
mock.return_value = {"logger", "api", "ota"}
yield mock
@pytest.fixture
def mock_memory_analyzer_cli() -> Generator[Mock]:
"""Mock MemoryAnalyzerCLI for testing."""
with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class:
mock_analyzer = MagicMock()
mock_analyzer.generate_report.return_value = "Mock Memory Report"
mock_class.return_value = mock_analyzer
yield mock_class
def test_choose_upload_log_host_with_string_default() -> None:
"""Test with a single string default device."""
setup_core()
@@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file(
def test_upload_using_esptool_path_conversion(
tmp_path: Path,
mock_run_external_command: Mock,
mock_run_external_command_main: Mock,
mock_get_idedata: Mock,
) -> None:
"""Test upload_using_esptool properly converts Path objects to strings for esptool.
@@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion(
assert result == 0
# Verify that run_external_command was called
assert mock_run_external_command.call_count == 1
assert mock_run_external_command_main.call_count == 1
# Get the actual call arguments
call_args = mock_run_external_command.call_args[0]
call_args = mock_run_external_command_main.call_args[0]
# The first argument should be esptool.main function,
# followed by the command arguments
@@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion(
def test_upload_using_esptool_with_file_path(
tmp_path: Path,
mock_run_external_command: Mock,
mock_run_external_command_main: Mock,
) -> None:
"""Test upload_using_esptool with a custom file that's a Path object."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
@@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path(
assert result == 0
# Verify that run_external_command was called
mock_run_external_command.assert_called_once()
mock_run_external_command_main.assert_called_once()
# Get the actual call arguments
call_args = mock_run_external_command.call_args[0]
call_args = mock_run_external_command_main.call_args[0]
cmd_list = list(call_args[1:]) # Skip the esptool.main function
# Find the firmware path in the command
@@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback(
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
def test_detect_external_components_no_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components with no external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"api": {},
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_with_external(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components detects external components."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {}, # Built-in
"api": {}, # Built-in
"my_custom_sensor": {}, # External
"another_custom": {}, # External
"external_components": [], # Special key, not a component
"substitutions": {}, # Special key, not a component
}
result = detect_external_components(config)
assert result == {"my_custom_sensor", "another_custom"}
mock_get_esphome_components.assert_called_once()
def test_detect_external_components_filters_special_keys(
mock_get_esphome_components: Mock,
) -> None:
"""Test detect_external_components filters out special config keys."""
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"substitutions": {"key": "value"},
"packages": {},
"globals": [],
"external_components": [],
"<<": {}, # YAML merge key
}
result = detect_external_components(config)
assert result == set()
mock_get_esphome_components.assert_called_once()
def test_command_analyze_memory_success(
tmp_path: Path,
capfd: CaptureFixture[str],
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory with successful compilation and analysis."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify compilation was done
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
# Verify analyzer was created with correct parameters
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
set(), # No external components
)
# Verify analysis was run
mock_analyzer = mock_memory_analyzer_cli.return_value
mock_analyzer.analyze.assert_called_once()
mock_analyzer.generate_report.assert_called_once()
# Verify report was printed
captured = capfd.readouterr()
assert "Mock Memory Report" in captured.out
def test_command_analyze_memory_with_external_components(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
mock_get_esphome_components: Mock,
mock_memory_analyzer_cli: Mock,
) -> None:
"""Test command_analyze_memory detects external components."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
# Create firmware.elf file
firmware_path = (
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
)
firmware_path.mkdir(parents=True, exist_ok=True)
firmware_elf = firmware_path / "firmware.elf"
firmware_elf.write_text("mock elf file")
# Mock idedata
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
mock_idedata_obj.objdump_path = "/path/to/objdump"
mock_idedata_obj.readelf_path = "/path/to/readelf"
mock_get_idedata.return_value = mock_idedata_obj
config = {
CONF_ESPHOME: {CONF_NAME: "test_device"},
"logger": {},
"my_custom_component": {"param": "value"}, # External component
"external_components": [{"source": "github://user/repo"}], # Not a component
}
args = MockArgs()
result = command_analyze_memory(args, config)
assert result == 0
# Verify analyzer was created with external components detected
mock_memory_analyzer_cli.assert_called_once_with(
str(firmware_elf),
"/path/to/objdump",
"/path/to/readelf",
{"my_custom_component"}, # External component detected
)
def test_command_analyze_memory_write_cpp_fails(
tmp_path: Path,
mock_write_cpp: Mock,
) -> None:
"""Test command_analyze_memory when write_cpp fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_write_cpp.return_value = 1 # Failure
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
def test_command_analyze_memory_compile_fails(
tmp_path: Path,
mock_write_cpp: Mock,
mock_compile_program: Mock,
) -> None:
"""Test command_analyze_memory when compilation fails."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_compile_program.return_value = 1 # Compilation failed
result = command_analyze_memory(args, config)
assert result == 1
mock_write_cpp.assert_called_once_with(config)
mock_compile_program.assert_called_once_with(args, config)
def test_command_analyze_memory_no_idedata(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
mock_write_cpp: Mock,
mock_compile_program: Mock,
mock_get_idedata: Mock,
) -> None:
"""Test command_analyze_memory when idedata cannot be retrieved."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
mock_get_idedata.return_value = None # Failed to get idedata
with caplog.at_level(logging.ERROR):
result = command_analyze_memory(args, config)
assert result == 1
assert "Failed to get IDE data for memory analysis" in caplog.text