Merge branch 'dev' into posix_tz

This commit is contained in:
J. Nick Koston
2026-02-12 05:40:07 -06:00
committed by GitHub
694 changed files with 13150 additions and 6833 deletions
@@ -1,10 +1,14 @@
"""
Test schema.extend functionality in esphome.config_validation.
Test config_validation functionality in esphome.config_validation.
"""
from typing import Any
import pytest
from voluptuous import Invalid
import esphome.config_validation as cv
from esphome.core import CORE
def test_config_extend() -> None:
@@ -49,3 +53,37 @@ def test_config_extend() -> None:
assert validated["key2"] == "initial_value2"
assert validated["extra_1"] == "value1"
assert validated["extra_2"] == "value2"
def test_requires_component_passes_when_loaded() -> None:
"""Test requires_component passes when the required component is loaded."""
CORE.loaded_integrations.update({"wifi", "logger"})
validator = cv.requires_component("wifi")
result = validator("test_value")
assert result == "test_value"
def test_requires_component_fails_when_not_loaded() -> None:
"""Test requires_component raises Invalid when the required component is not loaded."""
CORE.loaded_integrations.add("logger")
validator = cv.requires_component("wifi")
with pytest.raises(Invalid) as exc_info:
validator("test_value")
assert "requires component wifi" in str(exc_info.value)
def test_conflicts_with_component_passes_when_not_loaded() -> None:
"""Test conflicts_with_component passes when the conflicting component is not loaded."""
CORE.loaded_integrations.update({"wifi", "logger"})
validator = cv.conflicts_with_component("esp32_hosted")
result = validator("test_value")
assert result == "test_value"
def test_conflicts_with_component_fails_when_loaded() -> None:
"""Test conflicts_with_component raises Invalid when the conflicting component is loaded."""
CORE.loaded_integrations.update({"wifi", "esp32_hosted"})
validator = cv.conflicts_with_component("esp32_hosted")
with pytest.raises(Invalid) as exc_info:
validator("test_value")
assert "not compatible with component esp32_hosted" in str(exc_info.value)
@@ -289,3 +289,56 @@ def test_model_with_full_update_every(
"full_update_every": 10,
}
)
def test_busy_pin_input_mode_ssd1677(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that busy_pin has input mode and cs/dc/reset pins have output mode for ssd1677."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"dimensions": {
"width": 200,
"height": 200,
},
}
)
# Verify that busy_pin has input mode set
assert CONF_BUSY_PIN in result
busy_pin_config = result[CONF_BUSY_PIN]
assert "mode" in busy_pin_config
assert busy_pin_config["mode"]["input"] is True
# Verify that cs_pin has output mode set
assert CONF_CS_PIN in result
cs_pin_config = result[CONF_CS_PIN]
assert "mode" in cs_pin_config
assert cs_pin_config["mode"]["output"] is True
# Verify that dc_pin has output mode set
assert CONF_DC_PIN in result
dc_pin_config = result[CONF_DC_PIN]
assert "mode" in dc_pin_config
assert dc_pin_config["mode"]["output"] is True
# Verify that reset_pin has output mode set
assert CONF_RESET_PIN in result
reset_pin_config = result[CONF_RESET_PIN]
assert "mode" in reset_pin_config
assert reset_pin_config["mode"]["output"] is True
@@ -0,0 +1,12 @@
sensor:
- id: my_sensor
platform: adc
pin: GPIO1
name: ADC Test sensor
update_interval: "1:01"
attenuation: 2.5db
unit_of_measurement: "°C"
icon: "mdi:water-percent"
accuracy_decimals: 5
setup_priority: -100
force_update: true
@@ -0,0 +1,11 @@
dlms_meter:
decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key!
sensor:
- platform: dlms_meter
reactive_energy_plus:
name: "Reactive energy taken from grid"
reactive_energy_minus:
name: "Reactive energy put into grid"
<<: !include common.yaml
@@ -0,0 +1,17 @@
dlms_meter:
decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key!
provider: netznoe # (optional) key - only set if using evn
sensor:
- platform: dlms_meter
# EVN
power_factor:
name: "Power Factor"
text_sensor:
- platform: dlms_meter
# EVN
meternumber:
name: "meterNumber"
<<: !include common.yaml
+27
View File
@@ -0,0 +1,27 @@
sensor:
- platform: dlms_meter
voltage_l1:
name: "Voltage L1"
voltage_l2:
name: "Voltage L2"
voltage_l3:
name: "Voltage L3"
current_l1:
name: "Current L1"
current_l2:
name: "Current L2"
current_l3:
name: "Current L3"
active_power_plus:
name: "Active power taken from grid"
active_power_minus:
name: "Active power put into grid"
active_energy_plus:
name: "Active energy taken from grid"
active_energy_minus:
name: "Active energy put into grid"
text_sensor:
- platform: dlms_meter
timestamp:
name: "timestamp"
@@ -0,0 +1,4 @@
packages:
uart_2400: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml
<<: !include common-generic.yaml
@@ -0,0 +1,4 @@
packages:
uart_2400: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml
<<: !include common-netznoe.yaml
@@ -0,0 +1,4 @@
packages:
uart_2400: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml
<<: !include common-generic.yaml
@@ -25,6 +25,22 @@ display:
lambda: |-
it.circle(64, 64, 50, Color::BLACK);
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-1.54in-G
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-v3
@@ -6,8 +6,8 @@ esp32:
type: esp-idf
components:
- espressif/mdns^1.8.2
- name: espressif/esp_hosted
ref: 2.7.0
- name: espressif/button
ref: 4.1.5
advanced:
enable_idf_experimental_features: yes
disable_debug_stubs: true
@@ -0,0 +1,8 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml
esp32_ble:
io_capability: keyboard_only
disable_bt_logs: false
@@ -0,0 +1,7 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
# tx_power is not supported on ESP-Hosted platforms
esp32_ble_beacon:
type: iBeacon
uuid: 'c29ce823-e67a-4e71-bff2-abaa32e77a98'
@@ -0,0 +1,6 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
ble_client:
- mac_address: 01:02:03:04:05:06
id: blec
@@ -0,0 +1,4 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,7 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml
<<: !include common.yaml
esp32_ble_tracker:
max_connections: 9
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -12,3 +12,7 @@ ethernet:
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -1,2 +1,6 @@
ethernet:
type: OPENETH
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -13,3 +13,7 @@ ethernet:
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,16 @@
substitutions:
i2s_bclk_pin: GPIO15
i2s_lrclk_pin: GPIO4
i2s_mclk_pin: GPIO5
<<: !include common.yaml
wifi:
ssid: test
password: test1234
media_player:
- platform: i2s_audio
name: Test Media Player
dac_type: internal
mode: stereo
@@ -18,14 +18,23 @@ key_collector:
- logger.log:
format: "input progress: '%s', started by '%c'"
args: ['x.c_str()', "(start == 0 ? '~' : start)"]
- logger.log:
format: "second listener - progress: '%s'"
args: ['x.c_str()']
on_result:
- logger.log:
format: "input result: '%s', started by '%c', ended by '%c'"
args: ['x.c_str()', "(start == 0 ? '~' : start)", "(end == 0 ? '~' : end)"]
- logger.log:
format: "second listener - result: '%s'"
args: ['x.c_str()']
on_timeout:
- logger.log:
format: "input timeout: '%s', started by '%c'"
args: ['x.c_str()', "(start == 0 ? '~' : start)"]
- logger.log:
format: "second listener - timeout: '%s'"
args: ['x.c_str()']
enable_on_boot: false
button:
@@ -34,3 +43,8 @@ button:
on_press:
- key_collector.enable:
- key_collector.disable:
text_sensor:
- platform: key_collector
id: collected_keys
source_id: reader
+3
View File
@@ -1,5 +1,8 @@
ld2450:
- id: ld2450_radar
on_data:
then:
- logger.log: "LD2450 Radar Data Received"
button:
- platform: ld2450
@@ -5,3 +5,4 @@ esphome:
logger:
level: DEBUG
task_log_buffer_size: 0
+2
View File
@@ -20,6 +20,8 @@ lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
top_layer:
- id: lvgl_1
displays: sdl1
on_idle:
+52
View File
@@ -0,0 +1,52 @@
display:
- platform: mipi_rgb
spi_id: spi_bus
model: ZX2D10GE01R-V4848
update_interval: 1s
color_order: BGR
draw_rounding: 2
pixel_mode: 18bit
invert_colors: false
use_axis_flips: true
pclk_frequency: 15000000.0
pclk_inverted: true
byte_order: big_endian
hsync_pulse_width: 10
hsync_back_porch: 10
hsync_front_porch: 10
vsync_pulse_width: 2
vsync_back_porch: 12
vsync_front_porch: 14
data_pins:
red:
- number: 10
- number: 16
- number: 9
- number: 15
- number: 46
green:
- number: 8
- number: 13
- number: 18
- number: 12
- number: 11
- number: 17
blue:
- number: 47
- number: 1
- number: 0
- number: 42
- number: 14
de_pin:
number: 39
pclk_pin:
number: 45
hsync_pin:
number: 38
vsync_pin:
number: 48
data_rate: 1000000.0
spi_mode: MODE0
cs_pin:
number: 21
show_test_card: true
@@ -0,0 +1,6 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-p4-idf.yaml
psram:
<<: !include common.yaml
@@ -4,58 +4,4 @@ packages:
psram:
mode: octal
display:
- platform: mipi_rgb
spi_id: spi_bus
model: ZX2D10GE01R-V4848
update_interval: 1s
color_order: BGR
draw_rounding: 2
pixel_mode: 18bit
invert_colors: false
use_axis_flips: true
pclk_frequency: 15000000.0
pclk_inverted: true
byte_order: big_endian
hsync_pulse_width: 10
hsync_back_porch: 10
hsync_front_porch: 10
vsync_pulse_width: 2
vsync_back_porch: 12
vsync_front_porch: 14
data_pins:
red:
- number: 10
- number: 16
- number: 9
- number: 15
- number: 46
ignore_strapping_warning: true
green:
- number: 8
- number: 13
- number: 18
- number: 12
- number: 11
- number: 17
blue:
- number: 47
- number: 1
- number: 0
ignore_strapping_warning: true
- number: 42
- number: 14
de_pin:
number: 39
pclk_pin:
number: 45
ignore_strapping_warning: true
hsync_pin:
number: 38
vsync_pin:
number: 48
data_rate: 1000000.0
spi_mode: MODE0
cs_pin:
number: 21
show_test_card: true
<<: !include common.yaml
@@ -0,0 +1,10 @@
substitutions:
dc_pin: GPIO15
cs_pin: GPIO5
enable_pin: GPIO4
reset_pin: GPIO16
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
<<: !include common.yaml
+48
View File
@@ -219,6 +219,7 @@ cover:
name: Template Cover
state_topic: some/topic/cover
qos: 2
mqtt_json_state_payload: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
@@ -231,6 +232,53 @@ cover:
stop_action:
- logger.log: stop_action
optimistic: true
- platform: template
name: Template Cover with Position and Tilt
state_topic: some/topic/cover_pt
position_state_topic: some/topic/cover_pt/position
position_command_topic: some/topic/cover_pt/position/set
tilt_state_topic: some/topic/cover_pt/tilt
tilt_command_topic: some/topic/cover_pt/tilt/set
qos: 2
has_position: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
}
return COVER_CLOSED;
position_action:
- logger.log: position_action
tilt_action:
- logger.log: tilt_action
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
optimistic: true
- platform: template
name: Template Cover with Position and Tilt JSON
state_topic: some/topic/cover_pt_json
qos: 2
mqtt_json_state_payload: true
has_position: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
}
return COVER_CLOSED;
position_action:
- logger.log: position_action
tilt_action:
- logger.log: tilt_action
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
optimistic: true
datetime:
- platform: template
@@ -13,6 +13,8 @@ esphome:
id: template_water_heater
target_temperature: 50.0
mode: ECO
away: false
is_on: true
# Templated
- water_heater.template.publish:
@@ -20,6 +22,8 @@ esphome:
current_temperature: !lambda "return 45.0;"
target_temperature: !lambda "return 55.0;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_GAS;"
away: !lambda "return true;"
is_on: !lambda "return false;"
# Test C++ API: set_template() with stateless lambda (no captures)
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
@@ -245,6 +249,44 @@ cover:
stop_action:
- logger.log: stop_action
optimistic: true
- platform: template
name: "Template Cover with Triggers"
id: template_cover_with_triggers
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
}
return COVER_CLOSED;
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
optimistic: true
on_open:
- logger.log: "Cover on_open (deprecated)"
on_opened:
- logger.log: "Cover fully opened"
on_closed:
- logger.log: "Cover fully closed"
on_opening:
- logger.log: "Cover started opening"
on_closing:
- logger.log: "Cover started closing"
on_idle:
- logger.log: "Cover stopped moving"
- logger.log: "Cover stopped moving"
- if:
condition:
cover.is_open: template_cover_with_triggers
then:
logger.log: Cover is open
- if:
condition:
cover.is_closed: template_cover_with_triggers
then:
logger.log: Cover is closed
number:
- platform: template
@@ -296,6 +338,16 @@ select:
// Migration guide: Store in std::string
std::string stored_option(id(template_select).current_option());
ESP_LOGI("test", "Stored: %s", stored_option.c_str());
- platform: template
id: template_select_with_action
name: "Template select with action"
options:
- option_a
- option_b
set_action:
- logger.log:
format: "Selected: %s"
args: ["x.c_str()"]
lock:
- platform: template
@@ -364,7 +416,10 @@ water_heater:
name: "Template Water Heater"
optimistic: true
current_temperature: !lambda "return 42.0f;"
target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
away: !lambda "return false;"
is_on: !lambda "return true;"
supported_modes:
- "OFF"
- ECO
+1 -1
View File
@@ -29,7 +29,7 @@ class MockUARTComponent : public UARTComponent {
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(int, available, (), (override));
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(void, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
+41 -9
View File
@@ -4,11 +4,21 @@ binary_sensor:
- platform: vbus
model: deltasol_bs_plus
relay1:
name: Relay 1 On
name: BS Plus Relay 1 On
relay2:
name: Relay 2 On
name: BS Plus Relay 2 On
sensor1_error:
name: Sensor 1 Error
name: BS Plus Sensor 1 Error
- platform: vbus
model: deltasol_bs2
sensor1_error:
name: BS2 Sensor 1 Error
sensor2_error:
name: BS2 Sensor 2 Error
sensor3_error:
name: BS2 Sensor 3 Error
sensor4_error:
name: BS2 Sensor 4 Error
- platform: vbus
model: custom
command: 0x100
@@ -23,14 +33,36 @@ sensor:
- platform: vbus
model: deltasol c
temperature_1:
name: Temperature 1
name: DeltaSol C Temperature 1
temperature_2:
name: Temperature 2
name: DeltaSol C Temperature 2
temperature_3:
name: Temperature 3
name: DeltaSol C Temperature 3
operating_hours_1:
name: Operating Hours 1
name: DeltaSol C Operating Hours 1
heat_quantity:
name: Heat Quantity
name: DeltaSol C Heat Quantity
time:
name: System Time
name: DeltaSol C System Time
- platform: vbus
model: deltasol_bs2
temperature_1:
name: BS2 Temperature 1
temperature_2:
name: BS2 Temperature 2
temperature_3:
name: BS2 Temperature 3
temperature_4:
name: BS2 Temperature 4
pump_speed_1:
name: BS2 Pump Speed 1
pump_speed_2:
name: BS2 Pump Speed 2
operating_hours_1:
name: BS2 Operating Hours 1
operating_hours_2:
name: BS2 Operating Hours 2
heat_quantity:
name: BS2 Heat Quantity
version:
name: BS2 Firmware Version
@@ -68,3 +68,24 @@ voice_assistant:
- logger.log:
format: "Voice assistant error - code %s, message: %s"
args: [code.c_str(), message.c_str()]
on_timer_started:
- logger.log:
format: "Timer started: %s"
args: [timer.id.c_str()]
on_timer_updated:
- logger.log:
format: "Timer updated: %s"
args: [timer.id.c_str()]
on_timer_cancelled:
- logger.log:
format: "Timer cancelled: %s"
args: [timer.id.c_str()]
on_timer_finished:
- logger.log:
format: "Timer finished: %s"
args: [timer.id.c_str()]
on_timer_tick:
- lambda: |-
for (auto &timer : timers) {
ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left);
}
@@ -58,3 +58,24 @@ voice_assistant:
- logger.log:
format: "Voice assistant error - code %s, message: %s"
args: [code.c_str(), message.c_str()]
on_timer_started:
- logger.log:
format: "Timer started: %s"
args: [timer.id.c_str()]
on_timer_updated:
- logger.log:
format: "Timer updated: %s"
args: [timer.id.c_str()]
on_timer_cancelled:
- logger.log:
format: "Timer cancelled: %s"
args: [timer.id.c_str()]
on_timer_finished:
- logger.log:
format: "Timer finished: %s"
args: [timer.id.c_str()]
on_timer_tick:
- lambda: |-
for (auto &timer : timers) {
ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left);
}
+4
View File
@@ -26,3 +26,7 @@ wifi:
- ssid: MySSID3
password: password3
priority: 0
on_connect:
- logger.log: "WiFi connected!"
on_disconnect:
- logger.log: "WiFi disconnected!"
+80 -2
View File
@@ -3,11 +3,12 @@
from __future__ import annotations
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from icmplib import NameLookupError
import pytest
from esphome.dashboard.dns import DNSCache
from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper
@pytest.fixture
@@ -119,3 +120,80 @@ def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
assert result == ["192.168.1.10"]
mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_async_resolve_wrapper_ip_address() -> None:
"""Test _async_resolve_wrapper returns IP address directly."""
result = await _async_resolve_wrapper("192.168.1.10")
assert result == ["192.168.1.10"]
result = await _async_resolve_wrapper("2001:db8::1")
assert result == ["2001:db8::1"]
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_success() -> None:
"""Test _async_resolve_wrapper falls back to bare hostname for .local."""
mock_resolve = AsyncMock()
# First call (device.local) fails, second call (device) succeeds
mock_resolve.side_effect = [
NameLookupError("device.local"),
["192.168.1.50"],
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
assert mock_resolve.call_count == 2
mock_resolve.assert_any_call("device.local")
mock_resolve.assert_any_call("device")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_both_fail() -> None:
"""Test _async_resolve_wrapper returns exception when both fail."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.local")
mock_resolve.side_effect = [
original_exception,
NameLookupError("device"),
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
# Should return the original exception, not the fallback exception
assert result is original_exception
assert mock_resolve.call_count == 2
@pytest.mark.asyncio
async def test_async_resolve_wrapper_non_local_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback for non-.local hostnames."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.example.com")
mock_resolve.side_effect = original_exception
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.example.com")
assert result is original_exception
# Should only try the original hostname, no fallback
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.example.com")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_success_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback when .local succeeds."""
mock_resolve = AsyncMock(return_value=["192.168.1.50"])
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
# Should only try once since it succeeded
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.local")
+65 -1
View File
@@ -1,4 +1,4 @@
"""Tests for dashboard settings Path-related functionality."""
"""Tests for DashboardSettings (path resolution and authentication)."""
from __future__ import annotations
@@ -10,6 +10,7 @@ import pytest
from esphome.core import CORE
from esphome.dashboard.settings import DashboardSettings
from esphome.dashboard.util.password import password_hash
@pytest.fixture
@@ -221,3 +222,66 @@ def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
# Verify that CORE.config_path itself uses the sentinel file
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist
@pytest.fixture
def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings:
"""Create DashboardSettings with auth configured, based on dashboard_settings."""
dashboard_settings.username = "admin"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("correctpassword")
return dashboard_settings
def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None:
"""Test check_password returns True for correct username and password."""
assert auth_settings.check_password("admin", "correctpassword") is True
def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong password."""
assert auth_settings.check_password("admin", "wrongpassword") is False
def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong username."""
assert auth_settings.check_password("notadmin", "correctpassword") is False
def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False when both are wrong."""
assert auth_settings.check_password("notadmin", "wrongpassword") is False
def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None:
"""Test check_password returns True when auth is not configured."""
assert dashboard_settings.check_password("anyone", "anything") is True
def test_check_password_non_ascii_username(
dashboard_settings: DashboardSettings,
) -> None:
"""Test check_password handles non-ASCII usernames without TypeError."""
dashboard_settings.username = "\u00e9l\u00e8ve"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("pass")
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False
assert dashboard_settings.check_password("other", "pass") is False
def test_check_password_ha_addon_no_password(
dashboard_settings: DashboardSettings,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_password doesn't crash in HA add-on mode without a password.
In HA add-on mode, using_ha_addon_auth can be True while using_password
is False, leaving password_hash as b"". This must not raise TypeError
in hmac.compare_digest.
"""
monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False)
dashboard_settings.on_ha_addon = True
dashboard_settings.using_password = False
# password_hash stays as default b""
assert dashboard_settings.check_password("anyone", "anything") is False
+185 -15
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
from argparse import Namespace
import asyncio
import base64
from collections.abc import Generator
from contextlib import asynccontextmanager
import gzip
import json
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -29,7 +31,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state,
)
from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path
@@ -421,7 +423,7 @@ async def test_download_binary_handler_idedata_fallback(
# Mock idedata response
mock_image = Mock()
mock_image.path = str(bootloader_file)
mock_image.path = bootloader_file
mock_idedata_instance = Mock()
mock_idedata_instance.extra_flash_images = [mock_image]
mock_idedata.return_value = mock_idedata_instance
@@ -528,14 +530,22 @@ async def test_download_binary_handler_subdirectory_file_url_encoded(
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize(
"attack_path",
("attack_path", "expected_code"),
[
pytest.param("../../../secrets.yaml", id="basic_traversal"),
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
pytest.param("/etc/passwd", id="absolute_path"),
pytest.param("//etc/passwd", id="double_slash_absolute"),
pytest.param("....//secrets.yaml", id="multiple_dots"),
pytest.param("../../../secrets.yaml", 403, id="basic_traversal"),
pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"),
pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"),
pytest.param("/etc/passwd", 403, id="absolute_path"),
pytest.param("//etc/passwd", 403, id="double_slash_absolute"),
pytest.param(
"....//secrets.yaml",
# On Windows, Path.resolve() treats "..." and "...." as parent
# traversal (like ".."), so the path escapes base_dir -> 403.
# On Unix, "...." is a literal directory name that stays inside
# base_dir but doesn't exist -> 404.
403 if sys.platform == "win32" else 404,
id="multiple_dots",
),
],
)
async def test_download_binary_handler_path_traversal_protection(
@@ -543,11 +553,14 @@ async def test_download_binary_handler_path_traversal_protection(
tmp_path: Path,
mock_storage_json: MagicMock,
attack_path: str,
expected_code: int,
) -> None:
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
Verifies that attempts to use '..' in file paths are sanitized to prevent
accessing files outside the build directory. Tests multiple attack vectors.
Verifies that attempts to escape the build directory via '..' are rejected
using resolve()/relative_to() validation. Tests multiple attack vectors.
Real traversals that escape the base directory get 403. Paths like '....'
that resolve inside the base directory but don't exist get 404.
"""
# Create build structure
build_dir = get_build_path(tmp_path, "test")
@@ -565,14 +578,67 @@ async def test_download_binary_handler_path_traversal_protection(
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Attempt path traversal attack - should be blocked
with pytest.raises(HTTPClientError) as exc_info:
# Mock async_run_system_command so paths that pass validation but don't exist
# return 404 deterministically without spawning a real subprocess.
with (
patch(
"esphome.dashboard.web_server.async_run_system_command",
new_callable=AsyncMock,
return_value=(2, "", ""),
),
pytest.raises(HTTPClientError) as exc_info,
):
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={attack_path}",
method="GET",
)
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
assert exc_info.value.code in (404, 500)
assert exc_info.value.code == expected_code
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_no_firmware_bin_path(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
) -> None:
"""Test that download returns 404 when firmware_bin_path is None.
This covers configs created by StorageJSON.from_wizard() where no
firmware has been compiled yet.
"""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = None
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin",
method="GET",
)
assert exc_info.value.code == 404
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"])
async def test_download_binary_handler_empty_file_name(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
file_value: str,
) -> None:
"""Test that download returns 400 for empty or whitespace-only file names."""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = Path("/fake/firmware.bin")
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={file_value}",
method="GET",
)
assert exc_info.value.code == 400
@pytest.mark.asyncio
@@ -1654,3 +1720,107 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state"
finally:
ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()
def _make_auth_handler(auth_header: str | None = None) -> Mock:
"""Create a mock handler with the given Authorization header."""
handler = Mock()
handler.request = Mock()
if auth_header is not None:
handler.request.headers = {"Authorization": auth_header}
else:
handler.request.headers = {}
handler.get_secure_cookie = Mock(return_value=None)
return handler
@pytest.fixture
def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock:
"""Fixture to configure mock dashboard settings with auth enabled."""
mock_dashboard_settings.using_auth = True
mock_dashboard_settings.on_ha_addon = False
return mock_dashboard_settings
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_malformed_base64() -> None:
"""Test that invalid base64 in Authorization header returns False."""
handler = _make_auth_handler("Basic !!!not-valid-base64!!!")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_bad_base64_padding() -> None:
"""Test that incorrect base64 padding (binascii.Error) returns False."""
handler = _make_auth_handler("Basic abc")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_invalid_utf8() -> None:
"""Test that base64 decoding to invalid UTF-8 returns False."""
# \xff\xfe is invalid UTF-8
bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii")
handler = _make_auth_handler(f"Basic {bad_payload}")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_no_colon() -> None:
"""Test that base64 payload without ':' separator returns False."""
no_colon = base64.b64encode(b"nocolonhere").decode("ascii")
handler = _make_auth_handler(f"Basic {no_colon}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_valid_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth credentials are checked."""
creds = base64.b64encode(b"admin:secret").decode("ascii")
mock_auth_settings.check_password.return_value = True
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is True
mock_auth_settings.check_password.assert_called_once_with("admin", "secret")
def test_is_authenticated_wrong_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth with wrong credentials returns False."""
creds = base64.b64encode(b"admin:wrong").decode("ascii")
mock_auth_settings.check_password.return_value = False
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_no_auth_configured(
mock_dashboard_settings: MagicMock,
) -> None:
"""Test that requests pass when auth is not configured."""
mock_dashboard_settings.using_auth = False
mock_dashboard_settings.on_ha_addon = False
handler = _make_auth_handler()
assert web_server.is_authenticated(handler) is True
+1
View File
@@ -197,6 +197,7 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
" platformio_options:\n"
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
)
@@ -0,0 +1,109 @@
esphome:
name: scheduler-internal-id-test
on_boot:
priority: -100
then:
- logger.log: "Starting scheduler internal ID collision tests"
host:
api:
logger:
level: VERBOSE
globals:
- id: tests_done
type: bool
initial_value: 'false'
script:
- id: test_internal_id_no_collision
then:
- logger.log: "Testing NUMERIC_ID_INTERNAL vs NUMERIC_ID isolation"
- lambda: |-
// All tests use the same component and the same uint32_t value (0).
// NUMERIC_ID_INTERNAL and NUMERIC_ID are separate NameType values,
// so the scheduler must treat them as independent timers.
auto *comp = id(test_sensor);
// ---- Test 1: Both timeout types fire independently ----
// Set an internal timeout with ID 0
App.scheduler.set_timeout(comp, InternalSchedulerID{0}, 50, []() {
ESP_LOGI("test", "Internal timeout 0 fired");
});
// Set a component numeric timeout with the same ID 0
App.scheduler.set_timeout(comp, 0U, 50, []() {
ESP_LOGI("test", "Numeric timeout 0 fired");
});
// ---- Test 2: Cancelling numeric ID does NOT cancel internal ID ----
// Set an internal timeout with ID 1
App.scheduler.set_timeout(comp, InternalSchedulerID{1}, 100, []() {
ESP_LOGI("test", "Internal timeout 1 survived cancel");
});
// Set a numeric timeout with the same ID 1
App.scheduler.set_timeout(comp, 1U, 100, []() {
ESP_LOGE("test", "ERROR: Numeric timeout 1 should have been cancelled");
});
// Cancel only the numeric one
App.scheduler.cancel_timeout(comp, 1U);
// ---- Test 3: Cancelling internal ID does NOT cancel numeric ID ----
// Set a numeric timeout with ID 2
App.scheduler.set_timeout(comp, 2U, 150, []() {
ESP_LOGI("test", "Numeric timeout 2 survived cancel");
});
// Set an internal timeout with the same ID 2
App.scheduler.set_timeout(comp, InternalSchedulerID{2}, 150, []() {
ESP_LOGE("test", "ERROR: Internal timeout 2 should have been cancelled");
});
// Cancel only the internal one
App.scheduler.cancel_timeout(comp, InternalSchedulerID{2});
// ---- Test 4: Both interval types fire independently ----
static int internal_interval_count = 0;
static int numeric_interval_count = 0;
App.scheduler.set_interval(comp, InternalSchedulerID{3}, 100, []() {
internal_interval_count++;
if (internal_interval_count == 2) {
ESP_LOGI("test", "Internal interval 3 fired twice");
App.scheduler.cancel_interval(id(test_sensor), InternalSchedulerID{3});
}
});
App.scheduler.set_interval(comp, 3U, 100, []() {
numeric_interval_count++;
if (numeric_interval_count == 2) {
ESP_LOGI("test", "Numeric interval 3 fired twice");
App.scheduler.cancel_interval(id(test_sensor), 3U);
}
});
// ---- Test 5: String name does NOT collide with internal ID ----
// Use string name and internal ID 10 on same component
App.scheduler.set_timeout(comp, "collision_test", 200, []() {
ESP_LOGI("test", "String timeout collision_test fired");
});
App.scheduler.set_timeout(comp, InternalSchedulerID{10}, 200, []() {
ESP_LOGI("test", "Internal timeout 10 fired");
});
// Log completion after all timers should have fired
App.scheduler.set_timeout(comp, 9999U, 1500, []() {
ESP_LOGI("test", "All collision tests complete");
});
sensor:
- platform: template
name: Test Sensor
id: test_sensor
lambda: return 1.0;
update_interval: never
interval:
- interval: 0.1s
then:
- if:
condition:
lambda: 'return id(tests_done) == false;'
then:
- lambda: 'id(tests_done) = true;'
- script.execute: test_internal_id_no_collision
@@ -20,6 +20,9 @@ globals:
- id: retry_counter
type: int
initial_value: '0'
- id: defer_counter
type: int
initial_value: '0'
- id: tests_done
type: bool
initial_value: 'false'
@@ -136,11 +139,49 @@ script:
App.scheduler.cancel_retry(component1, 6002U);
ESP_LOGI("test", "Cancelled numeric retry 6002");
// Test 12: defer with numeric ID (Component method)
class TestDeferComponent : public Component {
public:
void test_defer_methods() {
// Test defer with uint32_t ID - should execute on next loop
this->defer(7001U, []() {
ESP_LOGI("test", "Component numeric defer 7001 fired");
id(defer_counter) += 1;
});
// Test another defer with numeric ID
this->defer(7002U, []() {
ESP_LOGI("test", "Component numeric defer 7002 fired");
id(defer_counter) += 1;
});
}
};
static TestDeferComponent test_defer_component;
test_defer_component.test_defer_methods();
// Test 13: cancel_defer with numeric ID (Component method)
class TestCancelDeferComponent : public Component {
public:
void test_cancel_defer() {
// Set a defer that should be cancelled
this->defer(8001U, []() {
ESP_LOGE("test", "ERROR: Numeric defer 8001 should have been cancelled");
});
// Cancel it immediately
bool cancelled = this->cancel_defer(8001U);
ESP_LOGI("test", "Cancelled numeric defer 8001: %s", cancelled ? "true" : "false");
}
};
static TestCancelDeferComponent test_cancel_defer_component;
test_cancel_defer_component.test_cancel_defer();
- id: report_results
then:
- lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d",
id(timeout_counter), id(interval_counter), id(retry_counter));
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
sensor:
- platform: template
@@ -56,7 +56,21 @@ select:
std::string prefix = x.substr(0, 6);
ESP_LOGI("test", "Substr prefix: %s", prefix.c_str());
# Second select with numeric options to test ADL functions
# Second select with set_action trigger (uses TemplateSelectWithSetAction subclass)
- platform: template
name: "Action Select"
id: action_select
options:
- "Action A"
- "Action B"
set_action:
then:
# Test: set_action trigger receives StringRef
- logger.log:
format: "set_action triggered: %s"
args: ['x.c_str()']
# Third select with numeric options to test ADL functions
- platform: template
name: "Baud Rate"
id: baud_select
@@ -4,12 +4,23 @@ host:
api:
logger:
globals:
- id: global_away
type: bool
initial_value: "false"
- id: global_is_on
type: bool
initial_value: "true"
water_heater:
- platform: template
id: test_boiler
name: Test Boiler
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);"
# Note: No mode lambda - we want optimistic mode changes to stick
# A mode lambda would override mode changes in loop()
supported_modes:
@@ -21,3 +32,8 @@ water_heater:
min_temperature: 30.0
max_temperature: 85.0
target_temperature_step: 0.5
set_action:
- lambda: |-
// Sync optimistic state back to globals so lambdas reflect the change
id(global_away) = id(test_boiler).is_away();
id(global_is_on) = id(test_boiler).is_on();
@@ -0,0 +1,124 @@
"""Test that NUMERIC_ID_INTERNAL and NUMERIC_ID cannot collide.
Verifies that InternalSchedulerID (used by core base classes like
PollingComponent and DelayAction) and uint32_t numeric IDs (used by
components) are in completely separate matching namespaces, even when
the underlying uint32_t values are identical and on the same component.
"""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_internal_id_no_collision(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that internal and numeric IDs with same value don't collide."""
# Test 1: Both types fire independently with same ID
internal_timeout_0_fired = asyncio.Event()
numeric_timeout_0_fired = asyncio.Event()
# Test 2: Cancelling numeric doesn't cancel internal
internal_timeout_1_survived = asyncio.Event()
numeric_timeout_1_error = asyncio.Event()
# Test 3: Cancelling internal doesn't cancel numeric
numeric_timeout_2_survived = asyncio.Event()
internal_timeout_2_error = asyncio.Event()
# Test 4: Both interval types fire independently
internal_interval_3_done = asyncio.Event()
numeric_interval_3_done = asyncio.Event()
# Test 5: String name doesn't collide with internal ID
string_timeout_fired = asyncio.Event()
internal_timeout_10_fired = asyncio.Event()
# Completion
all_tests_complete = asyncio.Event()
def on_log_line(line: str) -> None:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "Internal timeout 0 fired" in clean_line:
internal_timeout_0_fired.set()
elif "Numeric timeout 0 fired" in clean_line:
numeric_timeout_0_fired.set()
elif "Internal timeout 1 survived cancel" in clean_line:
internal_timeout_1_survived.set()
elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line:
numeric_timeout_1_error.set()
elif "Numeric timeout 2 survived cancel" in clean_line:
numeric_timeout_2_survived.set()
elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line:
internal_timeout_2_error.set()
elif "Internal interval 3 fired twice" in clean_line:
internal_interval_3_done.set()
elif "Numeric interval 3 fired twice" in clean_line:
numeric_interval_3_done.set()
elif "String timeout collision_test fired" in clean_line:
string_timeout_fired.set()
elif "Internal timeout 10 fired" in clean_line:
internal_timeout_10_fired.set()
elif "All collision tests complete" in clean_line:
all_tests_complete.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-internal-id-test"
try:
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail("Not all collision tests completed within 5 seconds")
# Test 1: Both timeout types with same ID 0 must fire
assert internal_timeout_0_fired.is_set(), (
"Internal timeout with ID 0 should have fired"
)
assert numeric_timeout_0_fired.is_set(), (
"Numeric timeout with ID 0 should have fired"
)
# Test 2: Cancelling numeric ID must NOT cancel internal ID
assert internal_timeout_1_survived.is_set(), (
"Internal timeout 1 should survive cancellation of numeric timeout 1"
)
assert not numeric_timeout_1_error.is_set(), (
"Numeric timeout 1 should have been cancelled"
)
# Test 3: Cancelling internal ID must NOT cancel numeric ID
assert numeric_timeout_2_survived.is_set(), (
"Numeric timeout 2 should survive cancellation of internal timeout 2"
)
assert not internal_timeout_2_error.is_set(), (
"Internal timeout 2 should have been cancelled"
)
# Test 4: Both interval types with same ID must fire independently
assert internal_interval_3_done.is_set(), (
"Internal interval 3 should have fired at least twice"
)
assert numeric_interval_3_done.is_set(), (
"Numeric interval 3 should have fired at least twice"
)
# Test 5: String name and internal ID don't collide
assert string_timeout_fired.is_set(), (
"String timeout 'collision_test' should have fired"
)
assert internal_timeout_10_fired.is_set(), (
"Internal timeout 10 should have fired alongside string timeout"
)
@@ -19,6 +19,7 @@ async def test_scheduler_numeric_id_test(
timeout_count = 0
interval_count = 0
retry_count = 0
defer_count = 0
# Events for each test completion
numeric_timeout_1001_fired = asyncio.Event()
@@ -33,6 +34,9 @@ async def test_scheduler_numeric_id_test(
max_id_timeout_fired = asyncio.Event()
numeric_retry_done = asyncio.Event()
numeric_retry_cancelled = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
final_results_logged = asyncio.Event()
# Track interval counts
@@ -40,7 +44,7 @@ async def test_scheduler_numeric_id_test(
numeric_retry_count = 0
def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, retry_count
nonlocal timeout_count, interval_count, retry_count, defer_count
nonlocal numeric_interval_count, numeric_retry_count
# Strip ANSI color codes
@@ -105,15 +109,27 @@ async def test_scheduler_numeric_id_test(
elif "Cancelled numeric retry 6002" in clean_line:
numeric_retry_cancelled.set()
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
elif "Component numeric defer 7002 fired" in clean_line:
numeric_defer_7002_fired.set()
elif "Cancelled numeric defer 8001: true" in clean_line:
numeric_defer_cancelled.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
clean_line,
)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
retry_count = int(match.group(3))
defer_count = int(match.group(4))
final_results_logged.set()
async with (
@@ -201,6 +217,23 @@ async def test_scheduler_numeric_id_test(
"Numeric retry 6002 should have been cancelled"
)
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds")
# Verify numeric defer was cancelled
try:
await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Numeric defer 8001 cancel confirmation not received")
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
@@ -215,3 +248,4 @@ async def test_scheduler_numeric_id_test(
assert retry_count >= 2, (
f"Expected at least 2 retry attempts, got {retry_count}"
)
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"
+5 -3
View File
@@ -98,9 +98,11 @@ async def test_script_queued(
if not test3_complete.done():
loop.call_later(
0.3,
lambda: test3_complete.set_result(True)
if not test3_complete.done()
else None,
lambda: (
test3_complete.set_result(True)
if not test3_complete.done()
else None
),
)
# Test 4: Rejection
@@ -28,6 +28,8 @@ async def test_select_stringref_trigger(
find_substr_future = loop.create_future()
find_char_future = loop.create_future()
substr_future = loop.create_future()
# set_action trigger (TemplateSelectWithSetAction subclass)
set_action_future = loop.create_future()
# ADL functions
stoi_future = loop.create_future()
stol_future = loop.create_future()
@@ -43,6 +45,8 @@ async def test_select_stringref_trigger(
find_substr_pattern = re.compile(r"Found 'Option' in value")
find_char_pattern = re.compile(r"Space at position: 6") # space at index 6
substr_pattern = re.compile(r"Substr prefix: Option")
# set_action trigger pattern (TemplateSelectWithSetAction subclass)
set_action_pattern = re.compile(r"set_action triggered: Action B")
# ADL function patterns (115200 from baud rate select)
stoi_pattern = re.compile(r"stoi result: 115200")
stol_pattern = re.compile(r"stol result: 115200")
@@ -67,6 +71,9 @@ async def test_select_stringref_trigger(
find_char_future.set_result(True)
if not substr_future.done() and substr_pattern.search(line):
substr_future.set_result(True)
# set_action trigger
if not set_action_future.done() and set_action_pattern.search(line):
set_action_future.set_result(True)
# ADL functions
if not stoi_future.done() and stoi_pattern.search(line):
stoi_future.set_result(True)
@@ -89,22 +96,21 @@ async def test_select_stringref_trigger(
# List entities to find our select
entities, _ = await client.list_entities_services()
select_entity = next(
(e for e in entities if hasattr(e, "options") and e.name == "Test Select"),
None,
)
select_entity = next((e for e in entities if e.name == "Test Select"), None)
assert select_entity is not None, "Test Select entity not found"
baud_entity = next(
(e for e in entities if hasattr(e, "options") and e.name == "Baud Rate"),
None,
)
baud_entity = next((e for e in entities if e.name == "Baud Rate"), None)
assert baud_entity is not None, "Baud Rate entity not found"
action_entity = next((e for e in entities if e.name == "Action Select"), None)
assert action_entity is not None, "Action Select entity not found"
# Change select to Option B - this should trigger on_value with StringRef
client.select_command(select_entity.key, "Option B")
# Change baud to 115200 - this tests ADL functions (stoi, stol, stof, stod)
client.select_command(baud_entity.key, "115200")
# Change action select - tests set_action trigger (TemplateSelectWithSetAction)
client.select_command(action_entity.key, "Action B")
# Wait for all log messages confirming StringRef operations work
try:
@@ -118,6 +124,7 @@ async def test_select_stringref_trigger(
find_substr_future,
find_char_future,
substr_future,
set_action_future,
stoi_future,
stol_future,
stof_future,
@@ -135,6 +142,7 @@ async def test_select_stringref_trigger(
"find_substr": find_substr_future.done(),
"find_char": find_char_future.done(),
"substr": substr_future.done(),
"set_action": set_action_future.done(),
"stoi": stoi_future.done(),
"stol": stol_future.done(),
"stof": stof_future.done(),
+33 -6
View File
@@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]
sock.close()
def _get_free_udp_port() -> int:
"""Get a free UDP port by binding to port 0 and releasing."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
@pytest.mark.asyncio
async def test_udp_send_receive(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test UDP component can send messages with multiple addresses configured."""
# Track log lines to verify dump_config output
"""Test UDP component can send and receive messages."""
log_lines: list[str] = []
receive_event = asyncio.Event()
def on_log_line(line: str) -> None:
log_lines.append(line)
if "Received UDP:" in line:
receive_event.set()
async with udp_listener() as (udp_port, receiver):
# Replace placeholders in the config
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port))
async with udp_listener() as (broadcast_port, receiver):
listen_port = _get_free_udp_port()
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port))
async with (
run_compiled(config, line_callback=on_log_line),
@@ -169,3 +180,19 @@ async def test_udp_send_receive(
assert "Address: 127.0.0.2" in log_text, (
f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}"
)
# Test receiving a UDP packet (exercises on_receive with std::span)
test_payload = b"TEST_RECEIVE_UDP"
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
send_sock.sendto(test_payload, ("127.0.0.1", listen_port))
finally:
send_sock.close()
try:
await asyncio.wait_for(receive_event.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(
f"on_receive did not fire. Expected 'Received UDP:' in logs. "
f"Last log lines: {log_lines[-20:]}"
)
+68 -24
View File
@@ -5,7 +5,13 @@ from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
from aioesphomeapi import (
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
WaterHeaterStateFlag,
)
import pytest
from .state_utils import InitialStateHelper
@@ -22,18 +28,25 @@ async def test_water_heater_template(
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
states: dict[int, aioesphomeapi.EntityState] = {}
gas_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future()
eco_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future()
state_future: asyncio.Future[WaterHeaterState] | None = None
def on_state(state: aioesphomeapi.EntityState) -> None:
states[state.key] = state
if isinstance(state, WaterHeaterState):
# Wait for GAS mode
if state.mode == WaterHeaterMode.GAS and not gas_mode_future.done():
gas_mode_future.set_result(state)
# Wait for ECO mode (we start at OFF, so test transitioning to ECO)
elif state.mode == WaterHeaterMode.ECO and not eco_mode_future.done():
eco_mode_future.set_result(state)
if (
isinstance(state, WaterHeaterState)
and state_future is not None
and not state_future.done()
):
state_future.set_result(state)
async def wait_for_state(timeout: float = 5.0) -> WaterHeaterState:
"""Wait for next water heater state change."""
nonlocal state_future
state_future = loop.create_future()
try:
return await asyncio.wait_for(state_future, timeout)
finally:
state_future = None
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
@@ -85,25 +98,56 @@ async def test_water_heater_template(
assert initial_state.current_temperature == 45.0, (
f"Expected current temp 45.0, got {initial_state.current_temperature}"
)
assert initial_state.target_temperature == 60.0, (
f"Expected target temp 60.0, got {initial_state.target_temperature}"
)
# Verify supported features: away mode and on/off (fixture has away + is_on lambdas)
assert (
test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE
) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features"
assert (
test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF
) != 0, "Expected SUPPORTS_ON_OFF in supported_features"
# Verify initial state: on (is_on lambda returns true), not away (away lambda returns false)
assert (initial_state.state & WaterHeaterStateFlag.ON) != 0, (
"Expected initial state to include ON flag"
)
assert (initial_state.state & WaterHeaterStateFlag.AWAY) == 0, (
"Expected initial state to not include AWAY flag"
)
# Test turning on away mode
client.water_heater_command(test_water_heater.key, away=True)
away_on_state = await wait_for_state()
assert (away_on_state.state & WaterHeaterStateFlag.AWAY) != 0
# ON flag should still be set (is_on lambda returns true)
assert (away_on_state.state & WaterHeaterStateFlag.ON) != 0
# Test turning off away mode
client.water_heater_command(test_water_heater.key, away=False)
away_off_state = await wait_for_state()
assert (away_off_state.state & WaterHeaterStateFlag.AWAY) == 0
assert (away_off_state.state & WaterHeaterStateFlag.ON) != 0
# Test turning off (on=False)
client.water_heater_command(test_water_heater.key, on=False)
off_state = await wait_for_state()
assert (off_state.state & WaterHeaterStateFlag.ON) == 0
assert (off_state.state & WaterHeaterStateFlag.AWAY) == 0
# Test turning back on (on=True)
client.water_heater_command(test_water_heater.key, on=True)
on_state = await wait_for_state()
assert (on_state.state & WaterHeaterStateFlag.ON) != 0
# Test changing to GAS mode
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS)
try:
gas_state = await asyncio.wait_for(gas_mode_future, timeout=5.0)
except TimeoutError:
pytest.fail("GAS mode change not received within 5 seconds")
assert isinstance(gas_state, WaterHeaterState)
gas_state = await wait_for_state()
assert gas_state.mode == WaterHeaterMode.GAS
# Test changing to ECO mode (from GAS)
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO)
try:
eco_state = await asyncio.wait_for(eco_mode_future, timeout=5.0)
except TimeoutError:
pytest.fail("ECO mode change not received within 5 seconds")
assert isinstance(eco_state, WaterHeaterState)
eco_state = await wait_for_state()
assert eco_state.mode == WaterHeaterMode.ECO
+2 -2
View File
@@ -1011,8 +1011,8 @@ def test_get_all_dependencies_handles_missing_components() -> None:
comp.dependencies = ["missing_comp"]
comp.auto_load = []
mock_get_component.side_effect = (
lambda name: comp if name == "existing" else None
mock_get_component.side_effect = lambda name: (
comp if name == "existing" else None
)
result = helpers.get_all_dependencies({"existing", "nonexistent"})
@@ -0,0 +1,20 @@
esphome:
name: componenttestesp32c2idf
friendly_name: $component_name
esp32:
board: esp32-c2-devkitm-1
framework:
type: esp-idf
# Use custom partition table with larger app partition (3MB)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file
@@ -0,0 +1,21 @@
# Common BLE tracker configuration for ESP32-P4 IDF tests
# ESP32-P4 requires ESP-Hosted for Bluetooth via external coprocessor
# BLE client components share this tracker infrastructure
# Each component defines its own ble_client with unique MAC address
esp32_hosted:
active_high: true
variant: ESP32C6
reset_pin: GPIO54
cmd_pin: GPIO19
clk_pin: GPIO18
d0_pin: GPIO14
d1_pin: GPIO15
d2_pin: GPIO16
d3_pin: GPIO17
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true
@@ -0,0 +1,12 @@
# Common SPI configuration for ESP32-P4 IDF tests
substitutions:
clk_pin: GPIO36
mosi_pin: GPIO32
miso_pin: GPIO33
spi:
- id: spi_bus
clk_pin: ${clk_pin}
mosi_pin: ${mosi_pin}
miso_pin: ${miso_pin}
@@ -0,0 +1,11 @@
# Common UART configuration for ESP32 Arduino tests - 2400 baud
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 2400
@@ -0,0 +1,11 @@
# Common UART configuration for ESP32 IDF tests - 2400 baud
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 2400
@@ -0,0 +1,11 @@
# Common UART configuration for ESP8266 Arduino tests - 2400 baud
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 2400
+16 -10
View File
@@ -453,11 +453,14 @@ def test_preload_core_config_no_platform(setup_core: Path) -> None:
# Mock _is_target_platform to avoid expensive component loading
with patch("esphome.core.config._is_target_platform") as mock_is_platform:
# Return True for known platforms
mock_is_platform.side_effect = lambda name: name in [
"esp32",
"esp8266",
"rp2040",
]
mock_is_platform.side_effect = lambda name: (
name
in [
"esp32",
"esp8266",
"rp2040",
]
)
with pytest.raises(cv.Invalid, match="Platform missing"):
preload_core_config(config, result)
@@ -477,11 +480,14 @@ def test_preload_core_config_multiple_platforms(setup_core: Path) -> None:
# Mock _is_target_platform to avoid expensive component loading
with patch("esphome.core.config._is_target_platform") as mock_is_platform:
# Return True for known platforms
mock_is_platform.side_effect = lambda name: name in [
"esp32",
"esp8266",
"rp2040",
]
mock_is_platform.side_effect = lambda name: (
name
in [
"esp32",
"esp8266",
"rp2040",
]
)
with pytest.raises(cv.Invalid, match="Found multiple target platform blocks"):
preload_core_config(config, result)
+75
View File
@@ -780,3 +780,78 @@ class TestEsphomeCore:
target.config = {const.CONF_ESPHOME: {"name": "test"}, "logger": {}}
assert target.has_networking is False
def test_add_library__esp32_arduino_enables_disabled_library(self, target):
"""Test add_library auto-enables Arduino libraries on ESP32 Arduino builds."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_called_once_with("WiFi")
assert "WiFi" in target.platformio_libraries
def test_add_library__esp32_arduino_ignores_non_arduino_library(self, target):
"""Test add_library doesn't enable libraries not in ARDUINO_DISABLED_LIBRARIES."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("SomeOtherLib", "1.0.0")
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "SomeOtherLib" in target.platformio_libraries
def test_add_library__esp32_idf_does_not_enable_arduino_library(self, target):
"""Test add_library doesn't auto-enable Arduino libraries on ESP32 IDF builds."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "esp-idf",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "WiFi" in target.platformio_libraries
def test_add_library__esp8266_does_not_enable_arduino_library(self, target):
"""Test add_library doesn't auto-enable Arduino libraries on ESP8266."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp8266",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "WiFi" in target.platformio_libraries
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("arduino/Wire", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_called_once_with("Wire")
assert "Wire" in target.platformio_libraries
+277
View File
@@ -347,3 +347,280 @@ class TestMockObj:
assert isinstance(actual, cg.MockObj)
assert actual.base == "foo.eek"
assert actual.op == "."
class TestStatementFunction:
"""Tests for the statement() function."""
def test_statement__expression_converted_to_statement(self):
"""Test that expressions are converted to ExpressionStatement."""
expr = cg.RawExpression("foo()")
result = cg.statement(expr)
assert isinstance(result, cg.ExpressionStatement)
assert str(result) == "foo();"
def test_statement__statement_unchanged(self):
"""Test that statements are returned unchanged."""
stmt = cg.RawStatement("foo()")
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "foo()"
def test_statement__expression_statement_unchanged(self):
"""Test that ExpressionStatement is returned unchanged."""
stmt = cg.ExpressionStatement(42)
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "42;"
def test_statement__line_comment_unchanged(self):
"""Test that LineComment is returned unchanged."""
stmt = cg.LineComment("This is a comment")
result = cg.statement(stmt)
assert result is stmt
assert str(result) == "// This is a comment"
class TestLiteralFunction:
"""Tests for the literal() function."""
def test_literal__creates_mockobj(self):
"""Test that literal() creates a MockObj."""
result = cg.literal("MY_CONSTANT")
assert isinstance(result, cg.MockObj)
assert result.base == "MY_CONSTANT"
assert result.op == ""
def test_literal__string_representation(self):
"""Test that literal names appear unquoted in generated code."""
result = cg.literal("nullptr")
assert str(result) == "nullptr"
def test_literal__can_be_used_in_expressions(self):
"""Test that literals can be used as part of larger expressions."""
null_lit = cg.literal("nullptr")
expr = cg.CallExpression(cg.RawExpression("my_func"), null_lit)
assert str(expr) == "my_func(nullptr)"
def test_literal__common_cpp_literals(self):
"""Test common C++ literal values."""
test_cases = [
("nullptr", "nullptr"),
("true", "true"),
("false", "false"),
("NULL", "NULL"),
("NAN", "NAN"),
]
for name, expected in test_cases:
result = cg.literal(name)
assert str(result) == expected
class TestLambdaConstructor:
"""Tests for the Lambda class constructor in core/__init__.py."""
def test_lambda__from_string(self):
"""Test Lambda constructor with string argument."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
assert lambda_obj.value == "return x + 1;"
assert str(lambda_obj) == "return x + 1;"
def test_lambda__from_expression(self):
"""Test Lambda constructor with Expression argument."""
from esphome.core import Lambda
expr = cg.RawExpression("x + 1")
lambda_obj = Lambda(expr)
# Expression should be converted to statement (with semicolon)
assert lambda_obj.value == "x + 1;"
def test_lambda__from_lambda(self):
"""Test Lambda constructor with another Lambda argument."""
from esphome.core import Lambda
original = Lambda("return x + 1;")
copy = Lambda(original)
assert copy.value == original.value
assert copy.value == "return x + 1;"
def test_lambda__parts_parsing(self):
"""Test that Lambda correctly parses parts with id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return id(my_sensor).state;")
parts = lambda_obj.parts
# Parts should be split by LAMBDA_PROG regex: text, id, op, text
assert len(parts) == 4
assert parts[0] == "return "
assert parts[1] == "my_sensor"
assert parts[2] == "."
assert parts[3] == "state;"
def test_lambda__requires_ids(self):
"""Test that Lambda correctly extracts required IDs."""
from esphome.core import ID, Lambda
lambda_obj = Lambda("return id(sensor1).state + id(sensor2).value;")
ids = lambda_obj.requires_ids
assert len(ids) == 2
assert all(isinstance(id_obj, ID) for id_obj in ids)
assert ids[0].id == "sensor1"
assert ids[1].id == "sensor2"
def test_lambda__no_ids(self):
"""Test Lambda with no id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return 42;")
ids = lambda_obj.requires_ids
assert len(ids) == 0
def test_lambda__comment_removal(self):
"""Test that comments are removed when parsing parts."""
from esphome.core import Lambda
lambda_obj = Lambda("return id(sensor).state; // Get sensor state")
parts = lambda_obj.parts
# Comment should be replaced with space, not affect parsing
assert "my_sensor" not in str(parts)
def test_lambda__multiline_string(self):
"""Test Lambda with multiline string."""
from esphome.core import Lambda
code = """if (id(sensor).state > 0) {
return true;
}
return false;"""
lambda_obj = Lambda(code)
assert lambda_obj.value == code
assert "sensor" in [id_obj.id for id_obj in lambda_obj.requires_ids]
@pytest.mark.asyncio
class TestProcessLambda:
"""Tests for the process_lambda() async function."""
async def test_process_lambda__none_value(self):
"""Test that None returns None."""
result = await cg.process_lambda(None, [])
assert result is None
async def test_process_lambda__with_expression(self):
"""Test process_lambda with Expression argument."""
expr = cg.RawExpression("return x + 1")
result = await cg.process_lambda(expr, [(int, "x")])
assert isinstance(result, cg.LambdaExpression)
assert "x + 1" in str(result)
async def test_process_lambda__simple_lambda_no_ids(self):
"""Test process_lambda with simple Lambda without id() references."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
result = await cg.process_lambda(lambda_obj, [(int, "x")])
assert isinstance(result, cg.LambdaExpression)
# Should have parameter
lambda_str = str(result)
assert "int32_t x" in lambda_str
assert "return x + 1;" in lambda_str
async def test_process_lambda__with_return_type(self):
"""Test process_lambda with return type specified."""
from esphome.core import Lambda
lambda_obj = Lambda("return x > 0;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], return_type=bool)
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "-> bool" in lambda_str
async def test_process_lambda__with_capture(self):
"""Test process_lambda with capture specified."""
from esphome.core import Lambda
lambda_obj = Lambda("return captured + x;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="captured")
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "[captured]" in lambda_str
async def test_process_lambda__empty_capture(self):
"""Test process_lambda with empty capture (stateless lambda)."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + 1;")
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="")
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "[]" in lambda_str
async def test_process_lambda__no_parameters(self):
"""Test process_lambda with no parameters."""
from esphome.core import Lambda
lambda_obj = Lambda("return 42;")
result = await cg.process_lambda(lambda_obj, [])
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
# Should have empty parameter list
assert "()" in lambda_str
async def test_process_lambda__multiple_parameters(self):
"""Test process_lambda with multiple parameters."""
from esphome.core import Lambda
lambda_obj = Lambda("return x + y + z;")
result = await cg.process_lambda(
lambda_obj, [(int, "x"), (float, "y"), (bool, "z")]
)
assert isinstance(result, cg.LambdaExpression)
lambda_str = str(result)
assert "int32_t x" in lambda_str
assert "float y" in lambda_str
assert "bool z" in lambda_str
async def test_process_lambda__parameter_validation(self):
"""Test that malformed parameters raise assertion error."""
from esphome.core import Lambda
lambda_obj = Lambda("return x;")
# Test invalid parameter format (not list of tuples)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, "invalid")
# Test invalid tuple format (not 2-element tuples)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int, "x", "extra")])
# Test invalid tuple format (single element)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int,)])
+31 -16
View File
@@ -18,8 +18,8 @@ from esphome import espota2
from esphome.core import EsphomeError
# Test constants
MOCK_RANDOM_VALUE = 0.123456
MOCK_RANDOM_BYTES = b"0.123456"
MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16)
MOCK_SHA256_CNONCE = "b" * 64 # Mock 64-char hex string from secrets.token_hex(32)
MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5
MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256
@@ -55,10 +55,18 @@ def mock_time() -> Generator[None]:
@pytest.fixture
def mock_random() -> Generator[Mock]:
"""Mock random for predictable test values."""
with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand:
yield mock_rand
def mock_token_hex() -> Generator[Mock]:
"""Mock secrets.token_hex for predictable test values."""
def _token_hex(nbytes: int) -> str:
if nbytes == 16:
return MOCK_MD5_CNONCE
if nbytes == 32:
return MOCK_SHA256_CNONCE
raise ValueError(f"Unexpected nbytes for token_hex mock: {nbytes}")
with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock:
yield mock
@pytest.fixture
@@ -236,7 +244,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_md5_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with MD5 authentication."""
# Setup socket responses for recv calls
@@ -272,8 +280,11 @@ def test_perform_ota_successful_md5_auth(
)
)
# Verify cnonce was sent (MD5 of random.random())
cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest()
# Verify token_hex was called with MD5 digest size
mock_token_hex.assert_called_once_with(16)
# Verify cnonce was sent
cnonce = MOCK_MD5_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly
@@ -366,7 +377,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None:
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_md5_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when MD5 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
@@ -390,7 +401,7 @@ def test_perform_ota_md5_auth_wrong_password(
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when SHA256 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
@@ -603,7 +614,7 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
# Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_sha256_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with SHA256 authentication."""
# Setup socket responses for recv calls
@@ -639,8 +650,11 @@ def test_perform_ota_successful_sha256_auth(
)
)
# Verify cnonce was sent (SHA256 of random.random())
cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest()
# Verify token_hex was called with SHA256 digest size
mock_token_hex.assert_called_once_with(32)
# Verify cnonce was sent
cnonce = MOCK_SHA256_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly with SHA256
@@ -654,7 +668,7 @@ def test_perform_ota_successful_sha256_auth(
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_fallback_to_md5(
mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test SHA256-capable client falls back to MD5 for compatibility."""
# This test verifies the temporary backward compatibility
@@ -692,7 +706,8 @@ def test_perform_ota_sha256_fallback_to_md5(
)
# But authentication was done with MD5
cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest()
mock_token_hex.assert_called_once_with(16)
cnonce = MOCK_MD5_CNONCE
expected_hash = hashlib.md5()
expected_hash.update(b"testpass")
expected_hash.update(MOCK_MD5_NONCE)
+70 -15
View File
@@ -32,6 +32,7 @@ from esphome.__main__ import (
has_mqtt_ip_lookup,
has_mqtt_logging,
has_non_ip_address,
has_ota,
has_resolvable_address,
mqtt_get_ip,
run_esphome,
@@ -332,7 +333,9 @@ def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None:
def test_choose_upload_log_host_with_ota_list() -> None:
"""Test with OTA as the only item in the list."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host(
default=["OTA"],
@@ -345,7 +348,7 @@ def test_choose_upload_log_host_with_ota_list() -> None:
@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None:
"""Test with OTA list falling back to MQTT when no address."""
setup_core(config={CONF_OTA: {}, "mqtt": {}})
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], "mqtt": {}})
result = choose_upload_log_host(
default=["OTA"],
@@ -408,7 +411,9 @@ def test_choose_upload_log_host_with_serial_device_with_ports(
def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
"""Test OTA device when OTA is configured."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host(
default="OTA",
@@ -475,7 +480,9 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
@pytest.mark.usefixtures("mock_choose_prompt")
def test_choose_upload_log_host_multiple_devices() -> None:
"""Test with multiple devices including special identifiers."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]
@@ -514,7 +521,9 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports(
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_ota() -> None:
"""Test interactive mode with OTA option."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
with patch(
"esphome.__main__.choose_prompt", return_value="192.168.1.100"
@@ -575,7 +584,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options(
) -> None:
"""Test interactive mode with all options available."""
setup_core(
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
config={
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
address="192.168.1.100",
)
@@ -604,7 +617,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
) -> None:
"""Test interactive mode with all options available."""
setup_core(
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
config={
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
address="192.168.1.100",
)
@@ -632,7 +649,9 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_check_default_matches() -> None:
"""Test when check_default matches an available option."""
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
result = choose_upload_log_host(
default=None,
@@ -704,7 +723,10 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None:
def test_choose_upload_log_host_ota_both_conditions() -> None:
"""Test OTA device when both OTA and API are configured and enabled."""
setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100")
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}},
address="192.168.1.100",
)
result = choose_upload_log_host(
default="OTA",
@@ -719,7 +741,7 @@ def test_choose_upload_log_host_ota_ip_all_options() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core(
config={
CONF_OTA: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
@@ -744,7 +766,7 @@ def test_choose_upload_log_host_ota_local_all_options() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core(
config={
CONF_OTA: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
@@ -769,7 +791,7 @@ def test_choose_upload_log_host_ota_ip_all_options_logging() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core(
config={
CONF_OTA: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
@@ -794,7 +816,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core(
config={
CONF_OTA: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {},
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
@@ -817,7 +839,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
@pytest.mark.usefixtures("mock_no_mqtt_logging")
def test_choose_upload_log_host_no_address_with_ota_config() -> None:
"""Test OTA device when OTA is configured but no address is set."""
setup_core(config={CONF_OTA: {}})
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved"
@@ -1532,10 +1554,43 @@ def test_has_mqtt() -> None:
assert has_mqtt() is False
# Test with other components but no MQTT
setup_core(config={CONF_API: {}, CONF_OTA: {}})
setup_core(config={CONF_API: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
assert has_mqtt() is False
def test_has_ota() -> None:
"""Test has_ota function.
The has_ota function should only return True when OTA is configured
with platform: esphome, not when only platform: http_request is configured.
This is because CLI OTA upload only works with the esphome platform.
"""
# Test with OTA esphome platform configured
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
assert has_ota() is True
# Test with OTA http_request platform only (should return False)
# This is the bug scenario from issue #13783
setup_core(config={CONF_OTA: [{CONF_PLATFORM: "http_request"}]})
assert has_ota() is False
# Test without OTA configured
setup_core(config={})
assert has_ota() is False
# Test with multiple OTA platforms including esphome
setup_core(
config={
CONF_OTA: [{CONF_PLATFORM: "http_request"}, {CONF_PLATFORM: CONF_ESPHOME}]
}
)
assert has_ota() is True
# Test with empty OTA list
setup_core(config={CONF_OTA: []})
assert has_ota() is False
def test_get_port_type() -> None:
"""Test get_port_type function."""
+92 -5
View File
@@ -2,7 +2,7 @@
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pytest import MonkeyPatch
@@ -25,7 +25,6 @@ def default_config() -> dict[str, Any]:
"board": "esp01_1m",
"ssid": "test_ssid",
"psk": "test_psk",
"password": "",
}
@@ -37,7 +36,7 @@ def wizard_answers() -> list[str]:
"nodemcuv2", # board
"SSID", # ssid
"psk", # wifi password
"ota_pass", # ota password
"", # ota password (empty for no password)
]
@@ -105,16 +104,35 @@ def test_config_file_should_include_ota_when_password_set(
default_config: dict[str, Any],
):
"""
The Over-The-Air update should be enabled when a password is set
The Over-The-Air update should be enabled when an OTA password is set
"""
# Given
default_config["password"] = "foo"
default_config["ota_password"] = "foo"
# When
config = wz.wizard_file(**default_config)
# Then
assert "ota:" in config
assert 'password: "foo"' in config
def test_config_file_should_include_api_encryption_key(
default_config: dict[str, Any],
):
"""
The API encryption key should be included when set
"""
# Given
default_config["api_encryption_key"] = "test_encryption_key_base64=="
# When
config = wz.wizard_file(**default_config)
# Then
assert "api:" in config
assert "encryption:" in config
assert 'key: "test_encryption_key_base64=="' in config
def test_wizard_write_sets_platform(
@@ -556,3 +574,72 @@ def test_wizard_write_protects_existing_config(
# Then
assert result is False # Should return False when file exists
assert config_file.read_text() == original_content
def test_wizard_accepts_ota_password(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
"""
The wizard should pass ota_password to wizard_write when the user provides one
"""
# Given
wizard_answers[5] = "my_ota_password" # Set OTA password
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
assert "ota_password" in call_kwargs
assert call_kwargs["ota_password"] == "my_ota_password"
def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
"""
The wizard should handle rpipico board which doesn't support WiFi.
This tests the branch where api_encryption_key is None.
"""
# Given
wizard_answers_rp2040 = [
"test-node", # Name of the node
"RP2040", # platform
"rpipico", # board (no WiFi support)
]
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers_rp2040)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
# rpipico doesn't support WiFi, so no api_encryption_key or ota_password
assert "api_encryption_key" not in call_kwargs
assert "ota_password" not in call_kwargs
def test_fallback_psk_uses_secrets_choice(
default_config: dict[str, Any],
) -> None:
"""Test that fallback PSK is generated using secrets.choice."""
with patch("esphome.wizard.secrets.choice", return_value="X") as mock_choice:
config = wz.wizard_file(**default_config)
assert 'password: "XXXXXXXXXXXX"' in config
assert mock_choice.call_count == 12
+18 -18
View File
@@ -466,8 +466,8 @@ def test_clean_build(
) as mock_get_instance:
mock_config = MagicMock()
mock_get_instance.return_value = mock_config
mock_config.get.side_effect = (
lambda section, option: str(platformio_cache_dir)
mock_config.get.side_effect = lambda section, option: (
str(platformio_cache_dir)
if (section, option) == ("platformio", "cache_dir")
else ""
)
@@ -630,8 +630,8 @@ def test_clean_build_empty_cache_dir(
) as mock_get_instance:
mock_config = MagicMock()
mock_get_instance.return_value = mock_config
mock_config.get.side_effect = (
lambda section, option: " " # Whitespace only
mock_config.get.side_effect = lambda section, option: (
" " # Whitespace only
if (section, option) == ("platformio", "cache_dir")
else ""
)
@@ -1574,8 +1574,8 @@ def test_copy_src_tree_writes_build_info_files(
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = "Test comment"
@@ -1649,8 +1649,8 @@ def test_copy_src_tree_detects_config_hash_change(
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF # Different from existing
mock_core.comment = ""
@@ -1711,8 +1711,8 @@ def test_copy_src_tree_detects_version_change(
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = ""
@@ -1761,8 +1761,8 @@ def test_copy_src_tree_handles_invalid_build_info_json(
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = ""
@@ -1835,8 +1835,8 @@ def test_copy_src_tree_build_info_timestamp_behavior(
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = ""
@@ -1930,8 +1930,8 @@ def test_copy_src_tree_detects_removed_source_file(
existing_file.write_text("// test file")
# Setup mocks - no components, so the file should be removed
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = ""
@@ -1992,8 +1992,8 @@ def test_copy_src_tree_ignores_removed_generated_file(
build_info_h.write_text("// old generated file")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.relative_src_path.side_effect = src_path.joinpath
mock_core.relative_build_path.side_effect = build_path.joinpath
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.comment = ""