Merge remote-tracking branch 'upstream/dev' into pack-entity-strings

# Conflicts:
#	esphome/components/rp2040/core.cpp
This commit is contained in:
J. Nick Koston
2026-02-27 14:54:37 -10:00
364 changed files with 13145 additions and 7869 deletions
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32p4
framework:
type: esp-idf
advanced:
execute_from_psram: true
psram:
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
execute_from_psram: true
psram:
mode: octal
+53 -2
View File
@@ -2,13 +2,17 @@
Test ESP32 configuration
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome.components.esp32 import VARIANTS
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
@@ -70,7 +74,7 @@ def test_esp32_config(
"advanced": {"execute_from_psram": True},
},
},
r"'execute_from_psram' is only supported on ESP32S3 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
r"'execute_from_psram' is not available on this esp32 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
id="execute_from_psram_invalid_for_variant_config",
),
pytest.param(
@@ -82,7 +86,18 @@ def test_esp32_config(
},
},
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
id="execute_from_psram_requires_psram_config",
id="execute_from_psram_requires_psram_s3_config",
),
pytest.param(
{
"variant": "esp32p4",
"framework": {
"type": "esp-idf",
"advanced": {"execute_from_psram": True},
},
},
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
id="execute_from_psram_requires_psram_p4_config",
),
pytest.param(
{
@@ -108,3 +123,39 @@ def test_esp32_configuration_errors(
with pytest.raises(cv.Invalid, match=error_match):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
def test_execute_from_psram_s3_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that execute_from_psram on ESP32-S3 sets the correct sdkconfig options."""
generate_main(component_config_path("execute_from_psram_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_SPIRAM_FETCH_INSTRUCTIONS") is True
assert sdkconfig.get("CONFIG_SPIRAM_RODATA") is True
assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig
def test_execute_from_psram_p4_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that execute_from_psram on ESP32-P4 sets the correct sdkconfig options."""
generate_main(component_config_path("execute_from_psram_p4.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_execute_from_psram_disabled_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that without execute_from_psram, no XIP sdkconfig options are set."""
generate_main(component_config_path("execute_from_psram_disabled.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig
@@ -22,6 +22,23 @@ display:
id: p4_86
model: "WAVESHARE-P4-86-PANEL"
rotation: 180
- platform: mipi_dsi
model: custom
id: custom_id
dimensions:
width: 400
height: 1280
hsync_back_porch: 40
hsync_pulse_width: 30
hsync_front_porch: 40
vsync_back_porch: 20
vsync_pulse_width: 10
vsync_front_porch: 20
pclk_frequency: 48Mhz
lane_bit_rate: 1.2Gbps
rotation: 180
transform: disabled
init_sequence:
i2c:
sda: GPIO7
scl: GPIO8
@@ -123,7 +123,8 @@ def test_code_generation(
in main_cpp
)
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
assert "p4_nano->set_lane_bit_rate(1500);" in main_cpp
assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp
assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,6 @@
esp32_ble:
io_capability: keyboard_display
# Explicitly not setting some parameters to test ifdef selection
# max_key_size: 16
# min_key_size: 7
auth_req_mode: sc_mitm_bond
@@ -0,0 +1,5 @@
esp32_ble:
io_capability: keyboard_display
max_key_size: 16
min_key_size: 7
auth_req_mode: sc_mitm_bond
@@ -0,0 +1,16 @@
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
d1_pin: GPIO10
d2_pin: GPIO9
d3_pin: GPIO8
sdio_frequency: 8MHz
wifi:
ssid: MySSID
password: password1
@@ -4,7 +4,6 @@ esp32_touch:
measurement_duration: 8ms
low_voltage_reference: 0.5V
high_voltage_reference: 2.7V
voltage_attenuation: 1.5V
binary_sensor:
- platform: esp32_touch
@@ -0,0 +1,5 @@
substitutions:
pin: GPIO5
<<: !include common-variants.yaml
<<: !include common-get-value.yaml
@@ -3,10 +3,13 @@ esp_ldo:
channel: 3
voltage: 2.5V
adjustable: true
- id: ldo_4
- id: ldo_4_passthrough
channel: 4
voltage: 2.0V
setup_priority: 900
voltage: passthrough
- id: ldo_1_internal
channel: 1
voltage: 1.8V
allow_internal_channel: true
esphome:
on_boot:
+7
View File
@@ -1,8 +1,15 @@
gps:
latitude:
name: "Latitude"
id: gps_lat
on_value:
then:
- logger.log:
format: "%.6f, %.6f"
args: [id(gps_lat).state, id(gps_long).state]
longitude:
name: "Longitude"
id: gps_long
altitude:
name: "Altitude"
speed:
+19
View File
@@ -0,0 +1,19 @@
esphome:
on_boot:
then:
- hdc302x.heater_on:
id: hdc302x_sensor
power: QUARTER
duration: 5s
- hdc302x.heater_off:
id: hdc302x_sensor
sensor:
- platform: hdc302x
id: hdc302x_sensor
i2c_id: i2c_bus
temperature:
name: Temperature
humidity:
name: Humidity
update_interval: 15s
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
+26
View File
@@ -71,6 +71,32 @@ esphome:
- light.control:
id: test_monochromatic_light
state: on
# Test static effect name resolution at codegen time
- light.turn_on:
id: test_monochromatic_light
effect: Strobe
- light.turn_on:
id: test_monochromatic_light
effect: none
# Test resolving a different effect on the same light
- light.control:
id: test_monochromatic_light
effect: My Flicker
# Test effect: None (capitalized)
- light.control:
id: test_monochromatic_light
effect: None
# Test effect lambda with no args (on_boot has empty Ts...)
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return "Strobe";'
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
- repeat:
count: 3
then:
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
- light.dim_relative:
id: test_monochromatic_light
relative_brightness: 5%
@@ -16,3 +16,7 @@ button:
switch:
- platform: safe_mode
name: Safe Mode Switch
esphome:
on_boot:
- safe_mode.mark_successful
+37
View File
@@ -0,0 +1,37 @@
sensor:
# Test with explicit type parameter
- platform: sen6x
id: sen6x_sensor
type: SEN69C
i2c_id: i2c_bus
temperature:
name: Temperature
accuracy_decimals: 1
humidity:
name: Humidity
accuracy_decimals: 0
pm_1_0:
name: PM <1µm Weight concentration
id: pm_1_0
accuracy_decimals: 1
pm_2_5:
name: PM <2.5µm Weight concentration
id: pm_2_5
accuracy_decimals: 1
pm_4_0:
name: PM <4µm Weight concentration
id: pm_4_0
accuracy_decimals: 1
pm_10_0:
name: PM <10µm Weight concentration
id: pm_10_0
accuracy_decimals: 1
nox:
name: NOx
voc:
name: VOC
co2:
name: Carbon Dioxide
formaldehyde:
name: Formaldehyde
address: 0x6B
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -1,9 +1,26 @@
import pytest
from esphome.components import socket
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
)
from esphome.core import CORE
def _setup_platform(platform=PLATFORM_ESP8266) -> None:
"""Set up CORE.data with a platform for testing."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
def test_require_wake_loop_threadsafe__first_call() -> None:
"""Test that first call sets up define and consumes socket."""
_setup_platform()
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
@@ -32,6 +49,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None:
def test_require_wake_loop_threadsafe__multiple_calls() -> None:
"""Test that multiple calls only set up once."""
_setup_platform()
# Call three times
CORE.config = {"openthread": True}
socket.require_wake_loop_threadsafe()
@@ -66,12 +84,44 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -
CORE.config = {"logger": {}}
# Track initial socket consumer state
initial_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {})
initial_udp = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
# Call require_wake_loop_threadsafe
socket.require_wake_loop_threadsafe()
# Verify no socket was consumed
consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {})
assert "socket.wake_loop_threadsafe" not in consumers
assert consumers == initial_consumers
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
assert udp_consumers == initial_udp
@pytest.mark.parametrize(
"platform",
[PLATFORM_ESP32, PLATFORM_BK72XX, PLATFORM_RTL87XX, PLATFORM_LN882X],
)
def test_require_wake_loop_threadsafe__fast_select_no_udp_socket(
platform: str,
) -> None:
"""Test that fast select platforms use task notifications instead of UDP socket."""
_setup_platform(platform)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify the define was added
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
# Verify no UDP socket was consumed (fast select platforms use FreeRTOS task notifications)
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
def test_require_wake_loop_threadsafe__non_fast_select_consumes_udp_socket() -> None:
"""Test that platforms without fast select consume a UDP socket for wake notifications."""
_setup_platform(PLATFORM_ESP8266)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify UDP socket was consumed
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert udp_consumers.get("socket.wake_loop_threadsafe") == 1
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,3 +1,16 @@
text_sensor:
- platform: version
name: "ESPHome Version"
name: "ESPHome Version Full"
- platform: version
name: "ESPHome Version No Timestamp"
hide_timestamp: true
- platform: version
name: "ESPHome Version No Hash"
hide_hash: true
- platform: version
name: "ESPHome Version Shortest"
hide_timestamp: true
hide_hash: true
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
+1 -1
View File
@@ -13,7 +13,7 @@ using namespace esphome;
void setup() {
App.pre_setup("livingroom", "LivingRoom", false);
auto *log = new logger::Logger(115200, 512); // NOLINT
auto *log = new logger::Logger(115200); // NOLINT
log->pre_setup();
log->set_uart_selection(logger::UART_SELECTION_UART0);
App.register_component(log);
@@ -46,6 +46,7 @@ sensor:
binary_sensor:
- platform: template
id: motion_detected
name: Motion Detected
device_id: motion_sensor
lambda: return true;
@@ -82,3 +83,117 @@ output:
write_action:
- lambda: |-
ESP_LOGD("test", "Light output: %d", state);
cover:
- platform: template
name: Garage Door
device_id: motion_sensor
optimistic: true
fan:
- platform: template
name: Ceiling Fan
device_id: humidity_monitor
speed_count: 3
has_oscillating: false
has_direction: false
lock:
- platform: template
name: Front Door Lock
device_id: motion_sensor
optimistic: true
number:
- platform: template
name: Target Temperature
device_id: temperature_monitor
optimistic: true
min_value: 0
max_value: 100
step: 1
select:
- platform: template
name: Mode Select
device_id: humidity_monitor
optimistic: true
options:
- "Auto"
- "Manual"
text:
- platform: template
name: Device Label
device_id: temperature_monitor
optimistic: true
mode: text
valve:
- platform: template
name: Water Valve
device_id: humidity_monitor
optimistic: true
globals:
- id: global_away
type: bool
initial_value: "false"
- id: global_is_on
type: bool
initial_value: "true"
water_heater:
- platform: template
name: Test Boiler
device_id: temperature_monitor
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
away: !lambda "return id(global_away);"
is_on: !lambda "return id(global_is_on);"
supported_modes:
- "off"
- electric
visual:
min_temperature: 30.0
max_temperature: 85.0
target_temperature_step: 0.5
set_action:
- lambda: |-
ESP_LOGD("test", "Water heater set");
alarm_control_panel:
- platform: template
name: House Alarm
device_id: motion_sensor
codes:
- "1234"
restore_mode: ALWAYS_DISARMED
binary_sensors:
- input: motion_detected
datetime:
- platform: template
name: Schedule Date
device_id: temperature_monitor
type: date
optimistic: true
- platform: template
name: Schedule Time
device_id: humidity_monitor
type: time
optimistic: true
- platform: template
name: Schedule DateTime
device_id: motion_sensor
type: datetime
optimistic: true
event:
- platform: template
name: Doorbell
device_id: motion_sensor
event_types:
- "press"
- "double_press"
@@ -28,6 +28,11 @@ sensor:
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -69,6 +74,13 @@ sensor:
filters:
- delta: 0
- platform: copy
source_id: source_sensor_5
name: "Filter Percentage"
id: filter_percentage
filters:
- delta: 50%
script:
- id: test_filter_min
then:
@@ -154,6 +166,28 @@ script:
id: source_sensor_4
state: 2.0
- id: test_filter_percentage
then:
- sensor.template.publish:
id: source_sensor_5
state: 100.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 120.0 # Filtered out (delta=20, need >50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 160.0 # Passes (delta=60 > 50% of 100=50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 200.0 # Filtered out (delta=40, need >50% of 160=80)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
button:
- platform: template
name: "Test Filter Min"
@@ -178,3 +212,9 @@ button:
id: btn_filter_zero_delta
on_press:
- script.execute: test_filter_zero_delta
- platform: template
name: "Test Filter Percentage"
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage
@@ -0,0 +1,47 @@
esphome:
name: varint-5byte-test
# Define areas and devices - device_ids will be FNV hashes > 2^28,
# requiring 5-byte varint encoding that exercises the 32-bit parse boundary.
areas:
- id: test_area
name: Test Area
devices:
- id: sub_device_one
name: Sub Device One
area_id: test_area
- id: sub_device_two
name: Sub Device Two
area_id: test_area
host:
api:
logger:
# Switches on sub-devices so we can send commands with large device_id varints
switch:
- platform: template
name: Device Switch
device_id: sub_device_one
id: device_switch_one
optimistic: true
turn_on_action:
- logger.log: "Switch one on"
turn_off_action:
- logger.log: "Switch one off"
- platform: template
name: Device Switch
device_id: sub_device_two
id: device_switch_two
optimistic: true
turn_on_action:
- logger.log: "Switch two on"
turn_off_action:
- logger.log: "Switch two off"
sensor:
- platform: template
name: Device Sensor
device_id: sub_device_one
lambda: return 42.0;
update_interval: 0.1s
+116 -57
View File
@@ -4,11 +4,80 @@ from __future__ import annotations
import asyncio
from aioesphomeapi import BinarySensorState, EntityState, SensorState, TextSensorState
from aioesphomeapi import (
AlarmControlPanelEntityState,
BinarySensorState,
CoverState,
DateState,
DateTimeState,
EntityState,
FanState,
LightState,
LockEntityState,
NumberState,
SelectState,
SensorState,
SwitchState,
TextSensorState,
TextState,
TimeState,
ValveState,
WaterHeaterState,
)
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Mapping of entity name to device name for all entities with device_id
ENTITY_TO_DEVICE = {
# Original entities
"Temperature": "Temperature Monitor",
"Humidity": "Humidity Monitor",
"Motion Detected": "Motion Sensor",
"Temperature Monitor Power": "Temperature Monitor",
"Temperature Status": "Temperature Monitor",
"Motion Light": "Motion Sensor",
# New entity types
"Garage Door": "Motion Sensor",
"Ceiling Fan": "Humidity Monitor",
"Front Door Lock": "Motion Sensor",
"Target Temperature": "Temperature Monitor",
"Mode Select": "Humidity Monitor",
"Device Label": "Temperature Monitor",
"Water Valve": "Humidity Monitor",
"Test Boiler": "Temperature Monitor",
"House Alarm": "Motion Sensor",
"Schedule Date": "Temperature Monitor",
"Schedule Time": "Humidity Monitor",
"Schedule DateTime": "Motion Sensor",
"Doorbell": "Motion Sensor",
}
# Entities without device_id (should have device_id 0)
NO_DEVICE_ENTITIES = {"No Device Sensor"}
# State types that should have non-zero device_id, mapped by their aioesphomeapi class
EXPECTED_STATE_TYPES = [
(SensorState, "sensor"),
(BinarySensorState, "binary_sensor"),
(SwitchState, "switch"),
(TextSensorState, "text_sensor"),
(LightState, "light"),
(CoverState, "cover"),
(FanState, "fan"),
(LockEntityState, "lock"),
(NumberState, "number"),
(SelectState, "select"),
(TextState, "text"),
(ValveState, "valve"),
(WaterHeaterState, "water_heater"),
(AlarmControlPanelEntityState, "alarm_control_panel"),
(DateState, "date"),
(TimeState, "time"),
(DateTimeState, "datetime"),
# Event is stateless (no initial state sent on subscribe)
]
@pytest.mark.asyncio
async def test_device_id_in_state(
@@ -40,34 +109,35 @@ async def test_device_id_in_state(
entity_device_mapping: dict[int, int] = {}
for entity in all_entities:
# All entities have name and key attributes
if entity.name == "Temperature":
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Humidity":
entity_device_mapping[entity.key] = device_ids["Humidity Monitor"]
elif entity.name == "Motion Detected":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name in {"Temperature Monitor Power", "Temperature Status"}:
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Motion Light":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name == "No Device Sensor":
# Entity without device_id should have device_id 0
if entity.name in ENTITY_TO_DEVICE:
expected_device = ENTITY_TO_DEVICE[entity.name]
entity_device_mapping[entity.key] = device_ids[expected_device]
elif entity.name in NO_DEVICE_ENTITIES:
entity_device_mapping[entity.key] = 0
assert len(entity_device_mapping) >= 6, (
f"Expected at least 6 mapped entities, got {len(entity_device_mapping)}"
expected_count = len(ENTITY_TO_DEVICE) + len(NO_DEVICE_ENTITIES)
assert len(entity_device_mapping) >= expected_count, (
f"Expected at least {expected_count} mapped entities, "
f"got {len(entity_device_mapping)}. "
f"Missing: {set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES - {e.name for e in all_entities}}"
)
# Subscribe to states and wait for all mapped entities
# Event entities are stateless (no initial state on subscribe),
# so exclude them from the expected count
stateless_keys = {e.key for e in all_entities if e.name == "Doorbell"}
stateful_count = len(entity_device_mapping) - len(
stateless_keys & entity_device_mapping.keys()
)
# Subscribe to states
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check if we have states for all mapped entities
if len(states) >= len(entity_device_mapping) and not states_future.done():
if state.key in entity_device_mapping:
states[state.key] = state
if len(states) >= stateful_count and not states_future.done():
states_future.set_result(True)
client.subscribe_states(on_state)
@@ -76,9 +146,16 @@ async def test_device_id_in_state(
try:
await asyncio.wait_for(states_future, timeout=10.0)
except TimeoutError:
received_names = {e.name for e in all_entities if e.key in states}
missing_names = (
(set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES)
- received_names
- {"Doorbell"}
)
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Received {len(states)} states, expected {len(entity_device_mapping)}"
f"Received {len(states)} states. "
f"Missing: {missing_names}"
)
# Verify each state has the correct device_id
@@ -86,51 +163,33 @@ async def test_device_id_in_state(
for key, expected_device_id in entity_device_mapping.items():
if key in states:
state = states[key]
entity_name = next(
(e.name for e in all_entities if e.key == key), f"key={key}"
)
assert state.device_id == expected_device_id, (
f"State for key {key} has device_id {state.device_id}, "
f"expected {expected_device_id}"
f"State for '{entity_name}' (type={type(state).__name__}) "
f"has device_id {state.device_id}, expected {expected_device_id}"
)
verified_count += 1
assert verified_count >= 6, (
f"Only verified {verified_count} states, expected at least 6"
# All stateful entities should be verified (everything except Doorbell event)
expected_verified = expected_count - 1 # exclude Doorbell
assert verified_count >= expected_verified, (
f"Only verified {verified_count} states, expected at least {expected_verified}"
)
# Test specific state types to ensure device_id is present
# Find a sensor state with device_id
sensor_state = next(
(
# Verify each expected state type has at least one instance with non-zero device_id
for state_type, type_name in EXPECTED_STATE_TYPES:
matching = [
s
for s in states.values()
if isinstance(s, SensorState)
and isinstance(s.state, float)
and s.device_id != 0
),
None,
)
assert sensor_state is not None, "No sensor state with device_id found"
assert sensor_state.device_id > 0, "Sensor state should have non-zero device_id"
# Find a binary sensor state
binary_sensor_state = next(
(s for s in states.values() if isinstance(s, BinarySensorState)),
None,
)
assert binary_sensor_state is not None, "No binary sensor state found"
assert binary_sensor_state.device_id > 0, (
"Binary sensor state should have non-zero device_id"
)
# Find a text sensor state
text_sensor_state = next(
(s for s in states.values() if isinstance(s, TextSensorState)),
None,
)
assert text_sensor_state is not None, "No text sensor state found"
assert text_sensor_state.device_id > 0, (
"Text sensor state should have non-zero device_id"
)
if isinstance(s, state_type) and s.device_id != 0
]
assert matching, (
f"No {type_name} state (type={state_type.__name__}) "
f"with non-zero device_id found"
)
# Verify the "No Device Sensor" has device_id = 0
no_device_key = next(
+26 -1
View File
@@ -24,12 +24,14 @@ async def test_sensor_filters_delta(
"filter_max": [],
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
}
filter_min_done = loop.create_future()
filter_max_done = loop.create_future()
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
@@ -66,6 +68,12 @@ async def test_sensor_filters_delta(
and not filter_zero_delta_done.done()
):
filter_zero_delta_done.set_result(True)
elif (
sensor_name == "filter_percentage"
and len(sensor_values[sensor_name]) == 3
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -80,6 +88,7 @@ async def test_sensor_filters_delta(
"filter_max": "Filter Max",
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
},
)
@@ -98,13 +107,14 @@ async def test_sensor_filters_delta(
"Test Filter Max": "filter_max",
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
}
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) == 4, f"Expected 3 buttons, found {len(buttons)}"
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -161,3 +171,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
)
# Test 5: Percentage (delta: 50%)
sensor_values["filter_percentage"].clear()
client.button_command(buttons["filter_percentage"])
try:
await asyncio.wait_for(filter_percentage_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_percentage']}"
)
expected = [100.0, 160.0, 250.0]
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)
@@ -0,0 +1,120 @@
"""Integration test for 5-byte varint parsing of device_id fields.
Device IDs are FNV hashes (uint32) that frequently exceed 2^28 (268435456),
requiring 5 varint bytes. This test verifies that:
1. The firmware correctly decodes 5-byte varint device_id in incoming commands
2. The firmware correctly encodes large device_id values in state responses
3. Switch commands with large device_id reach the correct entity
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SwitchInfo, SwitchState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_varint_five_byte_device_id(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that device_id values requiring 5-byte varints parse correctly."""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
devices = device_info.devices
assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}"
# Verify at least one device_id exceeds the 4-byte varint boundary (2^28)
large_ids = [d for d in devices if d.device_id >= (1 << 28)]
assert len(large_ids) > 0, (
"Expected at least one device_id >= 2^28 to exercise 5-byte varint path. "
f"Got device_ids: {[d.device_id for d in devices]}"
)
# Get entities
all_entities, _ = await client.list_entities_services()
switch_entities = [e for e in all_entities if isinstance(e, SwitchInfo)]
# Find switches named "Device Switch" — one per sub-device
device_switches = [e for e in switch_entities if e.name == "Device Switch"]
assert len(device_switches) == 2, (
f"Expected 2 'Device Switch' entities, got {len(device_switches)}"
)
# Verify switches have different device_ids matching the sub-devices
switch_device_ids = {s.device_id for s in device_switches}
assert len(switch_device_ids) == 2, "Switches should have different device_ids"
# Subscribe to states and wait for initial states
loop = asyncio.get_running_loop()
states: dict[tuple[int, int], EntityState] = {}
switch_futures: dict[tuple[int, int], asyncio.Future[EntityState]] = {}
initial_done: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
key = (state.device_id, state.key)
states[key] = state
if len(states) >= 3 and not initial_done.done():
initial_done.set_result(True)
if initial_done.done() and key in switch_futures:
fut = switch_futures[key]
if not fut.done() and isinstance(state, SwitchState):
fut.set_result(state)
client.subscribe_states(on_state)
try:
await asyncio.wait_for(initial_done, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for initial states. Got {len(states)} states"
)
# Verify state responses contain correct large device_id values
for device in devices:
device_states = [
s for (did, _), s in states.items() if did == device.device_id
]
assert len(device_states) > 0, (
f"No states received for device '{device.name}' "
f"(device_id={device.device_id})"
)
# Test switch commands with large device_id varints —
# this is the critical path: the client encodes device_id as a varint
# in the SwitchCommandRequest, and the firmware must decode it correctly.
for switch in device_switches:
state_key = (switch.device_id, switch.key)
# Turn on
switch_futures[state_key] = loop.create_future()
client.switch_command(switch.key, True, device_id=switch.device_id)
try:
await asyncio.wait_for(switch_futures[state_key], timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for switch ON state "
f"(device_id={switch.device_id}, key={switch.key}). "
f"This likely means the firmware failed to decode the "
f"5-byte varint device_id in SwitchCommandRequest."
)
assert states[state_key].state is True
# Turn off
switch_futures[state_key] = loop.create_future()
client.switch_command(switch.key, False, device_id=switch.device_id)
try:
await asyncio.wait_for(switch_futures[state_key], timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for switch OFF state "
f"(device_id={switch.device_id}, key={switch.key})"
)
assert states[state_key].state is False