Merge remote-tracking branch 'upstream/dev' into neutral-ble-client

This commit is contained in:
J. Nick Koston
2026-10-02 08:15:50 -05:00
1433 changed files with 60696 additions and 28535 deletions
@@ -39,12 +39,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
@@ -0,0 +1,16 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
supports_heat_cool: true
@@ -0,0 +1,14 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat_cool: false
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_cool: false
@@ -0,0 +1,16 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
supports_cool: false
@@ -0,0 +1,53 @@
"""Tests for the supports_heat_cool default resolved in climate_ir code generation."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
def _emitted_value(main_cpp: str) -> str | None:
"""Return the argument of the generated set_supports_heat_cool() call, or None if absent."""
match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp)
return match.group(1) if match else None
@pytest.mark.parametrize(
("config", "expected"),
[
("heat_and_cool.yaml", None),
("cool_only.yaml", "false"),
("heat_only.yaml", "false"),
("neither.yaml", "false"),
],
)
def test_default_requires_heat_and_cool(
config: str,
expected: str | None,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without the key, HEAT_COOL follows supports_heat and supports_cool."""
main_cpp = generate_main(component_config_path(config))
assert _emitted_value(main_cpp) == expected
@pytest.mark.parametrize(
("config", "expected"),
[
("cool_only_override_on.yaml", None),
("heat_and_cool_override_off.yaml", "false"),
],
)
def test_explicit_key_overrides_default(
config: str,
expected: str | None,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it."""
main_cpp = generate_main(component_config_path(config))
assert _emitted_value(main_cpp) == expected
@@ -0,0 +1,17 @@
esphome:
name: test
on_boot:
then:
- counter.set_value: 5
- counter.set_value:
value: 7
- counter.increment:
value: -3
esp32:
board: esp32dev
sensor:
- platform: counter
id: only_counter
name: Only Counter
@@ -0,0 +1,42 @@
esphome:
name: test
on_boot:
then:
- counter.set_value:
id: counter_a
value: 100
- counter.set_value:
id: counter_b
value: -5000000000
- counter.increment: counter_a
- counter.increment:
id: counter_a
value: -7
- counter.increment:
id: counter_b
value: !lambda "return 3;"
esp32:
board: esp32dev
sensor:
- platform: template
id: source_sensor
lambda: return 1.0;
- platform: counter
id: counter_a
name: Counter A
sensor: source_sensor
- platform: counter
id: counter_b
name: Counter B
restore: false
initial_value: -5000000000
- platform: counter
id: counter_c
name: Counter C
binary_sensor: source_binary_sensor
binary_sensor:
- platform: template
id: source_binary_sensor
@@ -0,0 +1,99 @@
"""Tests for the counter sensor."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE
INT64_MAX = 2**63 - 1
def test_counter_constructor_arguments(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Restore (on by default) and initial value (zero by default) are constructor arguments."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp
assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp
def test_counter_sensor_option_registers_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only the counter with a sensor option counts that sensor's updates."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "counter_a->count_updates_from(source_sensor);" in main_cpp
assert main_cpp.count("count_updates_from") == 1
def test_counter_binary_sensor_option_registers_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only the counter with a binary_sensor option counts that sensor's changes to true."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp
assert main_cpp.count("count_true_from") == 1
def test_counter_actions(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Values reach the counter as 64-bit integers; increment defaults to one."""
main_cpp = generate_main(component_config_path("counter_test.yaml"))
assert "::counter_a->set_value(100);" in main_cpp
assert "::counter_b->set_value(-5000000000LL);" in main_cpp
assert "::counter_a->increment(1);" in main_cpp
assert "::counter_a->increment(-7);" in main_cpp
assert "::counter_b->increment(static_cast<int64_t>(3));" in main_cpp
def test_counter_actions_without_id_use_only_counter(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With one counter configured, the id may be left out of an action."""
main_cpp = generate_main(component_config_path("counter_single.yaml"))
assert "::only_counter->set_value(5);" in main_cpp
assert "::only_counter->set_value(7);" in main_cpp
assert "::only_counter->increment(-3);" in main_cpp
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
def test_counter_initial_value_must_be_int64(value: float) -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value})
def test_counter_cannot_count_itself() -> None:
"""A counter watching its own updates would recurse forever."""
with pytest.raises(cv.Invalid, match="cannot count its own updates"):
CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"})
def test_counter_accepts_other_source() -> None:
config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"})
assert config["sensor"].id == "other"
@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX])
def test_counter_value_accepts_int64_range(value: int) -> None:
assert COUNTER_VALUE(value) == value
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
def test_counter_value_rejects_out_of_range(value: float) -> None:
with pytest.raises(cv.Invalid):
COUNTER_VALUE(value)
@@ -0,0 +1,22 @@
esphome:
name: test
on_boot:
then:
- dfrobot_sen0395.settings:
id: mmwave
output_latency:
delay_after_detect: 0.1s
delay_after_disappear: 5.0s
esp32:
board: esp32dev
framework:
type: esp-idf
uart:
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 115200
dfrobot_sen0395:
id: mmwave
@@ -0,0 +1,25 @@
"""Codegen tests for dfrobot_sen0395 output_latency units."""
from collections.abc import Callable
from pathlib import Path
import re
def _latency_return(main_cpp: str, setter: str) -> str:
match = re.search(
rf"{re.escape(setter)}\(\[\]\(\) -> float \{{\s*return ([^;]+);",
main_cpp,
)
assert match is not None, f"{setter} not found in generated main"
return match.group(1)
def test_output_latency_emits_seconds_not_milliseconds(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""YAML 0.1s / 5.0s must reach SetLatencyCommand as seconds, not milliseconds."""
main_cpp = generate_main(component_config_path("output_latency.yaml"))
assert _latency_return(main_cpp, "set_delay_after_detect") == "0.1f"
assert _latency_return(main_cpp, "set_delay_after_disappear") == "5.0f"
@@ -0,0 +1,29 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
full_update_every: 5
dimensions:
width: 200
height: 200
binary_sensor:
- platform: gpio
pin: GPIO27
name: Trigger
on_press:
- epaper_spi.full_update_next: epaper_display
@@ -0,0 +1,22 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
dimensions:
width: 200
height: 200
border_waveform: 0x1A
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
psram:
mode: octal
speed: 80MHz
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-sticky-gray4
@@ -312,6 +312,66 @@ def test_model_with_full_update_every(
)
def test_update_interval_below_model_minimum_rejected(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""update_interval faster than the model's minimum_update_interval is rejected."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
with pytest.raises(cv.Invalid, match="at least"):
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,
},
"update_interval": "500ms",
}
)
def test_reset_duration_over_max_rejected(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""reset_duration over the 500ms cap is rejected."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
with pytest.raises(cv.Invalid, match="at most"):
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,
},
"reset_duration": "600ms",
}
)
def test_busy_pin_input_mode_ssd1677(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
@@ -481,6 +541,16 @@ def test_enable_pin_code_generation(
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
def test_full_update_next_action_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The epaper_spi.full_update_next action targets the configured display."""
main_cpp = generate_main(component_config_path("full_update_next_test.yaml"))
assert "epaper_display->request_full_update();" in main_cpp
def test_model_with_no_default_init_sequence_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,376 @@
"""Tests for the SSD1677 border_waveform option and EpaperModel.check_requirements()."""
from collections.abc import Callable, Generator
from pathlib import Path
import re
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.epaper_spi.display import CONFIG_SCHEMA, MODELS
from esphome.components.epaper_spi.models import EpaperModel
from esphome.components.epaper_spi.models.ssd1677 import CONF_BORDER_WAVEFORM
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _ssd1677_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"dimensions": {"width": 200, "height": 200},
}
config.update(overrides)
return config
def _setup_esp32(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
variant: str = VARIANT_ESP32,
board: str = "esp32dev",
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: board, KEY_VARIANT: variant},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
@pytest.fixture
def temp_model() -> Generator[Callable[..., EpaperModel]]:
"""Register a throwaway EpaperModel for a test and remove it from the shared registry after."""
created: list[EpaperModel] = []
def _make(name: str, **defaults: Any) -> EpaperModel:
model = EpaperModel(name, class_name="EPaperMono", **defaults)
created.append(model)
return model
yield _make
for model in created:
MODELS.pop(model.name, None)
# --- border_waveform ---------------------------------------------------------
def test_border_waveform_default_mono(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""ssd1677 defaults border_waveform to 0x01."""
_setup_esp32(set_core_config, set_component_config)
result = CONFIG_SCHEMA(_ssd1677_config())
assert result[CONF_BORDER_WAVEFORM] == 0x01
def test_border_waveform_default_gray4(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The 4-level grayscale variant defaults border_waveform to 0x00, independently of mono."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
result = CONFIG_SCHEMA(
{"id": "test_display", "model": "seeed-reterminal-sticky-gray4"}
)
assert result[CONF_BORDER_WAVEFORM] == 0x00
def test_border_waveform_override(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""An explicit border_waveform overrides the model default."""
_setup_esp32(set_core_config, set_component_config)
result = CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x1A))
assert result[CONF_BORDER_WAVEFORM] == 0x1A
def test_border_waveform_accepts_hex_string(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""border_waveform accepts a hex string like the YAML author would write."""
_setup_esp32(set_core_config, set_component_config)
result = CONFIG_SCHEMA(_ssd1677_config(border_waveform="0x1A"))
assert result[CONF_BORDER_WAVEFORM] == 0x1A
def test_border_waveform_out_of_range(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""border_waveform rejects values that don't fit in a byte."""
_setup_esp32(set_core_config, set_component_config)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x100))
def test_border_waveform_in_generated_init_sequence(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The configured border_waveform byte reaches the generated init sequence.
Command 0x3C (60) is followed by a length of 1 and the waveform byte.
"""
main_cpp = generate_main(component_config_path("ssd1677_border_waveform_test.yaml"))
assert re.search(r"60,\s*1,\s*0x1A,", main_cpp)
# --- full_update_every / supports_partial_update ------------------------------
def test_full_update_every_rejected_for_gray4(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The gray4 driver's partial updates are black and white and flatten the whole
panel, so full_update_every > 1 is refused unless that is explicitly accepted."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
with pytest.raises(cv.Invalid, match="monochrome_partial_updates: true"):
CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky-gray4",
"full_update_every": 5,
}
)
def test_full_update_every_accepted_for_gray4_with_monochrome_partials(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""With the trade-off accepted, the gray4 driver takes full_update_every > 1."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
result = CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky-gray4",
"full_update_every": 5,
"monochrome_partial_updates": True,
}
)
assert result["full_update_every"] == 5
def test_monochrome_partial_updates_not_offered_for_mono_sticky(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The option only exists where partial updates lose something."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
with pytest.raises(cv.Invalid, match="monochrome_partial_updates"):
CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky",
"monochrome_partial_updates": True,
}
)
def test_full_update_every_default_accepted_for_gray4(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Leaving full_update_every at its default of 1 is fine for the gray4 driver."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
result = CONFIG_SCHEMA(
{"id": "test_display", "model": "seeed-reterminal-sticky-gray4"}
)
assert result["full_update_every"] == 1
def test_full_update_every_accepted_for_mono_sticky(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The mono sibling model supports partial update, unaffected by the gray4 restriction."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
result = CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky",
"full_update_every": 5,
}
)
assert result["full_update_every"] == 5
# --- check_requirements -------------------------------------------------------
def test_requirement_missing_raises(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""seeed-reterminal-sticky requires psram; without it, config validation fails."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {}
with pytest.raises(cv.Invalid, match="requires component 'psram'"):
CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"})
def test_requirement_satisfied_does_not_raise(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""With psram present at the top level, seeed-reterminal-sticky validates."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
CORE.raw_config = {"psram": {}}
result = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"})
assert result["model"] == "SEEED-RETERMINAL-STICKY"
def test_requirement_check_skipped_without_raw_config(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""With no raw_config (e.g. a schema invoked directly, as in these tests), the
requirement check is a no-op rather than a false failure."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
assert CORE.raw_config is None
# Should not raise even though "psram" is required and nothing was configured.
CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"})
def test_requirement_missing_multiple_pluralised(
temp_model: Callable[..., EpaperModel],
) -> None:
"""The error message pluralises "component(s)" and lists every missing one."""
model = temp_model("test-multi-requirement", requires={"aaa", "bbb"})
CORE.raw_config = {}
with pytest.raises(cv.Invalid, match="requires components 'aaa', 'bbb'"):
model.check_requirements()
# --- width_multiple -----------------------------------------------------------
@pytest.mark.parametrize("width", [804, 801])
def test_gray4_width_not_multiple_of_8_rejected(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
width: int,
) -> None:
"""The gray4 plane split reads two whole buffer bytes per plane byte, so width must be a multiple of 8."""
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
with pytest.raises(cv.Invalid, match="multiple of 8"):
CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky-gray4",
"dimensions": {"width": width, "height": 480},
}
)
def test_gray4_width_multiple_of_8_accepted(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
_setup_esp32(
set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1"
)
result = CONFIG_SCHEMA(
{
"id": "test_display",
"model": "seeed-reterminal-sticky-gray4",
"dimensions": {"width": 808, "height": 480},
}
)
assert result["dimensions"]["width"] == 808
def test_mono_ssd1677_accepts_any_width(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The width restriction applies only to the gray4 model."""
_setup_esp32(set_core_config, set_component_config)
result = CONFIG_SCHEMA(_ssd1677_config(dimensions={"width": 204, "height": 200}))
assert result["dimensions"]["width"] == 204
# --- extend(class_name=...) --------------------------------------------------
def test_gray4_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""seeed-reterminal-sticky-gray4 generates the EPaperSSD1677Gray4 driver, not EPaperMono."""
main_cpp = generate_main(component_config_path("ssd1677_gray4_test.yaml"))
assert "epaper_spi::EPaperSSD1677Gray4" in main_cpp
assert "epaper_spi::EPaperMono" not in main_cpp
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_cache_in_psram: false
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: "y"
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_cache_in_psram: true
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM: "n"
psram:
mode: octal
ignore_not_found: false
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
zigbee:
model: zigbee_test
power_source: MAINS_SINGLE_PHASE
+160 -4
View File
@@ -4,6 +4,7 @@ Test ESP32 configuration
import asyncio
from collections.abc import Callable
import logging
from pathlib import Path
from typing import Any
@@ -1457,7 +1458,7 @@ def test_esp32_s31_gpio_validation(
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping PIN" in caplog.text
assert "GPIO36 is a strapping pin" in caplog.text
_TLS_SERVER_OPTIONS = (
@@ -1488,7 +1489,7 @@ def test_mbedtls_tls_trim_sdkconfig(
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
@@ -1500,7 +1501,19 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False)
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_zigbee_keeps_only_what_it_uses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_user_sdkconfig_wins(
@@ -1529,7 +1542,16 @@ def test_mbedtls_tls_openthread_requires_server_and_extras(
"""The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
def test_mbedtls_tls_zigbee_requires_extras(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee hooks mark the CCM/deterministic ECDSA as required."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@@ -1554,3 +1576,137 @@ def test_vasprintf_stub_only_on_rom_vsnprintf_variants(
assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected
defines = {define.name for define in CORE.defines}
assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected
@pytest.mark.parametrize(
("fixture", "expected"),
[
("nvs_cache_psram_guaranteed.yaml", True),
("nvs_cache_psram_explicit.yaml", True),
("nvs_cache_psram_not_guaranteed.yaml", None),
("nvs_cache_psram_disabled.yaml", None),
# the encryption keys must stay in internal RAM, whichever way encryption is enabled
("nvs_cache_psram_encrypted.yaml", None),
("nvs_cache_psram_encrypted_sdkconfig.yaml", None),
],
)
def test_nvs_cache_in_psram_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
fixture: str,
expected: bool | None,
) -> None:
"""The NVS cache moves to PSRAM only with guaranteed PSRAM, the option not off and no NVS encryption."""
generate_main(component_config_path(fixture))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is expected
def test_nvs_cache_in_psram_user_sdkconfig_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A raw sdkconfig_options value for the NVS cache option is left alone."""
generate_main(component_config_path("nvs_cache_psram_user_off.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig["CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM"] == RawSdkconfigValue("n")
@pytest.mark.parametrize(
("full_config", "error_match"),
[
pytest.param(
{CONF_ESPHOME: {}, "psram": {"disabled": False, "ignore_not_found": True}},
r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'",
id="nvs_cache_in_psram_needs_guaranteed_psram",
),
pytest.param(
{CONF_ESPHOME: {}},
r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'",
id="nvs_cache_in_psram_needs_psram",
),
],
)
def test_nvs_cache_in_psram_explicit_true_errors(
full_config: dict, error_match: str, set_core_config: SetCoreConfigCallable
) -> None:
"""An explicit nvs_cache_in_psram: true that cannot apply is a config error, not a silent no-op."""
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
config = {
"variant": "esp32s3",
"framework": {"type": "esp-idf", "advanced": {"nvs_cache_in_psram": True}},
}
with pytest.raises(cv.Invalid, match=error_match):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
def test_nvs_cache_in_psram_explicit_true_rejects_encryption(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={
CONF_ESPHOME: {},
"psram": {"disabled": False, "ignore_not_found": False},
},
)
from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
config = {
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_cache_in_psram": True, "nvs_encryption": {"key_id": 0}},
},
}
with pytest.raises(cv.Invalid, match="cannot be used with NVS encryption"):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
def test_nvs_cache_in_psram_default_with_encryption_is_quiet(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Encryption on a board that never mentioned the option must not warn about it."""
with caplog.at_level(logging.WARNING):
generate_main(component_config_path("nvs_cache_psram_encrypted.yaml"))
assert "nvs_cache_in_psram" not in caplog.text
@pytest.mark.asyncio
async def test_nvs_cache_in_psram_explicit_request_warns_when_encrypted(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""An explicit request dropped for NVS encryption enabled elsewhere logs a warning."""
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_SDKCONFIG_OPTIONS: {}}
)
from esphome.components.esp32 import (
_apply_nvs_cache_in_psram,
add_idf_sdkconfig_option,
)
add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True)
with caplog.at_level(logging.WARNING):
await _apply_nvs_cache_in_psram(True)
assert "nvs_cache_in_psram ignored" in caplog.text
assert (
"CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM"
not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
)
def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""An explicit true that applies sets the option and warns about nothing."""
with caplog.at_level(logging.WARNING):
generate_main(component_config_path("nvs_cache_psram_explicit.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True
assert "nvs_cache_in_psram" not in caplog.text
@@ -0,0 +1,73 @@
"""The Bluedroid queued connection guard is emitted only for client builds on unfixed ESP-IDF."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components import esp32_ble_tracker
from esphome.core import CORE
# Spelled out so a typo in the component's flags fails here
_GUARD_FLAGS = {
"-Wl,--wrap=l2cble_init_direct_conn",
"-Wl,--undefined=__wrap_l2cble_init_direct_conn",
}
def _pin_idf(monkeypatch: pytest.MonkeyPatch, idf: str) -> None:
monkeypatch.setattr(esp32_ble_tracker, "idf_version", lambda: cv.Version.parse(idf))
@pytest.mark.parametrize(
("config_file", "idf", "expected"),
[
pytest.param("scan_window_raised.yaml", "5.5.5", True, id="client"),
pytest.param("scan_window_raised.yaml", "6.0.3", False, id="client_fixed_idf"),
pytest.param("scan_window_scan_only.yaml", "5.5.5", False, id="scan_only"),
],
)
def test_guard_only_in_client_builds_on_unfixed_idf(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
monkeypatch: pytest.MonkeyPatch,
config_file: str,
idf: str,
expected: bool,
) -> None:
_pin_idf(monkeypatch, idf)
generate_main(component_config_path(config_file))
assert (CORE.build_flags >= _GUARD_FLAGS) is expected
assert CORE.build_flags.isdisjoint(_GUARD_FLAGS) is not expected
defines = {define.name for define in CORE.defines}
assert ("USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD" in defines) is expected
@pytest.mark.parametrize(
("idf", "expected"),
[
("5.1.6", True), # series that never got the fix
("5.2.7", True),
("5.2.8", False),
("5.3.5", True),
("5.3.6", False),
("5.4.4", True),
("5.4.5", True), # no fixed 5.4, 5.5 or 6.1 tag yet
("5.5.5", True),
("5.5.6", True),
("6.0.2", True),
("6.0.3", False),
("6.1.0", True),
("6.1.1", True),
("6.2.0", False),
("7.0.0", False),
],
)
def test_needs_direct_conn_guard(
monkeypatch: pytest.MonkeyPatch, idf: str, expected: bool
) -> None:
_pin_idf(monkeypatch, idf)
assert esp32_ble_tracker._needs_direct_conn_guard() is expected
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
ethernet:
id: eth_component
type: KSZ8851SNL
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
version: 6.0.0
ethernet:
id: eth_component
type: KSZ8851SNL
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -0,0 +1,105 @@
"""Tests for the KSZ8851SNL SPI ethernet type."""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32S3,
)
from esphome.components.esp32.const import (
KEY_COMPONENTS,
KEY_ESP32,
KEY_REF,
KEY_SDKCONFIG_OPTIONS,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
_BASE_CONFIG = {
"type": "KSZ8851SNL",
"clk_pin": 47,
"mosi_pin": 48,
"miso_pin": 14,
"cs_pin": 21,
}
_SDKCONFIG_OPTION = "CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL"
_IDF_COMPONENT = "espressif/ksz8851snl"
def _sdkconfig() -> dict[str, object]:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
def _idf_components() -> dict[str, dict[str, str | None]]:
return CORE.data[KEY_ESP32][KEY_COMPONENTS]
def test_codegen_on_idf5_wires_the_spi_driver(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""On IDF 5.x the driver is built into esp_eth and enabled by its Kconfig option."""
main_cpp = generate_main(component_config_path("ksz8851snl_idf5.yaml"))
assert "eth_component->set_type(ethernet::ETHERNET_TYPE_KSZ8851SNL);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_interrupt_pin(36);" in main_cpp
assert "USE_ETHERNET_KSZ8851SNL" in {d.name for d in CORE.defines}
assert _sdkconfig()[_SDKCONFIG_OPTION] is True
# Built into IDF 5.x, so the registry component must not be pulled in.
assert _IDF_COMPONENT not in _idf_components()
def test_codegen_on_idf6_pulls_the_registry_component(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""IDF 6.0 dropped the built-in driver, so the managed component is required."""
generate_main(component_config_path("ksz8851snl_idf6.yaml"))
assert _idf_components()[_IDF_COMPONENT][KEY_REF] == "1.2.0"
# The Kconfig option no longer exists on IDF 6.0.
assert _SDKCONFIG_OPTION not in _sdkconfig()
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "ksz8851snl-test"
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "40MHz"])
def test_accepts_clock_speed_up_to_the_datasheet_maximum(
set_core_config: SetCoreConfigCallable, clock_speed: str
) -> None:
"""The datasheet rates fSCLK to 40MHz, so the whole range must be accepted."""
_set_esp32_s3(set_core_config)
config = CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: clock_speed})
assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed)
def test_rejects_clock_speed_above_the_datasheet_maximum(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The shared 80MHz ceiling is out of spec for this part."""
_set_esp32_s3(set_core_config)
with pytest.raises(Invalid, match="value must be at most 40000000"):
CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
@@ -0,0 +1,22 @@
esphome:
name: test-color-wipe-color
esp32:
board: esp32dev
framework:
type: arduino
light:
- platform: fastled_clockless
id: wipe_light
chipset: WS2811
pin: GPIO13
num_leds: 10
effects:
- addressable_color_wipe:
name: Wipe
colors:
- color: darkred
num_leds: 1
- red: 50%
num_leds: 1
@@ -0,0 +1,40 @@
"""Regression test: addressable_color_wipe's `color:` must scale by color_brightness.
The per-item schema tests in test_effect_color.py only check the validated config
dict; they would still pass if addressable_color_wipe_effect_to_code dropped
color_brightness on the floor (as it originally did), since a dark color's red/green/
blue are already peak-normalized to 1.0 by the time codegen sees them. Generate the
actual C++ and check the AddressableColorWipeEffectColor byte values instead, so a
regression in the scaling multiplication itself is caught.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.fixture
def main_cpp(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(
component_config_path("addressable_color_wipe_color_test.yaml")
)
def test_dark_color_name_is_scaled_by_color_brightness(main_cpp: str) -> None:
# color: darkred (0x8B0000) normalizes to r=1.0 but color_brightness=0x8B/0xFF,
# so the byte value must come back down to 0x8B (139), not 255.
assert ".r = 139," in main_cpp
assert ".g = 0," in main_cpp
assert ".b = 0," in main_cpp
def test_explicit_channel_is_unaffected(main_cpp: str) -> None:
# red: 50% with no color: or color_brightness: set must still scale by the
# default color_brightness of 1.0, i.e. come out as plain 50% of 255.
assert ".r = 128," in main_cpp
@@ -0,0 +1,119 @@
"""Tests for the light `color` option."""
from __future__ import annotations
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.light.automation import (
LIGHT_CONTROL_ACTION_SCHEMA,
LIGHT_STATE_SCHEMA,
)
from esphome.const import (
CONF_BLUE,
CONF_COLOR_BRIGHTNESS,
CONF_GREEN,
CONF_ID,
CONF_RED,
)
LOGGER_NAME: str = "esphome.components.light.automation"
def test_color_name_sets_rgb() -> None:
result = LIGHT_STATE_SCHEMA({"color": "Tomato"})
assert "color" not in result
assert result[CONF_RED] == 1.0
assert result[CONF_GREEN] == pytest.approx(0x63 / 0xFF)
assert result[CONF_BLUE] == pytest.approx(0x47 / 0xFF)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_color_name_in_control_action() -> None:
result = LIGHT_CONTROL_ACTION_SCHEMA({CONF_ID: "test_light", "color": "blue"})
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 1.0)
def test_unknown_color_name() -> None:
with pytest.raises(cv.Invalid, match="notacolor"):
LIGHT_STATE_SCHEMA({"color": "notacolor"})
def test_color_name_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
LIGHT_STATE_SCHEMA({"color": "red", CONF_GREEN: 0.5})
def test_dark_color_sets_color_brightness() -> None:
result = LIGHT_STATE_SCHEMA({"color": "darkred"})
assert result[CONF_RED] == 1.0
assert result[CONF_GREEN] == 0.0
assert result[CONF_BLUE] == 0.0
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF)
def test_black_sets_zero_color_brightness() -> None:
result = LIGHT_STATE_SCHEMA({"color": "black"})
assert result[CONF_COLOR_BRIGHTNESS] == 0.0
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0)
def test_explicit_color_brightness_wins(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
result = LIGHT_STATE_SCHEMA({"color": "darkred", CONF_COLOR_BRIGHTNESS: 0.25})
assert result[CONF_COLOR_BRIGHTNESS] == 0.25
assert "overrides the brightness of color 'darkred'" in caplog.text
def test_explicit_color_brightness_full_color(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
result = LIGHT_STATE_SCHEMA({"color": "red", CONF_COLOR_BRIGHTNESS: 0.25})
assert result[CONF_COLOR_BRIGHTNESS] == 0.25
assert not [r for r in caplog.records if r.name == LOGGER_NAME]
@pytest.mark.parametrize("value", [0x223344, "0x223344", "0X223344"])
def test_hex_color(value: int | str) -> None:
result = LIGHT_STATE_SCHEMA({"color": value})
assert "color" not in result
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x44 / 0xFF)
assert result[CONF_BLUE] == 1.0
assert result[CONF_RED] == pytest.approx(0x22 / 0x44)
assert result[CONF_GREEN] == pytest.approx(0x33 / 0x44)
def test_hex_color_full_level() -> None:
result = LIGHT_STATE_SCHEMA({"color": 0xFF8000})
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
assert result[CONF_RED] == 1.0
assert result[CONF_GREEN] == pytest.approx(0x80 / 0xFF)
assert result[CONF_BLUE] == 0.0
def test_hex_color_black() -> None:
result = LIGHT_STATE_SCHEMA({"color": 0})
assert result[CONF_COLOR_BRIGHTNESS] == 0.0
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0)
@pytest.mark.parametrize("value", [0x1000000, -1, "0xGG", "0x1000000"])
def test_hex_color_invalid(value: int | str) -> None:
with pytest.raises(cv.Invalid):
LIGHT_STATE_SCHEMA({"color": value})
def test_hex_color_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_RED: 0.5})
def test_explicit_color_brightness_warning_names_hex_color(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_COLOR_BRIGHTNESS: 0.25})
assert "overrides the brightness of color '0x223344'" in caplog.text
@@ -0,0 +1,69 @@
"""Tests for the `color` option in per-item effect color lists."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.light.effects import (
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA,
STROBE_COLOR_SCHEMA,
)
from esphome.const import CONF_BLUE, CONF_COLOR_BRIGHTNESS, CONF_GREEN, CONF_RED
def test_strobe_plain_defaults_full_level() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s"})
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_strobe_color_name() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "color": "darkred"})
assert "color" not in result
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 0.0, 0.0)
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF)
def test_strobe_hex_color() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "color": "0x223344"})
assert result[CONF_BLUE] == 1.0
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x44 / 0xFF)
def test_strobe_explicit_channels_unaffected() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "red": "50%"})
assert result[CONF_RED] == 0.5
assert (result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_strobe_color_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
STROBE_COLOR_SCHEMA({"duration": "1s", "color": "red", "red": "10%"})
def test_addressable_color_wipe_plain_defaults_full_level() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1})
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_addressable_color_wipe_color_name() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1, "color": "darkred"})
assert "color" not in result
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 0.0, 0.0)
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF)
def test_addressable_color_wipe_explicit_channels_unaffected() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1, "red": "50%"})
assert result[CONF_RED] == 0.5
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_addressable_color_wipe_color_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA(
{"num_leds": 1, "color": "blue", "blue": "10%"}
)
@@ -0,0 +1,37 @@
"""Limits and flash layout that let LightState stay small."""
import pytest
from esphome import config_validation as cv
from esphome.components.light.effects import (
MAX_EFFECTS,
MONOCHROMATIC_EFFECTS,
validate_effects,
)
def _effects(count: int) -> list[dict[str, dict[str, str]]]:
return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)]
def test_rejects_more_effects_than_the_index_holds() -> None:
with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"):
validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1))
def test_accepts_a_normal_effect_list() -> None:
assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3
def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None:
from esphome.components.light import gamma_table_initializer, generate_gamma_table
init = gamma_table_initializer(2.8)
lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8))
assert init == f"{{{{{lut}}}, 280}}"
def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None:
from esphome.components.light import gamma_table_initializer
assert gamma_table_initializer(1000.0).endswith(", 65535}")
@@ -34,12 +34,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -1,7 +1,13 @@
"""Tests for the logger component."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.core import CORE
def test_logger_pre_setup_before_other_components(generate_main):
"""Logger::pre_setup() must be called before any other component is created.
@@ -84,3 +90,24 @@ def test_libretiny_uart0_is_emitted(generate_main):
)
assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp
def test_ram_log_strings_is_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`false` is accepted but ignored: no RAM build flag, and a warning with the removal version."""
generate_main("tests/component_tests/logger/test_logger_ram_log_strings.yaml")
assert not any("STORE_LOG_STR" in flag for flag in CORE.build_flags)
assert "esp8266_store_log_strings_in_flash: false' is ignored" in caplog.text
assert "2027.4.0" in caplog.text
def test_flash_log_strings_default_does_not_warn(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
generate_main("tests/component_tests/logger/test_logger.yaml")
assert "esp8266_store_log_strings_in_flash" not in caplog.text
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
esp8266_store_log_strings_in_flash: false
@@ -0,0 +1,36 @@
esphome:
name: test-debug-outline
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
debug_outline: true
widgets:
- obj:
id: root_obj
widgets:
- label:
id: title_label
text: Title
- button:
id: ok_button
widgets:
- label:
text: OK
@@ -0,0 +1,28 @@
esphome:
name: test-keyboard-before-textarea
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- keyboard:
id: kb
textarea: ta
- textarea:
id: ta
@@ -0,0 +1,34 @@
esphome:
name: test-keyboard-update-textarea
on_boot:
- lvgl.keyboard.update:
id: kb
textarea: ta2
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- keyboard:
id: kb
textarea: ta1
- textarea:
id: ta1
- textarea:
id: ta2
@@ -0,0 +1,35 @@
esphome:
name: test-multi-instance-theme
host:
mac_address: "62:23:45:AF:B3:DD"
display:
- platform: snapshot
id: sdl0
dimensions:
width: 100
height: 100
- platform: snapshot
id: sdl1
dimensions:
width: 100
height: 100
lvgl:
- id: lvgl_0
displays: sdl0
widgets:
- label:
id: label_0
text: "First"
- id: lvgl_1
displays: sdl1
theme:
label:
bg_color: 0xFF0000
widgets:
- label:
id: label_1
text: "Second"
@@ -0,0 +1,35 @@
esphome:
name: test-debug-outline
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
widgets:
- obj:
id: root_obj
widgets:
- label:
id: title_label
text: Title
- button:
id: ok_button
widgets:
- label:
text: OK
@@ -191,6 +191,24 @@ class TestTimingSchema:
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5})
assert result["weight"] == pytest.approx(0.5)
def test_ease_in_default_weight(self) -> None:
result = TIMING_SCHEMA("ease_in")
assert result["type"] == "ease_in"
assert result["weight"] == pytest.approx(1.0)
def test_ease_in_custom_weight(self) -> None:
result = TIMING_SCHEMA({"type": "ease_in", "weight": 0.5})
assert result["weight"] == pytest.approx(0.5)
def test_ease_out_default_weight(self) -> None:
result = TIMING_SCHEMA("ease_out")
assert result["type"] == "ease_out"
assert result["weight"] == pytest.approx(1.0)
def test_ease_out_custom_weight(self) -> None:
result = TIMING_SCHEMA({"type": "ease_out", "weight": 0.5})
assert result["weight"] == pytest.approx(0.5)
def test_gravity_defaults(self) -> None:
result = TIMING_SCHEMA("gravity")
assert result["type"] == "gravity"
@@ -0,0 +1,33 @@
"""Tests for the LVGL ``debug_outline`` option code generation."""
from __future__ import annotations
import re
_OUTLINE_COLOR_RE = re.compile(
r"lv_obj_set_style_outline_color\(.+?, (lv_color_make\(.+?\)),"
)
_OUTLINE_WIDTH_RE = re.compile(r"lv_obj_set_style_outline_width\(")
_OUTLINE_PAD_RE = re.compile(r"lv_obj_set_style_outline_pad\(.+?, 0,")
class TestDebugOutlineCodeGeneration:
"""Verify that ``debug_outline`` outlines every widget in a distinct colour."""
def test_debug_outline_styles_every_widget(
self, generate_main, component_config_path
):
"""Four widgets are declared, so four outlines with different colours appear."""
main_cpp = generate_main(component_config_path("debug_outline.yaml"))
assert len(_OUTLINE_WIDTH_RE.findall(main_cpp)) == 4
assert len(_OUTLINE_PAD_RE.findall(main_cpp)) == 4
colors = _OUTLINE_COLOR_RE.findall(main_cpp)
assert len(colors) == 4
assert len(set(colors)) == 4
def test_debug_outline_default_emits_nothing(
self, generate_main, component_config_path
):
"""Without ``debug_outline`` no outline styles are generated."""
main_cpp = generate_main(component_config_path("no_debug_outline.yaml"))
assert "lv_obj_set_style_outline_" not in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: a keyboard: declared before its textarea: sibling must
still get attached to it, and only after both widgets exist.
attach_textareas() runs as a deferred pass, after every widget (across every
LVGL instance) is created, so it must emit the attach call after the
keyboard's own creation statement, not inline during widget creation.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "keyboard_before_textarea_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_keyboard_and_textarea_are_created(main_cpp: str) -> None:
assert "lv_keyboard_create(" in main_cpp
assert "lv_textarea_create(" in main_cpp
def test_attach_call_runs_after_keyboard_and_textarea_are_created(
main_cpp: str,
) -> None:
attach_index = main_cpp.find("lv_keyboard_set_textarea(kb->obj, ta);")
assert attach_index != -1, "keyboard was never attached to its textarea"
assert attach_index > main_cpp.find("lv_keyboard_create(")
assert attach_index > main_cpp.find("lv_textarea_create(")
@@ -0,0 +1,33 @@
"""Regression test: lvgl.keyboard.update must be able to change which
textarea a keyboard is attached to.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "keyboard_update_textarea_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_keyboard_update_changes_textarea(main_cpp: str) -> None:
assert "lv_keyboard_set_textarea(kb->obj, ta2);" in main_cpp
+4 -4
View File
@@ -286,7 +286,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
@@ -310,7 +310,7 @@ def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
"LV_PART_MAIN);\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
@@ -321,7 +321,7 @@ def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
"LV_PART_MAIN);"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
@@ -343,7 +343,7 @@ def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> Non
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
"LV_PART_MAIN);\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
@@ -1,16 +1,4 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock."""
from __future__ import annotations
@@ -0,0 +1,59 @@
"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option
the configuration does not use, so ``lv_conf.h`` must leave alone the options
the user defines in build flags. Otherwise the header, which is included after
the compiler ``-D`` flags, turns the option off again.
Build flags can come from ``esphome: build_flags:`` or from the deprecated
``esphome: platformio_options: build_flags:``; both must be honoured.
"""
from __future__ import annotations
import logging
import pytest
from esphome.components.lvgl import defines as df, generate_lv_conf_h
from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS
from esphome.core import CORE
def _set_esphome_config(
build_flags: list[str] | None = None,
pio_build_flags: list[str] | str | None = None,
) -> None:
pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags}
CORE.config = {
CONF_ESPHOME: {
CONF_PLATFORMIO_OPTIONS: pio_options,
CONF_BUILD_FLAGS: build_flags or [],
}
}
def test_unused_define_is_disabled_without_build_flag() -> None:
_set_esphome_config()
assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines()
@pytest.mark.parametrize(
"flags",
[
{"build_flags": ["-DLV_USE_OBSERVER=1"]},
{"build_flags": ["-D LV_USE_OBSERVER"]},
{"pio_build_flags": ["-DLV_USE_OBSERVER=1"]},
{"pio_build_flags": "-DLV_USE_OBSERVER=1"},
],
ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"],
)
def test_build_flag_define_is_not_disabled(flags: dict) -> None:
_set_esphome_config(**flags)
assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines()
def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None:
_set_esphome_config(build_flags=["-DLV_USE_ARC=1"])
df.add_define("LV_USE_ARC")
with caplog.at_level(logging.WARNING):
generate_lv_conf_h()
assert "LV_USE_ARC" in caplog.text
@@ -9,13 +9,16 @@ from esphome.components.lvgl.schemas import theme_schema
from esphome.config_validation import Invalid
def _config(displays: list[str], theme: dict | None = None) -> dict:
def _config(
displays: list[str], theme: dict | None = None, debug_outline: bool = False
) -> dict:
config = {
df.CONF_DISPLAYS: displays,
"log_level": "WARN",
"color_depth": 16,
"byte_order": "big_endian",
df.CONF_TRANSPARENCY_KEY: 0x000400,
df.CONF_DEBUG_OUTLINE: debug_outline,
}
if theme is not None:
config[df.CONF_THEME] = theme
@@ -53,3 +56,20 @@ class TestThemeOnMultipleInstances:
def test_passes_when_no_instance_has_theme(self) -> None:
configs = [_config(["disp_a"]), _config(["disp_b"])]
multi_conf_validate(configs)
class TestDebugOutlineOnMultipleInstances:
def test_raises_when_instances_differ(self) -> None:
configs = [
_config(["disp_a"], debug_outline=True),
_config(["disp_b"], debug_outline=False),
]
with pytest.raises(Invalid, match="'debug_outline' must be the same"):
multi_conf_validate(configs)
def test_passes_when_instances_match(self) -> None:
configs = [
_config(["disp_a"], debug_outline=True),
_config(["disp_b"], debug_outline=True),
]
multi_conf_validate(configs)
@@ -0,0 +1,44 @@
"""Regression test: a widget's theme style must be attached regardless of
which LVGL instance declares theme: and which instance's widgets are built
first.
theme_to_code() runs once per LVGL instance, interleaved with that instance's
own add_widgets(). A widget built by an earlier instance, before any instance
has declared theming for its type, must still pick up a later instance's
theme: declaration for that type - including on the later instance's own
widgets, which get_widget_theme_styles()'s memoisation can poison too.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "multi_instance_theme_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_earlier_instance_widget_is_themed(main_cpp: str) -> None:
assert "lv_obj_add_style(label_0, _lv_theme_style_label_main_default," in main_cpp
def test_later_instance_widget_is_themed(main_cpp: str) -> None:
assert "lv_obj_add_style(label_1, _lv_theme_style_label_main_default," in main_cpp
@@ -1,5 +1,7 @@
"""Tests for mipi_rgb configuration validation."""
from collections.abc import Generator
import pytest
from esphome import config_validation as cv
@@ -17,6 +19,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
from esphome.components.mipi import DriverChip
import esphome.components.pca9554 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
@@ -44,6 +47,19 @@ DATA_PINS = {
}
@pytest.fixture(autouse=True)
def _remove_test_models() -> Generator[None]:
"""Unregister chips created by a test.
display.py modules drain DriverChip.models when first imported, so a
leftover TEST-* chip could become a selectable model there.
"""
existing = set(DriverChip.models)
yield
for name in set(DriverChip.models) - existing:
del DriverChip.models[name]
def _set_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
@@ -176,6 +192,41 @@ def test_configuration_succeeds_on_supported_variants(
CONFIG_SCHEMA(config)
def test_st7701s_default_reset_delay() -> None:
"""ST7701S instances default to a 50ms reset delay.
The datasheet's stated 5ms is too short in practice; ST7701S overrides the
DriverChip default of 10ms with its own default of 50ms.
"""
from esphome.components.mipi_rgb.models.st7701s import st7701s
assert st7701s.get_default("reset_delay") == 50
def test_st7701s_reset_delay_can_be_overridden() -> None:
"""An explicit reset_delay overrides the ST7701S default of 50ms."""
from esphome.components.mipi_rgb.models.st7701s import ST7701S
chip = ST7701S("TEST-ST7701S-RESET-DELAY", width=480, height=480, reset_delay=99)
assert chip.get_default("reset_delay") == 99
def test_st7701s_extend_inherits_reset_delay_default() -> None:
"""extend() carries the 50ms default forward to derived board models.
Every shipped ST7701S variant is built via ``st7701s.extend(...)`` rather
than direct construction, so the override in ``ST7701S.__init__`` must
survive that path (see DriverChip.extend, which re-passes the copied
defaults as kwargs to the constructor).
"""
from esphome.components.mipi_rgb.models.st7701s import st7701s
extended = st7701s.extend("TEST-ST7701S-EXTEND", width=480, height=480)
assert extended.get_default("reset_delay") == 50
def test_only_on_variant_rejects_unsupported_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,81 @@
"""End-to-end tests for the mipi_rgb SPI reset sequence.
These exercise the actual codegen path (mipi_rgb/display.py's
``model.get_sequence(config, add_reset=True)`` call) rather than calling
DriverChip.get_sequence directly, so a regression that drops add_reset or
reintroduces a hardcoded SWRESET into a model's initsequence would be caught
here.
"""
from collections.abc import Callable
from pathlib import Path
# A model with no reset_pin default: SWRESET ({1, 0}) is prepended ahead of the
# inherited ST7701S reset_delay ({50, 255}).
_NO_RESET_PIN_YAML = """
esphome:
name: mipi-rgb-reset-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
spi:
id: spi_bus
clk_pin: 10
mosi_pin: 11
display:
- platform: mipi_rgb
id: no_reset_display
spi_id: spi_bus
model: MAKERFABS-4
"""
# A model with a reset_pin default: no SWRESET, just the settling delay.
_RESET_PIN_YAML = """
esphome:
name: mipi-rgb-reset-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
spi:
id: spi_bus
clk_pin: 6
mosi_pin: 7
display:
- platform: mipi_rgb
id: has_reset_display
spi_id: spi_bus
model: WAVESHARE-3.16-320X820
"""
def test_swreset_and_reset_delay_without_reset_pin(
generate_main: Callable[[str | Path], str],
tmp_path: Path,
) -> None:
"""A model with no reset_pin gets SWRESET plus the ST7701S 50ms delay."""
yaml_file = tmp_path / "no_reset.yaml"
yaml_file.write_text(_NO_RESET_PIN_YAML)
main_cpp = generate_main(yaml_file)
assert "no_reset_display->set_init_sequence({1, 0, 50, 255," in main_cpp
def test_reset_delay_only_with_reset_pin(
generate_main: Callable[[str | Path], str],
tmp_path: Path,
) -> None:
"""A model with a reset_pin default skips SWRESET but keeps the settling delay."""
yaml_file = tmp_path / "has_reset.yaml"
yaml_file.write_text(_RESET_PIN_YAML)
main_cpp = generate_main(yaml_file)
assert "has_reset_display->set_init_sequence({50, 255," in main_cpp
assert "has_reset_display->set_init_sequence({1, 0," not in main_cpp
@@ -0,0 +1,64 @@
"""Tests for DriverChip.get_sequence's reset-delay handling."""
from collections.abc import Generator
import pytest
from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip
# A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run
# without needing a full display configuration.
_BASE_CONFIG = {CONF_PIXEL_MODE: "16bit", CONF_INVERT_COLORS: False}
@pytest.fixture(autouse=True)
def _remove_test_models() -> Generator[None]:
"""Unregister chips created by a test."""
existing = set(DriverChip.models)
yield
for name in set(DriverChip.models) - existing:
del DriverChip.models[name]
def test_get_sequence_defaults_to_10ms_reset_delay() -> None:
"""A model with no reset_delay default falls back to a 10ms settling delay."""
chip = DriverChip("TEST-GET-SEQUENCE-DEFAULT")
sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True)
# SWRESET ({1, 0}) is prepended (no reset pin configured), followed by the
# 10ms settling delay, flattened to {10, 255}.
assert sequence[:4] == (1, 0, 10, 255)
def test_get_sequence_uses_model_reset_delay_default() -> None:
"""A model's own reset_delay default overrides the base 10ms default."""
chip = DriverChip("TEST-GET-SEQUENCE-CUSTOM-DELAY", reset_delay=99)
sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True)
assert sequence[:4] == (1, 0, 99, 255)
@pytest.mark.parametrize("reset_delay", [0, 256])
def test_get_sequence_rejects_out_of_range_reset_delay(reset_delay: int) -> None:
"""reset_delay outside 1-255ms is rejected.
This matches the 1-255ms range map_sequence() already allows for a
"delay N" entry in a custom init sequence.
"""
chip = DriverChip("TEST-GET-SEQUENCE-BAD-DELAY", reset_delay=reset_delay)
with pytest.raises(ValueError, match="reset_delay must be between 1 and 255ms"):
chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True)
def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None:
"""An out-of-range reset_delay is only checked when add_reset is requested.
mipi_dsi calls get_sequence with add_reset=False and never uses
reset_delay, so an invalid default there should not raise.
"""
chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999)
chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False)
@@ -0,0 +1,84 @@
"""Tag-based sensor defaults, driven through the real CONFIG_SCHEMA."""
import pytest
from esphome.components import sensor
from esphome.components.mk2pvrouter.sensor import CONFIG_SCHEMA, tag_kind
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_EMPTY,
STATE_CLASS_MEASUREMENT,
)
from esphome.types import ConfigType
def _sensor(tag: str, **extra: object) -> ConfigType:
return CONFIG_SCHEMA(
{"tag": tag, "mk2pvrouter_id": "hub", "name": f"{tag} sensor", **extra}
)
@pytest.mark.parametrize(
("tag", "unit", "device_class", "state_class", "decimals"),
[
("P", "W", "power", "measurement", 0),
("P1", "W", "power", "measurement", 0),
("D", "W", "power", "measurement", 0),
("D1", "%", "", "measurement", 0),
("V", "V", "voltage", "measurement", 2),
("v1", "V", "voltage", "measurement", 2),
("E", "Wh", "energy", "total_increasing", 0),
("T1", "°C", "temperature", "measurement", 2),
("R", "W", "power", "measurement", 0),
("R1", "", "", "", 0),
("R10", "", "", "", 0),
],
)
def test_tag_defaults(
tag: str, unit: str, device_class: str, state_class: str, decimals: int
) -> None:
config = _sensor(tag)
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
assert config[CONF_STATE_CLASS] == sensor.validate_state_class(state_class)
assert config[CONF_ACCURACY_DECIMALS] == decimals
@pytest.mark.parametrize("tag", ["S_MC", "STATUS", "X9", "Z", "T", "E1"])
def test_unknown_tag_gets_only_the_schema_defaults(tag: str) -> None:
config = _sensor(tag)
assert CONF_UNIT_OF_MEASUREMENT not in config
assert CONF_DEVICE_CLASS not in config
assert config[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_MEASUREMENT
)
assert config[CONF_ACCURACY_DECIMALS] == 0
def test_explicit_values_win_over_tag_defaults() -> None:
config = _sensor("P", device_class=DEVICE_CLASS_EMPTY, accuracy_decimals=3)
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_EMPTY
assert config[CONF_ACCURACY_DECIMALS] == 3
assert config[CONF_UNIT_OF_MEASUREMENT] == "W"
@pytest.mark.parametrize(
("tag", "scale_centi"),
[
("V", True),
("V1", True),
("v2", True),
("T1", True),
("P", False),
("D1", False),
("E", False),
("T", False),
("X9", False),
],
)
def test_centi_scale_follows_the_tag(tag: str, scale_centi: bool) -> None:
kind = tag_kind(tag)
assert (kind is not None and kind.scale_centi) is scale_centi
@@ -18,7 +18,6 @@ from esphome.components.motion import (
_build_calibrate_action,
_transform_matrix,
_validate_matrix_options,
clear_calibration_to_code,
)
from esphome.components.motion.sensor import (
_ACCELERATIONS,
@@ -785,44 +784,6 @@ class TestClearActionSchema:
assert result[CONF_SAVE] is False
@pytest.fixture
def mock_clear_codegen():
"""Mock cg functions used by clear_calibration_to_code."""
mock_var = MagicMock()
mock_parent = MagicMock()
with (
patch(
"esphome.components.motion.cg.get_variable",
new_callable=AsyncMock,
return_value=mock_parent,
),
patch(
"esphome.components.motion.cg.new_Pvariable",
return_value=mock_var,
) as mock_new_pvar,
patch("esphome.components.motion.cg.add") as mock_add,
):
yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var}
@pytest.mark.asyncio
async def test_clear_action_without_save(mock_clear_codegen):
"""With save=False, set_save should not be emitted."""
config = {CONF_ID: MagicMock(), CONF_SAVE: False}
result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), [])
assert result is mock_clear_codegen["var"]
mock_clear_codegen["add"].assert_not_called()
@pytest.mark.asyncio
async def test_clear_action_with_save(mock_clear_codegen):
"""With save=True, set_save(True) should be emitted exactly once."""
config = {CONF_ID: MagicMock(), CONF_SAVE: True}
await clear_calibration_to_code(config, MagicMock(), MagicMock(), [])
mock_clear_codegen["var"].set_save.assert_called_once_with(True)
mock_clear_codegen["add"].assert_called_once()
# --- Calibration persistence invalidation ---
#
# The C++ side stores a hash of the build-time base matrix alongside the saved
+11
View File
@@ -0,0 +1,11 @@
"""Shared helpers for the network component tests."""
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
from esphome.core import CORE
def sdkconfig_option(name: str) -> int | bool | None:
"""Return a generated sdkconfig value, or None when unset or not ESP32."""
if KEY_ESP32 not in CORE.data:
return None
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
ethernet:
type: LAN8720
mdc_pin: 23
mdio_pin: 32
clk:
pin: 0
mode: CLK_EXT_IN
phy_addr: 0
power_pin: 33
network:
enable_high_performance: true
@@ -0,0 +1,23 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
ignore_not_found: false
ethernet:
type: W5500
clk_pin: GPIO12
mosi_pin: GPIO11
miso_pin: GPIO13
cs_pin: GPIO10
interrupt_pin: GPIO14
reset_pin: GPIO9
network:
enable_high_performance: true
@@ -0,0 +1,30 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
ignore_not_found: false
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: GPIO12
mosi_pin: GPIO11
miso_pin: GPIO13
cs_pin: GPIO10
interrupt_pin: GPIO14
reset_pin: GPIO9
network:
priority:
- wifi
- ethernet
enable_high_performance: true
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
enable_high_performance: true
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
ignore_not_found: false
wifi:
ssid: "test_ssid"
password: "test_password"
network:
enable_high_performance: true
@@ -0,0 +1,54 @@
"""Tests for the ``network: enable_high_performance:`` lwip tier selection.
Ethernet drivers keep received frames in internal RAM, so an ethernet-only
build must keep lwip off the PSRAM tier even when PSRAM is guaranteed.
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.network import require_high_performance_networking
from tests.component_tests.network import sdkconfig_option
@pytest.mark.parametrize(
(
"fixture",
"window",
"tcp_mailbox",
"tcpip_mailbox",
"window_scale",
"wifi_rx_buffers",
),
[
("high_perf_wifi_psram.yaml", 512000, 512, 512, True, 512),
("high_perf_wifi_no_psram.yaml", 65534, 64, 64, None, 64),
# dual builds keep wifi's sizes; the ethernet component moves frames to PSRAM
("high_perf_wifi_ethernet_psram.yaml", 512000, 512, 512, True, 512),
("high_perf_ethernet_no_psram.yaml", 16384, 64, 32, None, None),
# ethernet only with PSRAM guaranteed: the cell the guard exists for
("high_perf_ethernet_psram.yaml", 16384, 64, 32, None, None),
],
)
def test_lwip_tier(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
fixture: str,
window: int,
tcp_mailbox: int,
tcpip_mailbox: int,
window_scale: bool | None,
wifi_rx_buffers: int | None,
) -> None:
# The wifi component only reacts to a component request, so request it the
# way sendspin does; the config key alone decides the lwip tier.
require_high_performance_networking()
generate_main(component_config_path(fixture))
assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") == window
assert sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE") == tcp_mailbox
assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == tcpip_mailbox
assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale
# Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded
assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers
@@ -19,6 +19,7 @@ from esphome.components.network import (
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
from tests.component_tests.network import sdkconfig_option
from tests.component_tests.types import SetCoreConfigCallable
@@ -217,13 +218,7 @@ def test_no_primary_interface_define_without_priority(
def _dns_per_default_netif_option() -> bool | None:
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all
return None
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(
"CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF"
)
return sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF")
@pytest.mark.parametrize(
@@ -14,32 +14,24 @@ import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32.const import (
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32,
)
from esphome.components.esp32.const import KEY_VARIANT, VARIANT_ESP32
from esphome.components.network import (
CONF_TCP_SEND_BUFFER,
CONFIG_SCHEMA,
TCP_SEND_BUFFER_MAX,
TCP_SEND_BUFFER_MIN,
)
from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework
from esphome.core import CORE
from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework
from tests.component_tests.network import sdkconfig_option
from tests.component_tests.types import SetCoreConfigCallable
def _sdkconfig_option(name: str) -> int | None:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_tcp_send_buffer_sets_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("tcp_send_buffer.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000
assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000
def test_tcp_send_buffer_overrides_high_performance(
@@ -48,7 +40,7 @@ def test_tcp_send_buffer_overrides_high_performance(
) -> None:
"""An explicit size wins over the high performance bundle's 65534."""
generate_main(component_config_path("tcp_send_buffer_high_perf.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384
assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384
@pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX])
+45 -17
View File
@@ -12,10 +12,11 @@ from esphome import config_validation as cv
from esphome.components.esphome.ota import (
AUTO_LOAD,
FILTER_SOURCE_FILES,
_encryption_schema,
_validate_no_password_with_encryption,
ota_esphome_final_validate,
)
from esphome.components.noise import static_encryption_key
from esphome.components.noise import encryption_schema, static_encryption_key
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
@@ -29,6 +30,7 @@ from esphome.const import (
CONF_VERSION,
)
from esphome.core import CORE, ID
from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD
import esphome.final_validate as fv
@@ -198,6 +200,21 @@ def test_encryption_without_any_key_rejected() -> None:
fv.full_config.reset(token)
def test_encryption_schema_allow_plaintext_upload() -> None:
"""The opt in is an ota only option with no default, so a merged block
that does not mention it cannot clear it; the shared api schema does not
know it."""
assert _encryption_schema(None) == {}
assert _encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) == {
CONF_ALLOW_PLAINTEXT_UPLOAD: True
}
assert _encryption_schema({CONF_KEY: API_KEY}) == {CONF_KEY: API_KEY}
with pytest.raises(cv.Invalid):
_encryption_schema(False)
with pytest.raises(cv.Invalid):
encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True})
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
"""Same-port configs with different encryption keys raise."""
full_conf = {
@@ -476,43 +493,43 @@ def test_static_encryption_key() -> None:
("yaml_name", "defines_present", "defines_absent"),
[
# An api key alone compiles the transport in without requiring it;
# the device uses the api server's key, not a copy
# the ota keeps its own pointer to the key so safe mode, which never
# constructs the api server, can still use it
(
"api_key_offer",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
{"USE_OTA_ENCRYPTION"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A password still guards plaintext uploads on an offering device
(
"api_key_offer_password",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# The migration install keeps the password for the old firmware's
# prompt but the build it sends is authenticated by the key alone
(
"migration_password",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"USE_OTA_PASSWORD", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# The ota encryption block is what makes the device refuse plaintext
(
"encryption_required",
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"USE_OTA_ENCRYPTION_PROVISIONED"},
),
# Without api encryption the ota key is the device's own
(
"own_key",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
{"USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A key provisioned at runtime lives in the api server; the device
# offers with it once provisioned and never requires it
(
"runtime_api_key",
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"},
{"USE_OTA_ENCRYPTION_REQUIRED"},
),
# No api encryption at all keeps the noise glue out of the build
@@ -522,7 +539,6 @@ def test_static_encryption_key() -> None:
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
),
@@ -541,8 +557,10 @@ def test_encryption_offer_codegen(
assert defines_present <= defines
assert not (defines_absent & defines)
encrypted = "USE_OTA_ENCRYPTION" in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
# The api shares the ota's array instead of emitting the same key twice
assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0)
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
# The noise transport source compiles only when the define is set
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
@@ -555,6 +573,16 @@ def test_password_with_encryption_rejected() -> None:
_validate_no_password_with_encryption(config)
def test_password_with_migration_install_accepted() -> None:
"""The old firmware may still ask for the password on the plaintext
leg of the migration install."""
config = {
CONF_PASSWORD: "pw",
CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_ALLOW_PLAINTEXT_UPLOAD: True},
}
assert _validate_no_password_with_encryption(config) is config
def test_password_alone_accepted() -> None:
"""A password without encryption still validates."""
config = {CONF_PASSWORD: "pw"}
@@ -0,0 +1,14 @@
esphome:
name: ota-migration-password
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
encryption:
allow_plaintext_upload: true
@@ -0,0 +1,28 @@
"""The PZEM-6L24 answers unit addresses 1 to 247 only."""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus
from esphome.components.pzem6l24.sensor import CONFIG_SCHEMA
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
def _sensor(**extra: object) -> ConfigType:
return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", **extra})
def test_address_defaults_to_one() -> None:
assert _sensor()[CONF_ADDRESS] == 1
@pytest.mark.parametrize("address", [1, 247])
def test_address_in_unit_range_accepted(address: int) -> None:
assert _sensor(**{CONF_ADDRESS: address})[CONF_ADDRESS] == address
@pytest.mark.parametrize("address", [0, 248, 255])
def test_address_outside_unit_range_rejected(address: int) -> None:
with pytest.raises(cv.Invalid):
_sensor(**{CONF_ADDRESS: address})
@@ -37,12 +37,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -0,0 +1,112 @@
"""Tests for UART clock source validation and code generation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp32 import KEY_VARIANT, VARIANTS
from esphome.components.uart import (
CONF_CLOCK_SOURCE,
CONFIG_SCHEMA,
UART_CLOCK_SOURCES,
UART_CLOCK_SOURCES_BY_VARIANT,
)
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture
def clock_source_validator() -> cv.All:
"""Find the clock validator without depending on pre-validator ordering."""
schema = next(
validator
for validator in CONFIG_SCHEMA.validators
if isinstance(validator, cv.Schema)
)
return schema.schema[cv.Optional(CONF_CLOCK_SOURCE)]
def test_clock_source_table_covers_all_variants() -> None:
"""New ESP32 variants must declare their supported UART clocks."""
assert set(UART_CLOCK_SOURCES_BY_VARIANT) == set(VARIANTS)
for sources in UART_CLOCK_SOURCES_BY_VARIANT.values():
assert "DEFAULT" in sources
assert set(sources) <= UART_CLOCK_SOURCES.keys()
@pytest.mark.parametrize(
("variant", "source", "supported"),
[
("ESP32", "REF_TICK", True),
("ESP32", "XTAL", False),
("ESP32S2", "REF_TICK", True),
("ESP32S2", "RTC", False),
("ESP32C3", "APB", True),
("ESP32C3", "xtal", True),
("ESP32C3", "REF_TICK", False),
("ESP32S3", "RTC", True),
("ESP32S3", "REF_TICK", False),
("ESP32C2", "APB", False),
("ESP32C6", "APB", False),
("ESP32C6", "DEFAULT", True),
("ESP32C5", "RTC", True),
("ESP32C61", "XTAL", True),
("ESP32H4", "RTC", True),
("ESP32H21", "XTAL", True),
("ESP32S31", "RTC", True),
("ESP32H2", "XTAL", True),
("ESP32P4", "RTC", True),
("ESP32C3", "INVALID", False),
],
)
def test_clock_source_validation(
variant: str,
source: str,
supported: bool,
set_core_config: SetCoreConfigCallable,
clock_source_validator: cv.All,
) -> None:
"""Reject unsupported clocks and normalize accepted names."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: variant},
)
if supported:
assert clock_source_validator(source) == source.upper()
else:
with pytest.raises(cv.Invalid):
clock_source_validator(source)
def test_clock_source_requires_esp32(
set_core_config: SetCoreConfigCallable,
clock_source_validator: cv.All,
) -> None:
"""Reject the ESP32-only option before accessing the chip variant."""
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(cv.Invalid, match="ESP32"):
clock_source_validator("DEFAULT")
@pytest.mark.parametrize("source", [None, "default", "xtal"])
def test_clock_source_codegen(
source: str | None,
tmp_path: Path,
generate_main: Callable[[str | Path], str],
) -> None:
"""Emit IDF constants directly and skip the setter when nothing changes."""
config = tmp_path / "uart.yaml"
config.write_text(
"esphome:\n name: uart-clock-test\n"
"esp32:\n variant: esp32c3\n framework:\n type: esp-idf\n"
"uart:\n id: test_uart\n tx_pin: GPIO4\n baud_rate: 9600\n"
+ (f" clock_source: {source}\n" if source else ""),
encoding="utf-8",
)
main_cpp = generate_main(config)
if source in (None, "default"):
assert "set_clock_source(" not in main_cpp
else:
assert f"test_uart->set_clock_source(::UART_SCLK_{source.upper()});" in main_cpp
@@ -0,0 +1,8 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
zephyr_ble_server:
@@ -0,0 +1,11 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
ble_nus:
type: logs
@@ -0,0 +1,18 @@
esphome:
name: mtutest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
zephyr_ble_server:
ble_nus:
type: logs
ota:
- platform: zephyr_mcumgr
transport:
ble: true
@@ -0,0 +1,52 @@
"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_PRJ_CONF
from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu
MTU_KEYS = (
"CONFIG_BT_L2CAP_TX_MTU",
"CONFIG_BT_BUF_ACL_TX_SIZE",
"CONFIG_BT_BUF_ACL_RX_SIZE",
)
def _mtu_values() -> tuple[int | None, ...]:
prj_conf = zephyr_data()[KEY_PRJ_CONF][""]
return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS)
@pytest.mark.parametrize(
("fixture", "expected"),
[
("no_request.yaml", (None, None, None)),
("nus.yaml", (247, 251, 251)),
# The largest request wins; only the TX buffer is capped at 251
("nus_mcumgr.yaml", (498, 251, 502)),
],
)
def test_l2cap_mtu_prj_conf(
fixture: str,
expected: tuple[int | None, ...],
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
assert _mtu_values() == expected
@pytest.mark.asyncio
async def test_request_after_emit_raises(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("no_request.yaml"))
request_ble_l2cap_mtu(247)
await _emit_ble_mtu()
with pytest.raises(RuntimeError, match="after it was written"):
request_ble_l2cap_mtu(498)