mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 16:30:22 +00:00
Merge branch 'dev' into posix_tz
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@ lvgl:
|
||||
- id: lvgl_0
|
||||
default_font: space16
|
||||
displays: sdl0
|
||||
top_layer:
|
||||
|
||||
- id: lvgl_1
|
||||
displays: sdl1
|
||||
on_idle:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,3 +26,7 @@ wifi:
|
||||
- ssid: MySSID3
|
||||
password: password3
|
||||
priority: 0
|
||||
on_connect:
|
||||
- logger.log: "WiFi connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "WiFi disconnected!"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:]}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,)])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user