mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 17:00:21 +00:00
Merge remote-tracking branch 'upstream/dev' into pack-entity-strings
# Conflicts: # esphome/components/rp2040/core.cpp
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user