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

# Conflicts:
#	esphome/components/ble_client/__init__.py
This commit is contained in:
J. Nick Koston
2026-10-10 09:25:46 -10:00
2360 changed files with 19556 additions and 6491 deletions
@@ -3,6 +3,8 @@
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/light/color_mode.h"
#include "esphome/components/light/light_effect.h"
#include "esphome/components/light/light_effect_names.h"
namespace esphome::api::benchmarks {
@@ -150,7 +152,17 @@ BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse);
// --- ListEntitiesLightResponse ---
static light::ColorModeMask light_color_modes;
static FixedVector<const char *> light_effects;
class BenchEffect : public light::LightEffect {
public:
explicit BenchEffect(const char *name) : LightEffect(name) {}
void apply() override {}
};
static BenchEffect rainbow_effect("Rainbow");
static BenchEffect strobe_effect("Strobe");
static light::LightEffect *const LIGHT_EFFECT_TABLE[] = {&rainbow_effect, &strobe_effect};
static const ConstVector<light::LightEffect *> light_effect_list(LIGHT_EFFECT_TABLE, 2);
static const light::LightEffectNames light_effects(light_effect_list);
static ListEntitiesLightResponse make_light_response() {
// Initialize static data on first call
@@ -158,10 +170,6 @@ static ListEntitiesLightResponse make_light_response() {
if (!initialized) {
light_color_modes.insert(light::ColorMode::RGB_WHITE);
light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE);
light_effects.init(3);
light_effects.push_back("None");
light_effects.push_back("Rainbow");
light_effects.push_back("Strobe");
initialized = true;
}
@@ -149,7 +149,7 @@ BENCHMARK(Decode_SerialProxyWriteRequest);
// --- InfraredRFReceiveEvent encode (100 sint32 timings) +
// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) ---
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings).
static std::vector<int32_t> make_ir_timings_100() {
@@ -275,6 +275,6 @@ static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state)
}
BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest);
#endif // USE_IR_RF || USE_RADIO_FREQUENCY
#endif // USE_IR_RF
} // namespace esphome::api::benchmarks
@@ -25,7 +25,9 @@ ethernet:
type: LAN8720
mdc_pin: GPIO23
mdio_pin: GPIO18
clk_mode: GPIO17_OUT
clk:
mode: CLK_OUT
pin: GPIO17
phy_addr: 0
power_pin: GPIO12
@@ -19,7 +19,8 @@ class InfraredCall {
return *this;
}
InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; }
void perform() {}
template<typename T> InfraredCall &set_api_connection(T * /*conn*/) { return *this; }
bool perform() { return false; }
protected:
Infrared *parent_;
@@ -37,6 +38,7 @@ class Infrared : public Component, public EntityBase {
const InfraredTraits &get_traits() const { return this->traits_; }
InfraredCall make_call() { return InfraredCall(this); }
uint32_t get_capability_flags() const { return 0; }
template<typename T> void on_api_connection_closed(T * /*conn*/) {}
protected:
InfraredTraits traits_;
@@ -23,7 +23,8 @@ class RadioFrequencyCall {
RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
void perform() {}
template<typename T> RadioFrequencyCall &set_api_connection(T * /*conn*/) { return *this; }
bool perform() { return false; }
protected:
RadioFrequency *parent_;
@@ -43,6 +44,7 @@ class RadioFrequency : public Component, public EntityBase {
const RadioFrequencyTraits &get_traits() const { return this->traits_; }
RadioFrequencyCall make_call() { return RadioFrequencyCall(this); }
uint32_t get_capability_flags() const { return 0; }
template<typename T> void on_api_connection_closed(T * /*conn*/) {}
protected:
RadioFrequencyTraits traits_;
@@ -49,6 +49,7 @@ class SerialProxy {
uint32_t get_modem_pins() const { return 0; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
void send_identity(api::APIConnection *api_connection) {}
protected:
uint32_t instance_index_{0};
@@ -0,0 +1,33 @@
"""Tests for the Home Assistant action field tables."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.api import DATA_FIELDS_SCHEMA, VARIABLES_FIELDS_SCHEMA
import esphome.config_validation as cv
CONFIG = "tests/component_tests/api/test_homeassistant_fields.yaml"
def test_field_tables(generate_main: Callable[[str | Path], str]) -> None:
"""Strings are single PROGMEM arrays on ESP8266; only tables without lambdas are shared."""
main_cpp = generate_main(CONFIG)
assert main_cpp.count('PROGMEM = "message";') == 1
assert main_cpp.count('PROGMEM = "notify.notify";') == 1
assert 'ESPHOME_F("message")' not in main_cpp
assert main_cpp.count("(api_apiserver_id, false, ha_action_fields, 1, 0, 0);") == 2
assert "(api_apiserver_id, true, ha_action_fields_2, 1, 0, 0);" in main_cpp
assert "(api_apiserver_id, true, ha_action_fields_3, 1, 0, 0);" in main_cpp
@pytest.mark.parametrize("schema", [DATA_FIELDS_SCHEMA, VARIABLES_FIELDS_SCHEMA])
def test_field_map_limit(schema: cv.Schema) -> None:
"""Each map is counted in a uint8_t, so 256 entries fail validation."""
schema({f"key{i}": "value" for i in range(255)})
with pytest.raises(cv.Invalid):
schema({f"key{i}": "value" for i in range(256)})
@@ -0,0 +1,29 @@
esphome:
name: test
on_boot:
then:
- homeassistant.action:
action: notify.notify
data:
message: Booted
- homeassistant.action:
action: notify.notify
data:
message: Booted
- homeassistant.event:
event: esphome.booted
data:
message: !lambda 'static int n = 0; return ++n;'
- homeassistant.event:
event: esphome.booted
data:
message: !lambda 'static int n = 0; return ++n;'
esp8266:
board: esp01_1m
wifi:
ssid: SomeNetwork
password: SomePassword
api:
@@ -17,7 +17,7 @@ def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
assert main_cpp.count('from_lambda("lambda_var", []() {') == 2
assert "return millis();" in main_cpp
# The source text must not be sent as a static string value.
assert '"return millis();"' not in main_cpp
@@ -28,16 +28,11 @@ def test_static_string_is_kept_as_static_value(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A static string stays static, PROGMEM wrapped, with no warning."""
"""A static string stays a constant table entry, with no warning."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert (
main_cpp.count(
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
)
== 2
)
assert main_cpp.count('{"static_var", "static value", nullptr}') == 2
assert "static value" not in caplog.text
@@ -49,7 +44,7 @@ def test_static_id_value_stays_literal_with_hint(
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
assert '{"hint_var", "id(test_sensor).state", nullptr}' in main_cpp
assert "sent as literal text" in caplog.text
@@ -59,5 +54,5 @@ def test_explicit_lambda_tag_is_compiled_as_lambda(
"""A !lambda value keeps working unchanged."""
main_cpp = generate_main(CONFIG)
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
assert 'from_lambda("tagged_var", []() {' in main_cpp
assert "return App.get_name();" in main_cpp
+927
View File
@@ -0,0 +1,927 @@
"""Tests for the api wizard: schema, final validation and generated tables."""
from collections.abc import Callable
import json
from pathlib import Path
import random
import re
import string
import textwrap
import pytest
from esphome.components.api import wizard
from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS
from esphome.config import load_config
from esphome.core import CORE
from esphome.helpers import fnv1_hash, fnv1a_32bit_hash, zstd_module
from tests.component_tests.helpers import get_define_value
ESP32_HEADER = """
esphome:
name: test
esp32:
variant: esp32
wifi:
ssid: test
password: testtest
logger:
"""
ESP8266_HEADER = """
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
logger:
"""
ENTITIES = """
switch:
- platform: template
id: sw
name: Switch
optimistic: true
- platform: template
id: internal_sw
name: Internal switch
internal: true
optimistic: true
- platform: template
id: unnamed_sw
optimistic: true
- platform: homeassistant
id: ha_switch
%(light)s
sensor:
- platform: template
id: template_sensor
name: Template sensor
- platform: homeassistant
id: ha_plain_sensor
entity_id: sensor.plain
- platform: homeassistant
id: ha_sensor
%(sensor)s
binary_sensor:
- platform: homeassistant
id: ha_binary
%(binary)s
text_sensor:
- platform: homeassistant
id: ha_text
%(text)s
number:
- platform: homeassistant
id: ha_number
%(number)s
text:
- platform: homeassistant
id: ha_txt
%(text_entity)s
select:
- platform: homeassistant
id: ha_select
%(select)s
button:
- platform: homeassistant
id: ha_button
%(button)s
"""
def build_entities(inputs: set[str]) -> str:
"""The test entities. The homeassistant ones that are wizard inputs set no entity_id, the others do."""
ids = {
"light": ("ha_switch", "entity_id: light.lamp"),
"sensor": ("ha_sensor", "entity_id: sensor.a"),
"binary": ("ha_binary", "entity_id: binary_sensor.a"),
"text": ("ha_text", "entity_id: sensor.b"),
"number": ("ha_number", "entity_id: number.a"),
"text_entity": ("ha_txt", "entity_id: text.a"),
"select": ("ha_select", "entity_id: select.a"),
"button": ("ha_button", "entity_id: button.a"),
}
return ENTITIES % {
key: "" if name in inputs else yaml for key, (name, yaml) in ids.items()
}
GOOD_PAGES = """
api:
wizard:
pages:
- title: Setup
description: "[%key:component::domain::section::name%]"
entities:
- id: sw
description: Enable
- id: sw
- inputs:
- entity: ha_sensor
description: Weather
target:
entity:
- integration: met
domain: sensor
device_class: [temperature, humidity]
supported_features: weather.WeatherEntityFeature.FORECAST_DAILY
- domain: [sensor, number]
- entity: ha_binary
- entity: ha_text
- entity: ha_number
- entity: ha_switch
- entity: ha_txt
- entity: ha_select
- entity: ha_button
- id: weather_input
target:
entity:
- domain: weather
- id: unset_input
"""
def write_config(
tmp_path: Path, header: str, api: str, yaml_entities: str | None = None
) -> Path:
"""Write a config. By default the homeassistant entities that the wizard does not use as inputs set an entity_id."""
path = tmp_path / "test.yaml"
if yaml_entities is None:
yaml_entities = build_entities(set(re.findall(r"entity: (\w+)", api)))
path.write_text(
textwrap.dedent(header) + textwrap.dedent(yaml_entities) + textwrap.dedent(api)
)
return path
def write_input_config(tmp_path: Path, header: str) -> Path:
"""Write a config with GOOD_PAGES."""
return write_config(tmp_path, header, GOOD_PAGES)
def config_errors(path: Path) -> list[str]:
CORE.config_path = path
return [str(err.msg) for err in load_config({}).errors]
def wizard_api(pages: str) -> str:
return "api:\n wizard:\n pages:\n" + textwrap.indent(
textwrap.dedent(pages).strip("\n"), " "
)
def test_no_wizard_emits_nothing(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(
write_config(
tmp_path,
ESP32_HEADER,
"api:\n",
"sensor:\n - platform: homeassistant\n id: s\n entity_id: sensor.s\n",
)
)
assert "API_WIZARD_PAGES" not in main_cpp
assert 's->set_entity_id("sensor.s");' in main_cpp
assert not any(define.name == "USE_API_WIZARD" for define in CORE.defines)
def test_valid_wizard_has_no_errors(tmp_path: Path) -> None:
assert config_errors(write_input_config(tmp_path, ESP32_HEADER)) == []
def wizard_inputs(inputs: str) -> str:
return wizard_api("- inputs:\n" + textwrap.indent(textwrap.dedent(inputs), " "))
def test_switch_input_with_supported_domains_is_valid(tmp_path: Path) -> None:
api = wizard_inputs(
"""
- entity: ha_switch
target:
entity:
- domain: [light, fan]
"""
)
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
@pytest.mark.parametrize(
("api", "message"),
[
pytest.param(
wizard_inputs("- entity: template_sensor"),
"must be a homeassistant",
id="input-not-homeassistant",
),
pytest.param(
wizard_inputs("- entity: sw"),
"must be a homeassistant",
id="input-not-homeassistant-switch",
),
pytest.param(
wizard_inputs(
"""
- entity: ha_switch
target:
entity:
- domain: [light, valve]
"""
),
"does not support the domain(s) valve",
id="switch-unsupported-domain",
),
pytest.param(
wizard_inputs(
"""
- entity: ha_switch
target:
entity:
- integration: hue
"""
),
"must set domain",
id="switch-filter-without-domain",
),
pytest.param(
wizard_inputs("- entity: ha_sensor\n- entity: ha_sensor"),
"'ha_sensor' is used more than once",
id="duplicate-input",
),
],
)
def test_invalid_inputs_are_rejected(tmp_path: Path, api: str, message: str) -> None:
errors = config_errors(write_config(tmp_path, ESP32_HEADER, api))
assert any(message in error for error in errors), errors
def test_hash_collision_is_rejected(tmp_path: Path) -> None:
entities = "".join(
f"sensor:\n - platform: homeassistant\n id: {name}\n"
if index == 0
else f" - platform: homeassistant\n id: {name}\n"
for index, name in enumerate(("a179599", "a362382"))
)
api = wizard_inputs("- entity: a179599\n- entity: a362382")
errors = config_errors(write_config(tmp_path, ESP32_HEADER, api, entities))
assert any("have the same hash" in error for error in errors), errors
@pytest.mark.parametrize(
"extra",
[
"",
" entity_id: sensor.default\n",
],
)
def test_entity_id_is_optional_only_for_wizard_inputs(
tmp_path: Path, extra: str
) -> None:
entities = f"sensor:\n - platform: homeassistant\n id: lonely\n{extra}"
errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entities))
if extra:
assert errors == []
else:
assert any(
"entity_id is required unless this entity is a wizard input" in error
for error in errors
), errors
def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked(
tmp_path: Path,
) -> None:
entities = "switch:\n - platform: homeassistant\n id: ha_switch\n entity_id: climate.heater\n"
errors = config_errors(
write_config(
tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_switch"), entities
)
)
# The YAML entity ID is still checked against the platform's domains
assert any("not supported by the switch platform" in error for error in errors)
@pytest.mark.parametrize(
("pages", "message"),
[
pytest.param(
"- title: Empty",
"at least one of entities, inputs",
id="page-without-fields",
),
pytest.param(
"""
- entities: []
""",
"length of value must be at least 1",
id="empty-entities-list",
),
pytest.param(
"""
- entities: [{id: internal_sw}]
""",
"Entity 'internal_sw' is internal",
id="internal-entity",
),
pytest.param(
"""
- entities: [{id: unnamed_sw}]
""",
"Entity 'unnamed_sw' is internal",
id="unnamed-entity-is-internal",
),
pytest.param(
"""
- inputs:
- entity: ha_sensor
target:
entity:
- {}
""",
"at least one of",
id="empty-filter",
),
pytest.param(
"""
- inputs:
- entity: ha_sensor
target:
entity: []
""",
"length of value must be at least 1",
id="no-filters",
),
pytest.param(
"""
- inputs:
- entity: ha_sensor
target:
entity:
- domain: []
""",
"length of value must be at least 1",
id="empty-domain-list",
),
pytest.param(
"""
- inputs:
- entity: ha_sensor
target:
entity:
- supported_features: FEATURE
""".replace("FEATURE", "x" * 128),
"length of value must be at most 127",
id="long-feature",
),
pytest.param(
"""
- description: TEXT
entities: [{id: sw}]
""".replace("TEXT", "x" * 256),
"length of value must be at most 255",
id="long-description",
),
],
)
def test_invalid_wizards_are_rejected(tmp_path: Path, pages: str, message: str) -> None:
errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_api(pages)))
assert any(message in error for error in errors), errors
def test_no_pages_is_rejected(tmp_path: Path) -> None:
errors = config_errors(
write_config(tmp_path, ESP32_HEADER, "api:\n wizard:\n pages: []\n")
)
assert any("length of value must be at least 1" in error for error in errors), (
errors
)
def test_input_is_set_condition(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
interval = """
interval:
- interval: 1h
then:
- if:
condition:
api.wizard.input_is_set: weather_input
then:
- logger.log: set
- if:
condition:
api.wizard.input_is_set:
id: unset_input
then:
- logger.log: set
"""
path = write_input_config(tmp_path, ESP32_HEADER)
path.write_text(path.read_text() + textwrap.dedent(interval))
main_cpp = generate_main(path)
assert "weather_input->has_entity_id()" in main_cpp
assert "unset_input->has_entity_id()" in main_cpp
@pytest.mark.parametrize(
("inputs", "message"),
[
pytest.param(
"- description: Neither",
"exactly one of id, entity",
id="neither-id-nor-entity",
),
pytest.param(
"- id: standalone\n entity: ha_sensor",
"more than one of id, entity",
id="both-id-and-entity",
),
pytest.param(
"- entity: ha_sensor\n entity_id: sensor.a",
"extra keys not allowed",
id="entity-id-on-linked-input",
),
pytest.param(
"- id: standalone\n entity_id: weather.home",
"extra keys not allowed",
id="entity-id-on-standalone-input",
),
pytest.param(
"- entity: ha_sensor\n- entity: ha_sensor",
"used more than once",
id="duplicate-linked-input",
),
],
)
def test_standalone_and_linked_input_rules(
tmp_path: Path, inputs: str, message: str
) -> None:
errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs)))
assert any(message in error for error in errors), errors
def test_standalone_input_is_valid(tmp_path: Path) -> None:
api = wizard_inputs("- id: standalone")
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
WIZARD_DEFINES = {
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
}
@pytest.mark.parametrize(
("pages", "expected"),
[
pytest.param(
"- entities: [{id: sw}]",
{"USE_API_WIZARD"},
id="entity-only",
),
pytest.param(
"- inputs: [{id: standalone}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
},
id="standalone-input-without-filter",
),
pytest.param(
"- inputs: [{id: standalone, target: {entity: [{domain: weather}]}}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
},
id="standalone-input-with-filter",
),
pytest.param(
"- inputs: [{entity: ha_sensor}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
},
id="linked-sensor-without-filter",
),
pytest.param(
"- inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
},
id="linked-sensor-with-filter",
),
pytest.param(
# A switch without a target gets the default filter
"- inputs: [{entity: ha_switch}]",
{
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
},
id="linked-switch-default-filter",
),
pytest.param(
"- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]",
WIZARD_DEFINES,
id="everything",
),
],
)
def test_only_the_defines_the_wizard_needs_are_emitted(
tmp_path: Path,
generate_main: Callable[[str | Path], str],
pages: str,
expected: set[str],
) -> None:
api = wizard_api(pages)
generate_main(write_config(tmp_path, ESP32_HEADER, api))
assert {d.name for d in CORE.defines} & WIZARD_DEFINES == expected
@pytest.mark.parametrize(
("entity", "bad_domain"),
[
("ha_txt", "select"),
("ha_select", "text"),
("ha_button", "switch"),
("ha_number", "sensor"),
],
)
def test_text_select_and_button_inputs_keep_to_their_domains(
tmp_path: Path, entity: str, bad_domain: str
) -> None:
api = wizard_inputs(
f"- entity: {entity}\n target:\n entity:\n - domain: {bad_domain}"
)
errors = config_errors(write_config(tmp_path, ESP32_HEADER, api))
assert any(
f"does not support the domain(s) {bad_domain}" in error for error in errors
), errors
@pytest.mark.parametrize("entity", ["ha_txt", "ha_select", "ha_button", "ha_number"])
def test_text_select_and_button_filters_must_set_a_domain(
tmp_path: Path, entity: str
) -> None:
api = wizard_inputs(
f"- entity: {entity}\n target:\n entity:\n - integration: hue"
)
errors = config_errors(write_config(tmp_path, ESP32_HEADER, api))
assert any("must set domain" in error for error in errors), errors
def test_number_input_accepts_input_number(tmp_path: Path) -> None:
api = wizard_inputs(
"- entity: ha_number\n target:\n entity:\n - domain: input_number"
)
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
def test_text_select_and_button_default_to_their_domains(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER))
document = json.loads(zstd_module().decompress(blob_in(main_cpp)))
filters = {
entry["key"]: entry.get("entity_filters")
for page in document["pages"]
for entry in page.get("inputs", [])
}
# ha_txt and ha_button have no target, so they take every supported domain
assert filters[fnv1_hash("ha_txt")] == [{"domain": ["input_text", "text"]}]
assert filters[fnv1_hash("ha_button")] == [{"domain": ["button", "input_button"]}]
assert filters[fnv1_hash("ha_select")] == [{"domain": ["input_select", "select"]}]
# A number takes number and input_number entities, and the other sensors take anything
assert filters[fnv1_hash("ha_number")] == [{"domain": ["input_number", "number"]}]
assert filters[fnv1_hash("ha_binary")] is None
for entity in ("ha_txt", "ha_select", "ha_button"):
assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp
@pytest.mark.parametrize(
"entity_yaml",
[
"text:\n - platform: homeassistant\n id: lonely\n",
"select:\n - platform: homeassistant\n id: lonely\n",
"button:\n - platform: homeassistant\n id: lonely\n",
],
)
def test_new_platforms_need_an_entity_id_unless_they_are_inputs(
tmp_path: Path, entity_yaml: str
) -> None:
errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entity_yaml))
assert any(
"entity_id is required unless this entity is a wizard input" in error
for error in errors
), errors
DEVICES_HEADER = """
esphome:
name: test
devices:
- id: kitchen_dev
name: Kitchen
esp32:
variant: esp32
wifi:
ssid: test
password: testtest
logger:
"""
JSON_ENTITIES = """
switch:
- platform: template
id: sw
name: Switch
optimistic: true
- platform: template
id: sw2
name: Kitchen Switch
device_id: kitchen_dev
optimistic: true
- platform: homeassistant
id: ha_switch
sensor:
- platform: homeassistant
id: ha_sensor
"""
JSON_PAGES = """
- title: Audio
description: Pick
entities:
- id: sw
description: Enable
- id: sw2
inputs:
- id: weather
description: Weather
target:
entity:
- integration: met
domain: weather
device_class: [temperature, humidity]
- domain: [weather, sensor]
- entity: ha_switch
- inputs: [{entity: ha_sensor}]
"""
def blob_in(main_cpp: str) -> bytes:
"""The compressed wizard that the generated code puts in flash."""
match = re.search(
r"const uint8_t esphome::api::API_WIZARD_DATA\[\] PROGMEM = \{([^}]*)\};",
main_cpp,
)
assert match is not None
return bytes(int(byte) for byte in match.group(1).split(", "))
def entity_hash(main_cpp: str, variable: str) -> int:
"""The key the generated code passes to configure_entity_, which ListEntities then sends."""
match = re.search(
rf'App\.register_switch\({variable}, "[^"]*", (\d+)UL, \d+\)', main_cpp
)
assert match is not None, variable
return int(match.group(1))
def test_the_blob_is_the_exact_json_document(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(
write_config(tmp_path, DEVICES_HEADER, wizard_api(JSON_PAGES), JSON_ENTITIES)
)
blob = blob_in(main_cpp)
text = zstd_module().decompress(blob).decode("utf-8")
expected = {
"version": 1,
"pages": [
{
"title": "Audio",
"description": "Pick",
"entities": [
# No device_id for the main device, and no description when unset
{"key": entity_hash(main_cpp, "sw"), "description": "Enable"},
{
"key": entity_hash(main_cpp, "sw2"),
"device_id": fnv1a_32bit_hash("kitchen_dev"),
},
],
"inputs": [
{
"key": fnv1_hash("weather"),
"description": "Weather",
"entity_filters": [
{
"integration": "met",
"domain": ["weather"],
"device_class": ["temperature", "humidity"],
},
{"domain": ["weather", "sensor"]},
],
},
{
"key": fnv1_hash("ha_switch"),
# The default filter of a switch
"entity_filters": [{"domain": SWITCH_DOMAINS}],
},
],
},
{"inputs": [{"key": fnv1_hash("ha_sensor")}]},
],
}
assert text == json.dumps(
expected, separators=(",", ":"), sort_keys=True, ensure_ascii=False
)
# The device id is the one the generated code gives the device
assert f"set_device_id({fnv1a_32bit_hash('kitchen_dev')})" in main_cpp
assert get_define_value("API_WIZARD_DATA_SIZE") == str(len(blob))
def test_the_blob_is_deterministic_and_the_same_on_every_platform(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
api = wizard_api(JSON_PAGES)
first = blob_in(
generate_main(write_config(tmp_path, DEVICES_HEADER, api, JSON_ENTITIES))
)
# Compressing the same document again gives the same bytes
document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config)
again = zstd_module().compress(
json.dumps(
document, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8"),
level=wizard.WIZARD_ZSTD_LEVEL,
)
assert again == first
assert first[:4] == b"\x28\xb5\x2f\xfd" # a zstd frame
def test_the_blob_is_in_flash_on_esp8266(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(write_input_config(tmp_path, ESP8266_HEADER))
assert "esphome::api::API_WIZARD_DATA[] PROGMEM = {" in main_cpp
assert (
"const api::WizardInputEntry esphome::api::API_WIZARD_INPUTS[] PROGMEM = {"
in main_cpp
)
# Only the compressed data and the input table are emitted, no strings or row tables
assert "api_wizard_str" not in main_cpp
assert "WizardEntityRow" not in main_cpp
def test_inputs_have_buffers_hashes_and_a_table(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER))
# The buffer holds the YAML entity ID until the wizard sets another
assert 'static char api_wizard_input_ha_sensor[256] = "";' in main_cpp
entities = (
"ha_sensor",
"ha_binary",
"ha_text",
"ha_number",
"ha_switch",
"ha_txt",
"ha_select",
"ha_button",
"weather_input",
"unset_input",
)
for entity in entities:
assert f"{{{fnv1_hash(entity)}u, api_wizard_input_{entity}}}" in main_cpp
assert get_define_value("API_WIZARD_INPUT_COUNT") == str(len(entities))
for entity in entities[:-2]:
assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp
# The standalone inputs are objects that read the same buffer
assert "WizardInput(api_wizard_input_weather_input)" in main_cpp
assert "WizardInput(api_wizard_input_unset_input)" in main_cpp
# Not an input, so it keeps its constant
assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp
def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> None:
yaml_entities = 'switch:\n - platform: template\n id: nameless\n name: ""\n optimistic: true\n'
errors = config_errors(
write_config(
tmp_path,
ESP32_HEADER,
wizard_api("- entities: [{id: nameless}]"),
yaml_entities,
)
)
assert any("has no name of its own" in error for error in errors), errors
def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None:
# Random text does not compress, so this needs more than the limit even compressed
rng = random.Random(1)
alphabet = string.ascii_letters + string.digits
entities = ",\n".join(
"{id: sw, description: "
+ "".join(rng.choice(alphabet) for _ in range(255))
+ "}"
for _ in range(400)
)
api = wizard_api(f"- entities: [\n{entities}\n]")
errors = config_errors(write_config(tmp_path, ESP32_HEADER, api))
assert any(
"The compressed wizard is" in error and "bytes over the 65512 bytes" in error
for error in errors
), errors
def test_repeated_text_compresses_well_inside_the_limit(tmp_path: Path) -> None:
# 400 identical entities are far over the limit as JSON, but compress to very little
entities = ",\n".join(["{id: sw, description: " + "x" * 255 + "}"] * 400)
api = wizard_api(f"- entities: [\n{entities}\n]")
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
@pytest.mark.parametrize(
("platform", "entity_id"),
[
("sensor", "sensor.a"),
("switch", "light.a"),
("text", "text.a"),
("button", "button.a"),
],
)
def test_a_linked_entity_must_not_set_an_entity_id(
tmp_path: Path, platform: str, entity_id: str
) -> None:
yaml_entities = (
f"{platform}:\n - platform: homeassistant\n id: fixed\n"
f" entity_id: {entity_id}\n"
)
errors = config_errors(
write_config(
tmp_path, ESP32_HEADER, wizard_inputs("- entity: fixed"), yaml_entities
)
)
assert any(
"'fixed' has an entity_id set in its configuration" in error
and "Remove entity_id" in error
for error in errors
), errors
def test_an_entity_with_an_entity_id_that_is_no_input_is_valid(
tmp_path: Path, generate_main: Callable[[str | Path], str]
) -> None:
main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER))
# Static entries keep their literal and are kept apart from the wizard buffers
assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp
assert "api_wizard_input_ha_plain_sensor" not in main_cpp
# Every buffer starts empty
assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0]
@@ -0,0 +1,44 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
logger:
binary_sensor:
- platform: template
id: single_and
on_press:
- if:
condition:
- lambda: return true;
then:
- logger.log: single and
- platform: template
id: single_or
on_press:
- if:
any:
- lambda: return true;
then:
- logger.log: single or
- platform: template
id: double_and
on_press:
- if:
condition:
- lambda: return true;
- lambda: return false;
then:
- logger.log: double and
- platform: template
id: single_xor
on_press:
- if:
condition:
xor:
- lambda: return true;
then:
- logger.log: single xor
@@ -0,0 +1,25 @@
"""Tests for and/or condition group codegen."""
from collections.abc import Callable
from pathlib import Path
import re
def test_single_condition_groups_are_unwrapped(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A group of one condition is passed to the action directly."""
main_cpp = generate_main(component_config_path("condition_groups.yaml"))
assert "IfAction<false>(lambdacondition_id);" in main_cpp
assert "IfAction<false>(lambdacondition_id_2);" in main_cpp
group = re.search(
r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);",
main_cpp,
)
assert group is not None
assert f"IfAction<false>({group.group(1)});" in main_cpp
# xor of one condition equals that condition, so it is unwrapped too.
assert "IfAction<false>(lambdacondition_id_5);" in main_cpp
assert "XorCondition<" not in main_cpp
@@ -0,0 +1,21 @@
---
esphome:
name: test
on_boot:
then:
- canbus.send: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
- canbus.send:
can_id: 0x100
data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
- canbus.send: !lambda return {0x09};
esp32:
board: esp32dev
canbus:
- platform: esp32_can
id: can_bus
rx_pin: 4
tx_pin: 5
can_id: 4
bit_rate: 50kbps
@@ -0,0 +1,41 @@
"""Tests for canbus constant payloads in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.canbus import CANBUS_SEND_ACTION_SCHEMA
import esphome.config_validation as cv
def test_constant_payloads_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal send payloads share one table; lambdas stay templates."""
main_cpp = generate_main(component_config_path("payload_tables.yaml"))
tables = re.findall(
r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
assert [v for _, v in tables] == [
"{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}"
]
assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2
assert "set_data_template(" in main_cpp
@pytest.mark.parametrize("size", [8, 9])
@pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n])
def test_send_one_frame_limit(
size: int, make: Callable[[int], list[int] | str]
) -> None:
"""Payloads up to one 8 byte CAN frame are accepted, longer ones rejected."""
config = {"canbus_id": "can_bus", "data": make(size)}
if size <= 8:
assert len(CANBUS_SEND_ACTION_SCHEMA(config)["data"]) == size
else:
with pytest.raises(cv.Invalid, match="at most 8"):
CANBUS_SEND_ACTION_SCHEMA(config)
@@ -0,0 +1,25 @@
---
esphome:
name: test
on_boot:
then:
- cc1101.send_packet: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
- cc1101.send_packet:
data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
- cc1101.send_packet: !lambda return {0x09};
esp32:
board: esp32dev
spi:
clk_pin: 18
mosi_pin: 23
miso_pin: 19
cc1101:
id: transceiver
cs_pin: 5
gdo0_pin: 4
frequency: 433.92MHz
packet_mode: true
packet_length: 8
@@ -0,0 +1,41 @@
"""Tests for cc1101 constant payloads in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.cc1101 import SEND_PACKET_ACTION_SCHEMA
import esphome.config_validation as cv
def test_constant_payloads_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal send_packet payloads share one table; lambdas stay templates."""
main_cpp = generate_main(component_config_path("payload_tables.yaml"))
tables = re.findall(
r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
assert [v for _, v in tables] == [
"{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}"
]
assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2
assert "set_data_template(" in main_cpp
@pytest.mark.parametrize("size", [64, 65])
@pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n])
def test_send_packet_tx_fifo_limit(
size: int, make: Callable[[int], list[int] | str]
) -> None:
"""Payloads up to the 64 byte TX FIFO are accepted, longer ones rejected."""
config = {"id": "transceiver", "data": make(size)}
if size <= 64:
assert len(SEND_PACKET_ACTION_SCHEMA(config)["data"]) == size
else:
with pytest.raises(cv.Invalid, match="at most 64"):
SEND_PACKET_ACTION_SCHEMA(config)
@@ -0,0 +1,17 @@
"""Tests for the dlms_meter provider option removed in 2026.11.0."""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.dlms_meter import CONFIG_SCHEMA
def test_removed_provider_shows_the_netznoe_patterns() -> None:
with pytest.raises(Invalid) as exc_info:
CONFIG_SCHEMA({"provider": "netznoe"})
errors = (
exc_info.value.errors
if isinstance(exc_info.value, MultipleInvalid)
else [exc_info.value]
)
assert any('pattern: "L, TSTR"' in str(error) for error in errors)
@@ -0,0 +1,33 @@
"""Tests for the dlms_meter predefined keys removed in 2026.11.0."""
from collections.abc import Callable
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.dlms_meter.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA
from esphome.components.dlms_meter.text_sensor import (
CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA,
)
from esphome.types import ConfigType
def _errors(schema: Callable[[ConfigType], ConfigType], config: ConfigType) -> str:
with pytest.raises(Invalid) as exc_info:
schema(config)
errors = (
exc_info.value.errors
if isinstance(exc_info.value, MultipleInvalid)
else [exc_info.value]
)
return "\n".join(str(error) for error in errors)
def test_removed_sensor_key_names_its_replacement() -> None:
errors = _errors(SENSOR_SCHEMA, {"voltage_l1": {"name": "Voltage"}})
assert 'obis_code: "1.0.32.7.0.255" and unit_of_measurement: V' in errors
def test_removed_text_sensor_key_names_its_replacement() -> None:
errors = _errors(TEXT_SENSOR_SCHEMA, {"timestamp": {"name": "Timestamp"}})
assert 'obis_code: "0.0.1.0.0.255"' in errors
@@ -27,3 +27,8 @@ binary_sensor:
name: Trigger
on_press:
- epaper_spi.full_update_next: epaper_display
- if:
condition:
epaper_spi.is_updating: epaper_display
then:
- component.update: epaper_display
@@ -551,6 +551,16 @@ def test_full_update_next_action_code_generation(
assert "epaper_display->request_full_update();" in main_cpp
def test_is_updating_condition_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The epaper_spi.is_updating condition checks the configured display."""
main_cpp = generate_main(component_config_path("full_update_next_test.yaml"))
assert "epaper_display->is_updating()" in main_cpp
def test_model_with_no_default_init_sequence_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -12,6 +12,3 @@ wifi:
identity: "user@example.org"
username: "user"
password: "secret"
http_request:
verify_ssl: true
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
disable_mbedtls_tls: false
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP_TLS_INSECURE: y
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_TLS_ENABLED: n
+707 -17
View File
@@ -10,36 +10,59 @@ from typing import Any
import pytest
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.esp32 import (
_ESP_TLS_LINKING_COMPONENTS,
DEFAULT_EXCLUDED_IDF_COMPONENTS,
ESP32_FLASH_CHIPS,
KEY_FATFS_REQUIRED,
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED,
KEY_MBEDTLS_TLS_SERVER_REQUIRED,
KEY_VFS_DIR_REQUIRED,
KEY_VFS_SELECT_REQUIRED,
KEY_VFS_TERMIOS_REQUIRED,
MBEDTLS_TLS_EXTRA_OPTIONS,
VARIANT_ESP32,
VARIANTS,
MbedtlsSdkconfigData,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_mbedtls_sdkconfig,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
_user_sdkconfig_wants_tls,
)
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
KEY_IDF_VERSION,
KEY_MBEDTLS_SDKCONFIG,
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.components.esp32.gpio import validate_gpio_pin, validate_supports
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
CONF_IGNORE_PIN_VALIDATION_ERROR,
CONF_INPUT,
CONF_MODE,
CONF_NUMBER,
CONF_OPEN_DRAIN,
CONF_OUTPUT,
CONF_PULLDOWN,
CONF_PULLUP,
PlatformFramework,
Toolchain,
)
@@ -52,7 +75,7 @@ def test_esp32_config(
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY
from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_FRIENDLY
# Example ESP32 configuration
config = {
@@ -88,6 +111,245 @@ def test_esp32_config(
assert VARIANT_FRIENDLY[variant].lower() in config["board"]
@pytest.mark.parametrize(
(
"sdkconfig_options",
"cpu_frequency",
"expected_frequency",
"warns",
"variant",
"framework_type",
),
[
pytest.param(
{
"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y",
"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160",
},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-frequency-options-with-default",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"},
"160MHz",
"160MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="explicit-cpu-frequency",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-frequency-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="current-idf-frequency-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "n"},
None,
"240MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="disabled-frequency-choice",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240": "n"},
"240MHz",
"240MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="ignore-unrecognized-legacy-disabled-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32C3,
"esp-idf",
id="current-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32S2_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32S2,
"esp-idf",
id="s2-legacy-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32S3,
"esp-idf",
id="s3-legacy-choice-frequency",
),
pytest.param(
{"CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80": "y"},
None,
"80MHZ",
True,
VARIANT_ESP32C3,
"esp-idf",
id="c3-legacy-choice-frequency",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"arduino",
id="arduino-framework-current-scalar-frequency",
),
],
)
def test_sdkconfig_cpu_frequency_is_honored(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
sdkconfig_options: dict[str, str],
cpu_frequency: str | None,
expected_frequency: str,
warns: bool,
variant: str,
framework_type: str,
) -> None:
"""SDK config frequency settings are honored and recommend the native config key."""
platform_framework = (
PlatformFramework.ESP32_ARDUINO
if framework_type == "arduino"
else PlatformFramework.ESP32_IDF
)
set_core_config(platform_framework)
from esphome.components.esp32 import CONFIG_SCHEMA
config: dict[str, Any] = {
"variant": variant,
"framework": {
"type": framework_type,
"sdkconfig_options": sdkconfig_options,
},
}
if cpu_frequency is not None:
config["cpu_frequency"] = cpu_frequency
with caplog.at_level(logging.WARNING):
config = CONFIG_SCHEMA(config)
assert ("CPU frequency setting" in caplog.text) is warns
assert config["cpu_frequency"] == expected_frequency
@pytest.mark.parametrize(
("variant", "cpu_frequency", "sdkconfig_options", "error_match"),
[
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "fast"},
"must be an integer MHz value",
id="integer-option-not-a-number",
),
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"},
"ESP32C3 does not support",
id="unsupported-frequency",
),
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "yes"},
"must be y or n",
id="choice-option-not-y-or-n",
),
pytest.param(
VARIANT_ESP32C3,
None,
{
"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y",
"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y",
},
r"conflicting CPU frequencies \(160MHz, 240MHz\)",
id="two-frequencies-selected",
),
pytest.param(
VARIANT_ESP32,
"160MHz",
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"},
"conflicts with sdkconfig_options",
id="conflicts-with-explicit-value",
),
pytest.param(
VARIANT_ESP32,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "n"},
"disables the selected CPU frequency",
id="default-frequency-disabled",
),
pytest.param(
VARIANT_ESP32,
"160MHz",
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "n"},
"disables the selected CPU frequency",
id="explicit-frequency-disabled-by-legacy-option",
),
],
)
def test_sdkconfig_cpu_frequency_errors(
set_core_config: SetCoreConfigCallable,
variant: str,
cpu_frequency: str | None,
sdkconfig_options: dict[str, str],
error_match: str,
) -> None:
"""Bad, conflicting or disabled sdkconfig frequencies fail at validation."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
config: dict[str, Any] = {
"variant": variant,
"framework": {"type": "esp-idf", "sdkconfig_options": sdkconfig_options},
}
if cpu_frequency is not None:
config["cpu_frequency"] = cpu_frequency
with pytest.raises(cv.Invalid, match=error_match) as exc_info:
CONFIG_SCHEMA(config)
assert exc_info.value.path == ["framework", "sdkconfig_options"]
@pytest.mark.parametrize(
("config_toolchain", "expected"),
[
@@ -322,8 +584,8 @@ def test_esp32_configuration_errors(
("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"),
id="i2c_ledc_ac_dimmer",
),
# esp-tls has three owners; a per-owner config makes a dropped
# re-include from any single one fail the test.
# esp-tls comes back through request_tls(); a per-owner config makes
# a dropped request from any single one fail the test.
pytest.param(
"exclusion_reincludes_http_request.yaml",
("esp-tls", "esp_http_client"),
@@ -337,8 +599,9 @@ def test_esp32_configuration_errors(
id="mqtt",
),
pytest.param(
# Basic auth uses mbedtls_base64_encode directly, so no esp-tls.
"exclusion_reincludes_web_server.yaml",
("esp-tls", "esp_http_server"),
("esp_http_server",),
id="web_server_idf",
),
pytest.param(
@@ -480,6 +743,303 @@ def test_user_sdkconfig_certificate_bundle_wins(
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
_TLS_OFF_CRYPTO = {
"CONFIG_MBEDTLS_ECP_C": False,
"CONFIG_MBEDTLS_PEM_WRITE_C": False,
"CONFIG_MBEDTLS_X509_CRL_PARSE_C": False,
"CONFIG_MBEDTLS_X509_CSR_PARSE_C": False,
}
_TLS_OFF_IDF5 = {
"CONFIG_MBEDTLS_TLS_DISABLED": True,
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False,
**_TLS_OFF_CRYPTO,
}
_TLS_OFF_IDF6 = {
"CONFIG_MBEDTLS_TLS_ENABLED": False,
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False,
**_TLS_OFF_CRYPTO,
}
_PEER_CERT_PKCS7_OFF = {
"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": False,
"CONFIG_MBEDTLS_PKCS7_C": False,
}
_TLS_EXTRAS_OFF = dict.fromkeys(MBEDTLS_TLS_EXTRA_OPTIONS, False)
_TLS_CLIENT_ONLY = {
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY": True,
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": False,
}
_IDF5 = cv.Version(5, 5, 5)
_IDF6 = cv.Version(6, 0, 0)
@pytest.mark.parametrize(
("framework", "idf", "data", "preset", "expected", "excluded"),
[
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{},
{**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf5_no_tls_user",
),
pytest.param(
# An external component that only re-included esp-tls keeps TLS.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{},
{**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"},
id="idf_esp_tls_reincluded",
),
pytest.param(
# esp_http_client links esp_tls itself, so re-including it counts too.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{},
{**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS) - {"esp_http_client"},
id="idf_http_client_reincluded",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF6,
MbedtlsSdkconfigData(),
{},
{
**_TLS_OFF_IDF6,
**_TLS_EXTRAS_OFF,
**_PEER_CERT_PKCS7_OFF,
"CONFIG_MBEDTLS_SHA384_C": False,
"CONFIG_MBEDTLS_SHA512_C": False,
},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf6_drops_sha512",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF6,
MbedtlsSdkconfigData(sha512_required=True),
{},
{**_TLS_OFF_IDF6, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf6_sha512_required",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(ecp_required=True),
{},
{
**{
k: v
for k, v in _TLS_OFF_IDF5.items()
if k != "CONFIG_MBEDTLS_ECP_C"
},
**_TLS_EXTRAS_OFF,
**_PEER_CERT_PKCS7_OFF,
},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_ecp_without_tls",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(),
{"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y")},
{
**_TLS_OFF_IDF5,
"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y"),
**_TLS_EXTRAS_OFF,
**_PEER_CERT_PKCS7_OFF,
},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_user_ecp_wins",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(peer_cert_required=True, pkcs7_required=True),
{},
{
**_TLS_OFF_IDF5,
**_TLS_EXTRAS_OFF,
"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": True,
"CONFIG_MBEDTLS_PKCS7_C": True,
},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_peer_cert_pkcs7_required",
),
pytest.param(
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(disable_peer_cert=False, disable_pkcs7=False),
{},
{**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_advanced_disables_off",
),
pytest.param(
# advanced: disable_mbedtls_tls: false keeps TLS with no requester.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(disable_tls=False),
{},
{**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_disable_tls_opt_out",
),
pytest.param(
# require_mbedtls_tls() keeps TLS with every wrapper still excluded.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(tls_required=True),
{},
{**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="idf_require_mbedtls_tls",
),
pytest.param(
# TLS kept: a required server role blocks the client-only trim.
PlatformFramework.ESP32_IDF,
_IDF5,
MbedtlsSdkconfigData(tls_server_required=True),
{},
{**_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"},
id="idf_tls_server_required",
),
pytest.param(
PlatformFramework.ESP32_ARDUINO,
_IDF5,
MbedtlsSdkconfigData(),
{},
{**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF},
set(_ESP_TLS_LINKING_COMPONENTS),
id="arduino_keeps_tls",
),
],
)
def test_reconcile_mbedtls_sdkconfig(
set_core_config: SetCoreConfigCallable,
framework: PlatformFramework,
idf: cv.Version,
data: MbedtlsSdkconfigData,
preset: dict[str, Any],
expected: dict[str, Any],
excluded: set[str],
) -> None:
"""The FINAL-priority reconciler turns TLS off only when nothing requested it;
user sdkconfig_options always win."""
set_core_config(framework)
CORE.data[KEY_ESP32] = {
KEY_IDF_VERSION: idf,
KEY_SDKCONFIG_OPTIONS: dict(preset),
KEY_MBEDTLS_SDKCONFIG: data,
KEY_EXCLUDE_COMPONENTS: excluded,
}
asyncio.run(_reconcile_mbedtls_sdkconfig())
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
def test_esp_tls_linking_components_are_excluded_by_default() -> None:
"""The fallback scan is only a real signal while every name is excluded by default."""
assert set(_ESP_TLS_LINKING_COMPONENTS) <= set(DEFAULT_EXCLUDED_IDF_COMPONENTS)
@pytest.mark.parametrize(
("options", "wants_tls"),
[
pytest.param({}, False, id="empty"),
pytest.param({"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": "y"}, True, id="role_y"),
pytest.param({"CONFIG_MBEDTLS_TLS_ENABLED": "n"}, False, id="enabled_n"),
pytest.param({"CONFIG_MBEDTLS_TLS_DISABLED": "n"}, True, id="disabled_n"),
pytest.param({"CONFIG_ESP_TLS_INSECURE": "y"}, True, id="esp_tls_prefix"),
pytest.param(
{"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": "n"},
False,
id="prefix_n_is_not_a_request",
),
pytest.param({"CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP": "y"}, True, id="https_prefix"),
pytest.param({"CONFIG_OPENTHREAD_COMMISSIONER": "y"}, True, id="ot_dtls_y"),
pytest.param({"CONFIG_OPENTHREAD_JOINER": "n"}, False, id="ot_dtls_n"),
pytest.param({"CONFIG_OPENTHREAD_BORDER_ROUTER": "y"}, True, id="ot_br_y"),
pytest.param(
{"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": "y"}, True, id="wifi_enterprise_y"
),
pytest.param({"CONFIG_LWIP_IPV6": "y"}, False, id="unrelated"),
],
)
def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> None:
"""The sdkconfig escape hatch reads values, never bare key presence."""
assert _user_sdkconfig_wants_tls(options) is wants_tls
@pytest.mark.parametrize(
("config_file", "tls_off", "ecp_off", "esp_tls_excluded"),
[
pytest.param("network_ethernet_only.yaml", True, True, True, id="ethernet_api"),
pytest.param(
"exclusion_reincludes_web_server.yaml",
True,
True,
True,
id="web_server_idf",
),
pytest.param(
"exclusion_reincludes_http_request.yaml",
False,
False,
False,
id="http_request",
),
pytest.param("exclusion_reincludes_mqtt.yaml", False, False, False, id="mqtt"),
pytest.param(
"exclusion_reincludes_nextion.yaml", False, False, False, id="nextion"
),
pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"),
# zigbee requests ECP for the esp-zigbee-lib blobs, without TLS.
pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"),
# A raw bundle keeps the TLS role but no longer compiles esp-tls.
pytest.param(
"certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle"
),
pytest.param(
"tls_sdkconfig_esp_tls.yaml", False, False, False, id="raw_esp_tls"
),
# A role option set to n is not a request.
pytest.param(
"tls_sdkconfig_tls_enabled_n.yaml", True, True, True, id="raw_tls_enabled_n"
),
# ECDSA signed OTA requests ECP itself (SECURE_SIGNED_APPS selects it too).
pytest.param(
"signed_ota_ecdsa256_c6.yaml", True, False, True, id="signed_ota_ecdsa"
),
],
)
def test_tls_disabled_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
tls_off: bool,
ecp_off: bool,
esp_tls_excluded: bool,
) -> None:
"""TLS is compiled out unless a component or a raw sdkconfig option asks for it."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off
assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None)
assert (
"esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
) is esp_tls_excluded
def test_execute_from_psram_s3_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -1419,9 +1979,8 @@ def test_esp32_s31_gpio_validation(
caplog: pytest.LogCaptureFixture,
) -> None:
"""S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29
and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a
and GPIO41 do not exist, GPIO35 is a normal pin, and GPIO36 is a
strapping pin."""
from esphome.components.esp32.const import VARIANT_ESP32S31
from esphome.components.esp32.gpio import validate_supports
from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT
@@ -1432,8 +1991,8 @@ def test_esp32_s31_gpio_validation(
input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False}
# Not reserved; a normal GPIO
pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
assert validate_gpio_pin(pin)[CONF_NUMBER] == 33
pin = {CONF_NUMBER: 35, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
assert validate_gpio_pin(pin)[CONF_NUMBER] == 35
# Reserved for the SPI flash interface, but can be bypassed with
# ignore_pin_validation_error
@@ -1461,6 +2020,113 @@ def test_esp32_s31_gpio_validation(
assert "GPIO36 is a strapping pin" in caplog.text
_INPUT_ONLY_SETTINGS = (
(CONF_OUTPUT, "does not support output pin mode"),
(CONF_PULLUP, "does not support pullups"),
(CONF_PULLDOWN, "does not support pulldowns"),
(CONF_HOLD_STATE, "is input-only and cannot be held"),
)
@pytest.mark.parametrize(
("variant", "number", "setting", "error"),
[
pytest.param(
variant, number, setting, error, id=f"{name}-gpio{number}-{setting}"
)
for variant, name, numbers in (
(VARIANT_ESP32, "esp32", range(34, 40)),
(VARIANT_ESP32S2, "s2", (46,)),
)
for number in numbers
for setting, error in _INPUT_ONLY_SETTINGS
]
+ [
pytest.param(
VARIANT_ESP32,
20,
CONF_HOLD_STATE,
"GPIO20 has no hold function",
id="esp32-gpio20-hold_state",
)
],
)
def test_input_only_gpio_rejects_unsupported_modes(
set_core_config: SetCoreConfigCallable,
variant: str,
number: int,
setting: str,
error: str,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant})
mode = {
CONF_INPUT: True,
CONF_OUTPUT: False,
CONF_OPEN_DRAIN: False,
CONF_PULLUP: False,
CONF_PULLDOWN: False,
}
pin = {CONF_NUMBER: number, CONF_MODE: mode}
if setting == CONF_HOLD_STATE:
pin[setting] = True
else:
mode[setting] = True
with pytest.raises(cv.Invalid, match=error):
validate_supports(pin)
@pytest.mark.parametrize(
("variant", "number"),
[
pytest.param(VARIANT_ESP32C3, 18, id="c3-18"),
pytest.param(VARIANT_ESP32C3, 19, id="c3-19"),
pytest.param(VARIANT_ESP32C5, 13, id="c5-13"),
pytest.param(VARIANT_ESP32C5, 14, id="c5-14"),
pytest.param(VARIANT_ESP32C6, 12, id="c6-12"),
pytest.param(VARIANT_ESP32C6, 13, id="c6-13"),
pytest.param(VARIANT_ESP32C61, 12, id="c61-12"),
pytest.param(VARIANT_ESP32C61, 13, id="c61-13"),
pytest.param(VARIANT_ESP32H2, 26, id="h2-26"),
pytest.param(VARIANT_ESP32H2, 27, id="h2-27"),
pytest.param(VARIANT_ESP32H4, 13, id="h4-13"),
pytest.param(VARIANT_ESP32H4, 14, id="h4-14"),
pytest.param(VARIANT_ESP32H21, 17, id="h21-17"),
pytest.param(VARIANT_ESP32H21, 18, id="h21-18"),
pytest.param(VARIANT_ESP32P4, 24, id="p4-24"),
pytest.param(VARIANT_ESP32P4, 25, id="p4-25"),
pytest.param(VARIANT_ESP32S3, 19, id="s3-19"),
pytest.param(VARIANT_ESP32S3, 20, id="s3-20"),
pytest.param(VARIANT_ESP32S31, 33, id="s31-33"),
pytest.param(VARIANT_ESP32S31, 34, id="s31-34"),
],
)
def test_usb_jtag_gpio_hold_state_warns(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
variant: str,
number: int,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant})
pin = {
CONF_NUMBER: number,
CONF_MODE: {
CONF_INPUT: True,
CONF_OUTPUT: False,
CONF_OPEN_DRAIN: False,
},
CONF_HOLD_STATE: True,
}
with caplog.at_level(logging.WARNING):
validate_supports(pin)
assert (
f"GPIO{number} cannot hold at low level during wakeup from deep sleep."
in caplog.text
)
_TLS_SERVER_OPTIONS = (
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
@@ -1496,9 +2162,14 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed."""
"""Nothing in the OpenThread config links TLS, so the stack is compiled out
and no TLS role is written; the extras trim still runs because CCM and
deterministic ECDSA are plain crypto, and OpenThread keeps those two."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True
# require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off
assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig
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 _CCM_ECDSA_EXTRAS else False)
@@ -1508,14 +2179,31 @@ 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."""
"""Nothing in the Zigbee config links TLS, so the stack is compiled out and
no role is written; the extras trim still runs and Zigbee keeps CCM and
deterministic ECDSA."""
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)
assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True
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 _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""disable_mbedtls_tls: false keeps TLS with no requester; the client-only
and extras trims then still apply."""
generate_main(component_config_path("tls_keep_opt_out.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_MBEDTLS_TLS_DISABLED" not in sdkconfig
assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig
assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is True
assert sdkconfig.get("CONFIG_MBEDTLS_SSL_RENEGOTIATION") is False
def test_mbedtls_tls_user_sdkconfig_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -1541,8 +2229,9 @@ def test_mbedtls_tls_openthread_requires_server_and_extras(
) -> None:
"""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] == _CCM_ECDSA_EXTRAS
mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG]
assert mbedtls.tls_server_required is True
assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS
def test_mbedtls_tls_zigbee_requires_extras(
@@ -1551,7 +2240,8 @@ def test_mbedtls_tls_zigbee_requires_extras(
) -> 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
mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG]
assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@@ -0,0 +1,5 @@
esphome:
name: test
esp8266:
board: d1_mini
@@ -0,0 +1,17 @@
"""Tests for ESP8266 build flags."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
def test_esp8266_keeps_switches_out_of_ram(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("minimal.yaml"))
assert "-fno-tree-switch-conversion" in CORE.build_flags
@@ -0,0 +1,22 @@
---
esphome:
name: test
on_boot:
then:
- espnow.send:
address: 11:22:33:44:55:66
data: [0x01, 0x02, 0x03]
- espnow.broadcast:
data: [0x01, 0x02, 0x03]
- espnow.broadcast: "OK"
- espnow.broadcast:
data: !lambda return {0x09};
esp32:
board: esp32dev
wifi:
ssid: test
password: password1
espnow:
@@ -0,0 +1,23 @@
"""Tests for ESP-NOW constant send payloads in shared flash tables."""
from collections.abc import Callable
from pathlib import Path
import re
def test_constant_payloads_share_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal payloads share one table; lambdas stay templates."""
main_cpp = generate_main(component_config_path("payload_tables.yaml"))
tables = dict(
re.findall(
r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
)
assert sorted(tables.values()) == sorted(["{0x01, 0x02, 0x03}", "{0x4F, 0x4B}"])
shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}")
assert main_cpp.count(f"set_data_static({shared}, 3);") == 2
assert "set_data_template(" in main_cpp
@@ -8,6 +8,7 @@ from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
@@ -82,3 +83,40 @@ def test_ch390_rejects_clock_speed_above_the_datasheet_maximum(
CORE.name = "ch390-test"
with pytest.raises(Invalid, match="value must be at most 72000000"):
CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
_LAN8720_CONFIG = {"type": "LAN8720", "mdc_pin": 23, "mdio_pin": 18}
def _set_esp32_core(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32dev",
KEY_VARIANT: VARIANT_ESP32,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
CORE.name = "rmii-test"
def test_rmii_rejects_removed_clk_mode(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The removed clk_mode option points at the clk: replacement."""
_set_esp32_core(set_core_config)
with pytest.raises(Invalid, match="GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17"):
CONFIG_SCHEMA(
{
**_LAN8720_CONFIG,
"clk_mode": "GPIO17_OUT",
"clk": {"mode": "CLK_OUT", "pin": 17},
}
)
def test_rmii_requires_clk(set_core_config: SetCoreConfigCallable) -> None:
"""An RMII PHY has no default clock, so clk must be given."""
_set_esp32_core(set_core_config)
with pytest.raises(Invalid, match=r"required key not provided @ data\['clk'\]"):
CONFIG_SCHEMA(_LAN8720_CONFIG)
@@ -0,0 +1,38 @@
"""Tests for GPIO output final validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.gpio.output import FINAL_VALIDATE_SCHEMA
from esphome.config import Config
from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY, PlatformFramework
from esphome.core import ID
from tests.component_tests.types import SetCoreConfigCallable
@pytest.mark.parametrize("power_supply_holds", [False, True])
def test_output_hold_state_requires_power_supply_hold_state(
set_core_config: SetCoreConfigCallable,
power_supply_holds: bool,
) -> None:
supply_id = ID("supply", is_declaration=True, type="power_supply")
full_config = Config()
full_config["power_supply"] = [
{CONF_ID: supply_id, CONF_PIN: {CONF_HOLD_STATE: power_supply_holds}}
]
full_config.declare_ids.append((supply_id, ["power_supply", 0, CONF_ID]))
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
config = {
CONF_PIN: {CONF_HOLD_STATE: True},
CONF_POWER_SUPPLY: ID("supply", is_declaration=False, type="power_supply"),
}
if power_supply_holds:
FINAL_VALIDATE_SCHEMA(config)
else:
with pytest.raises(
cv.Invalid,
match="The power supply pin must also set hold_state when an output it powers sets it",
):
FINAL_VALIDATE_SCHEMA(config)
@@ -42,18 +42,20 @@ def test_platform_without_ble_server_rejected(
assert "only available on" in capsys.readouterr().out
def test_automations_emit_renamed_triggers(
def test_automations_register_state_callbacks(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("automations.yaml"))
for trigger in (
"ImprovBLEProvisionedTrigger",
"ImprovBLEProvisioningTrigger",
"ImprovBLEStartTrigger",
"ImprovBLEStateTrigger",
"ImprovBLEStoppedTrigger",
assert "Trigger" not in main_cpp
for state in (
"improv::STATE_PROVISIONED",
"improv::STATE_PROVISIONING",
"improv::STATE_AUTHORIZED",
"improv::STATE_STOPPED",
):
assert f"improv_ble::{trigger}" in main_cpp
assert f"state == {state}" in main_cpp
assert main_cpp.count("add_on_state_callback(") == 5
assert "->is_failed()" in main_cpp
assert "set_authorizer" in main_cpp
assert "set_status_indicator" in main_cpp
+28
View File
@@ -0,0 +1,28 @@
"""Tests for it8951 configuration validation."""
from collections.abc import Callable
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
from esphome.components.it8951.display import CONFIG_SCHEMA
from esphome.const import CONF_ENABLE_PIN, CONF_NUMBER, PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_reterminal_e1003_enable_pins_hold_state(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The reTerminal E1003 enable pins must hold their state during deep sleep."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 7, "mosi_pin": 9})
config = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-e1003"})
enable_pins = config[CONF_ENABLE_PIN]
assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11]
assert all(pin[CONF_HOLD_STATE] for pin in enable_pins)
@@ -2,6 +2,14 @@
from collections.abc import Callable
from pathlib import Path
import re
import pytest
import esphome.codegen as cg
from esphome.components import light
from esphome.components.light import types as light_types
from esphome.core import ID, EsphomeError
def test_default_flash_length_and_empty_effects_are_not_emitted(
@@ -16,4 +24,20 @@ def test_default_flash_length_and_empty_effects_are_not_emitted(
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
call = re.search(r"fancy_light->add_effects\((\w+), (\d+)\);", main_cpp)
assert call is not None
# The effect pointers form a flash table; they are address constants, not constexpr
assert re.search(
rf"static light::LightEffect \* const {call.group(1)}\[\] PROGMEM = \{{[^}}]+\}};",
main_cpp,
)
def test_effect_assigned_in_setup_is_rejected() -> None:
"""A pointer assigned in setup() would make the flash table need dynamic init."""
dynamic_effect = cg.Pvariable(
ID("dynamic_effect", is_declaration=True, type=light_types.LightEffect),
cg.RawExpression("make_effect()"),
)
with pytest.raises(EsphomeError, match="dynamic_effect"):
light._effects_table([dynamic_effect])
@@ -10,9 +10,11 @@ import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4
# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their
# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test.
# Importing the I/O expanders registers their pin schemas with
# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed
# pins (e.g. SEEED-RETERMINAL-D1001 uses xl9535, the M5Stack Tab5 models use
# pi4ioe5v6408) can be validated by the mipi_dsi CONFIG_SCHEMA in this test.
import esphome.components.pi4ioe5v6408 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
CONF_DIMENSIONS,
@@ -0,0 +1,43 @@
esphome:
name: test
esp32:
board: esp32dev
remote_transmitter:
pin: GPIO4
carrier_duty_percent: 50%
button:
- platform: template
name: Midea A
on_press:
- remote_transmitter.transmit_midea:
code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF]
- platform: template
name: Midea B
on_press:
- remote_transmitter.transmit_midea:
code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF]
- platform: template
name: AEHA
on_press:
- remote_transmitter.transmit_aeha:
address: 0x8008
data: [0x00, 0x02, 0xFD, 0xFF, 0x00, 0x33]
- platform: template
name: Haier lambda
on_press:
- remote_transmitter.transmit_haier:
code: !lambda |-
return {0xA6, 0xDA, 0x00, 0x00};
- platform: template
name: Haier static
on_press:
- remote_transmitter.transmit_haier:
code: [0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05]
- platform: template
name: Mirage static
on_press:
- remote_transmitter.transmit_mirage:
code: [0x56, 0x6C, 0x00, 0x00, 0x20, 0x1A, 0x00, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0x42]
@@ -0,0 +1,24 @@
"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas."""
from collections.abc import Callable
from pathlib import Path
import re
def test_ir_codes_use_shared_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("ir_codes.yaml"))
tables = re.findall(
r"static constexpr uint8_t (remote_base_code\w*)\[\] PROGMEM", main_cpp
)
assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage
midea = tables[0]
assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2
assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp)
assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp)
assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp)
assert "->set_code_template([](" in main_cpp
assert "std::vector<uint8_t>{" not in main_cpp
@@ -20,7 +20,7 @@ def test_proxy_claims_its_slots_through_the_shared_accounting(
# A default (3-slot) proxy build records one claim per slot, attributed
# to the consumer, and passes final validation.
generate_main(component_config_path("rp2_proxy_default.yaml"))
used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
used = CORE.data[rp2040_ble.DOMAIN][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
assert used == ["bluetooth_proxy"] * 3
@@ -0,0 +1,27 @@
"""Validation tests for the frameworks the sendspin hub builds on.
sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0
cannot use, so the hub is ESP-IDF only.
"""
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
HUB_CONFIG = {"id": "sendspin_hub_id"}
def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_ARDUINO)
with pytest.raises(cv.Invalid, match="esp-idf"):
CONFIG_SCHEMA(HUB_CONFIG)
def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id"
@@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum(
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""The Sendspin protocol only allows JPEG and PNG artwork."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"):
CONFIG_SCHEMA(_slot_config(format="BMP"))
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
set_core_config(PlatformFramework.ESP32_IDF)
@@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected(
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
def test_opus_only_codec_list_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Every server supports FLAC or PCM, so sendspin-cpp requires one of them."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"]))
@@ -0,0 +1,95 @@
"""Validation tests for the sendspin hub's pairing options.
These cover the rejection branches, which a compile test cannot reach: a
`test*.yaml` can only assert that a configuration is accepted.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_STATIC_PAIRING_CODE,
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
request_pairing_code_display_support,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _hub_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid hub config, allowing field overrides."""
config: ConfigType = {"id": "sendspin_hub_id"}
config.update(overrides)
return config
def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None:
"""The baseline the rejection tests vary is itself valid."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_hub_config())
assert CONF_STATIC_PAIRING_CODE not in config
@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"])
def test_static_pairing_code_accepted(
set_core_config: SetCoreConfigCallable, code: str
) -> None:
"""Eight decimal digits, leading zeros included, pass through unchanged."""
set_core_config(PlatformFramework.ESP32_IDF)
assert (
CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE]
== code
)
@pytest.mark.parametrize(
"code",
[
"0123456", # too short
"012345678", # too long
"0123456a", # not all decimal digits
"0123 567", # whitespace is not a digit
"01234567", # full-width digits are not ASCII
"",
],
)
def test_static_pairing_code_rejected(
set_core_config: SetCoreConfigCallable, code: str
) -> None:
"""Anything that is not exactly eight decimal digits is refused here, since the
library would otherwise refuse to start the client."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"):
CONFIG_SCHEMA(_hub_config(static_pairing_code=code))
def test_unquoted_static_pairing_code_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""An unquoted YAML code arrives as an int, having already lost its leading
zeros, so it is refused rather than silently pairing with the wrong value."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567))
def test_static_code_with_dynamic_code_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Only one pairing code method can be offered, so a static code next to the dynamic
code would be silently ignored."""
set_core_config(PlatformFramework.ESP32_IDF)
request_pairing_code_display_support()
config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567"))
with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"):
FINAL_VALIDATE_SCHEMA(config)
@@ -0,0 +1,60 @@
"""Validation tests for the sendspin text sensor platform.
These cover behavior a compile test cannot observe: which roles a sensor type requests.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import _get_data
from esphome.components.sendspin.text_sensor import (
CONF_PAIRING_CODE,
CONFIG_SCHEMA,
SENDSPIN_TEXT_METADATA_TYPES,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _sensor_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid text sensor config, allowing field overrides."""
config: ConfigType = {"name": "Sendspin Text", "type": "title"}
config.update(overrides)
return config
@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES))
def test_metadata_types_request_metadata_role(
set_core_config: SetCoreConfigCallable, sensor_type: str
) -> None:
"""Metadata sensors need the metadata role, and say nothing about pairing codes."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=sensor_type))
assert _get_data().metadata_support is True
assert _get_data().pairing_code_display_support is False
def test_pairing_code_requests_code_display(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A pairing_code sensor is a way to show the dynamic code, so it alone makes the
hub offer it. It needs no role, so a pairing-only config builds without metadata."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE))
assert _get_data().pairing_code_display_support is True
assert _get_data().metadata_support is False
def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A misspelled type must fail rather than fall through to a default."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_sensor_config(type="pairing_secret"))
@@ -0,0 +1,185 @@
"""Validation tests for the unpaired access policy.
The hub's `unpaired_access` key and an `unpaired_access` switch both set the
policy, so the switch platform rejects a config that has both.
"""
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_UNPAIRED_ACCESS,
CONFIG_SCHEMA as HUB_CONFIG_SCHEMA,
DOMAIN,
FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA,
_get_data,
request_pairing_code_display_support,
)
from esphome.components.sendspin.switch import (
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub_id"
NO_PAIRING_WARNING = "no new server can pair with this device"
def _switch_config(switch_type: str) -> ConfigType:
return SWITCH_CONFIG_SCHEMA(
{"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID}
)
def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None:
"""With no default in the schema, the switch can tell whether the key was set."""
set_core_config(PlatformFramework.ESP32_IDF)
assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID})
@pytest.mark.parametrize("value", [True, False])
def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value})
assert config[CONF_UNPAIRED_ACCESS] is value
def test_off_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""With unpaired access off and no way to pair, no new server could pair."""
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False})
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING in caplog.text
@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"])
def test_off_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
method: str,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}
if method == "static_pairing_code":
hub_config["static_pairing_code"] = "01234567"
else:
request_pairing_code_display_support()
config = HUB_CONFIG_SCHEMA(hub_config)
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_on_does_not_warn(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID})
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Both would set the policy, and the hub's value would be silently ignored."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
def test_switch_without_hub_key_accepted(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
def test_switch_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""Turning the switch off would leave no way for a new server to pair."""
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING in caplog.text
def test_switch_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None:
"""Only the unpaired access switch competes with the hub key."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config("enabled")
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_duplicate_switch_type_rejected(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""Two switches of one type would fight over the same hub setting."""
entry = {"platform": DOMAIN, "type": switch_type}
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]},
)
config = _switch_config(switch_type)
with pytest.raises(cv.Invalid, match="Only one sendspin"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_switch_requests_its_type(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""The hub skips the codegen value for a setting a switch drives, so the client
waits for that switch to restore before its first start."""
set_core_config(PlatformFramework.ESP32_IDF)
_switch_config(switch_type)
assert _get_data().switch_types == {switch_type}
@@ -0,0 +1,24 @@
"""timeout (value last) followed by throttle_with_priority with the same period becomes one filter."""
from collections.abc import Callable
from pathlib import Path
import re
def test_timeout_throttle_pair_is_merged(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/timeout_throttle.yaml")
# Only the `merged` sensor's adjacent pair becomes one object
assert (
len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp))
== 1
)
merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp)
assert merged is not None
assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp
# Every other shape keeps its two filters
for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"):
call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp)
assert call is not None and len(call.group(1).split(",")) == 2, sensor_id
@@ -0,0 +1,52 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: merged
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
- platform: template
id: other_periods
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 2s
- platform: template
id: other_order
filters:
- throttle_with_priority: 1s
- timeout:
timeout: 1s
value: last
- platform: template
id: value_list
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority:
timeout: 1s
value: [0.0]
- platform: template
id: configured_value
filters:
- timeout:
timeout: 1s
value: 0.0
- throttle_with_priority: 1s
- platform: template
id: inside_or
filters:
- or:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
+19 -18
View File
@@ -8,7 +8,8 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate
from esphome.components.sntp import DOMAIN as SNTP_DOMAIN
from esphome.components.sntp.time import _sntp_final_validate
from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME
from esphome.core import ID
import esphome.final_validate as fv
@@ -20,7 +21,7 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
}
@@ -33,12 +34,12 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -51,12 +52,12 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
@@ -69,17 +70,17 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_3", is_manual=False),
CONF_SERVERS: ["pool3.ntp.org"],
},
@@ -95,7 +96,7 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: [
"192.168.1.1",
@@ -105,7 +106,7 @@ import esphome.final_validate as fv
],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
@@ -143,7 +144,7 @@ def test_sntp_instance_merging(
sntp_instances = [
tc
for tc in updated_conf[CONF_TIME]
if tc.get(CONF_PLATFORM) == CONF_SNTP
if tc.get(CONF_PLATFORM) == SNTP_DOMAIN
]
assert len(sntp_instances) == expected_count
@@ -169,12 +170,12 @@ def test_sntp_inconsistent_manual_ids() -> None:
# Create configs with manual IDs that are inconsistent
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=True),
CONF_SERVERS: ["192.168.1.1"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=True),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -197,7 +198,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
"""Test that SNTP merging doesn't affect other time platforms."""
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1"],
},
@@ -206,7 +207,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
CONF_ID: ID("homeassistant_time", is_manual=False),
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -226,11 +227,11 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
# Find the platforms
platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]}
assert platforms == {CONF_SNTP, "homeassistant"}
assert platforms == {SNTP_DOMAIN, "homeassistant"}
# Verify SNTP was merged
sntp_instances = [
tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP
tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == SNTP_DOMAIN
]
assert len(sntp_instances) == 1
assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"]
@@ -0,0 +1,27 @@
esphome:
name: test
on_boot:
then:
- sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82]
- sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82]
- sx126x.send_packet: "hi"
- sx126x.send_packet: !lambda return {0x09};
esp32:
board: esp32dev
spi:
clk_pin: 18
mosi_pin: 23
miso_pin: 19
sx126x:
cs_pin: 12
rst_pin: 13
busy_pin: 25
dio1_pin: 26
frequency: 433920000
hw_version: sx1262
modulation: LORA
bandwidth: 125_0kHz
rf_switch: true
@@ -0,0 +1,39 @@
"""Tests for SX126x constant packets in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sx126x import validate_packet_data
import esphome.config_validation as cv
def test_constant_packets_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal constant packets share one table; lambdas stay templates."""
main_cpp = generate_main(component_config_path("packet_tables.yaml"))
tables = dict(
re.findall(
r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
)
assert sorted(tables.values()) == sorted(
["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"]
)
shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}")
assert main_cpp.count(f"set_data_static({shared}, 4);") == 2
assert "set_data_template(" in main_cpp
def test_packet_data_length_limit() -> None:
"""Constant packets must be 1 to 255 bytes long."""
assert len(validate_packet_data([0x01] * 255)) == 255
with pytest.raises(cv.Invalid):
validate_packet_data([0x01] * 256)
with pytest.raises(cv.Invalid):
validate_packet_data([])
@@ -0,0 +1,24 @@
esphome:
name: test
on_boot:
then:
- sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82]
- sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82]
- sx127x.send_packet: "hi"
- sx127x.send_packet: !lambda return {0x09};
esp32:
board: esp32dev
spi:
clk_pin: 18
mosi_pin: 23
miso_pin: 19
sx127x:
cs_pin: 12
rst_pin: 13
dio0_pin: 26
frequency: 433920000
modulation: LORA
preamble_size: 8
@@ -0,0 +1,51 @@
"""Tests for SX127x constant packets in shared PROGMEM tables."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sx127x import CONFIG_SCHEMA, validate_packet_data
import esphome.config_validation as cv
def test_constant_packets_share_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Equal constant packets share one table; lambdas stay templates."""
main_cpp = generate_main(component_config_path("packet_tables.yaml"))
tables = dict(
re.findall(
r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp
)
)
assert sorted(tables.values()) == sorted(
["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"]
)
shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}")
assert main_cpp.count(f"set_data_static({shared}, 4);") == 2
assert "set_data_template(" in main_cpp
def test_packet_data_length_limit() -> None:
"""Constant packets are limited to the 255 bytes the length register holds."""
assert len(validate_packet_data([0x01] * 255)) == 255
with pytest.raises(cv.Invalid):
validate_packet_data([0x01] * 256)
with pytest.raises(cv.Invalid):
validate_packet_data("x" * 256)
with pytest.raises(cv.Invalid):
validate_packet_data([])
def test_payload_length_limit() -> None:
"""payload_length goes into an 8 bit register, so 256 is rejected."""
validator = next(
v for k, v in CONFIG_SCHEMA.schema.items() if str(k) == "payload_length"
)
assert validator(255) == 255
with pytest.raises(cv.Invalid):
validator(256)
@@ -0,0 +1,149 @@
"""Tests for teleinfo sensor tag presets."""
import pytest
from esphome.components.teleinfo import CONF_TAG_NAME
from esphome.components.teleinfo.sensor import apply_tag_config
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@pytest.mark.parametrize(
("tag", "unit", "device_class", "state_class"),
[
("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
(
"ERQ1",
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
),
("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT),
(
"SINSTS",
UNIT_VOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
(
"PREF",
UNIT_KILOVOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT),
("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
],
)
def test_known_tag_preset(
tag: str, unit: str, device_class: str, state_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
assert config[CONF_STATE_CLASS] == state_class
assert CONF_ICON not in config
def test_unknown_tag_keeps_legacy_defaults() -> None:
config = apply_tag_config({CONF_TAG_NAME: "MYTAG"})
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_starting_with_u_is_not_voltage() -> None:
config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"})
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
def test_numeric_tag_name_does_not_crash() -> None:
config = apply_tag_config({CONF_TAG_NAME: 1234})
assert config[CONF_TAG_NAME] == 1234
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
@pytest.mark.parametrize(
("tag", "unit", "device_class"),
[
("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT),
],
)
def test_user_compatible_unit_keeps_preset_device_class(
tag: str, unit: str, device_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
def test_user_incompatible_unit_skips_preset_device_class() -> None:
config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"})
assert config[CONF_UNIT_OF_MEASUREMENT] == "W"
assert CONF_DEVICE_CLASS not in config
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
def test_user_incompatible_device_class_skips_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER}
)
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER
assert CONF_UNIT_OF_MEASUREMENT not in config
def test_user_same_device_class_keeps_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS
def test_user_state_class_is_kept() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT}
)
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
@@ -0,0 +1,23 @@
esphome:
name: test
esp8266:
board: d1_mini
alarm_control_panel:
- platform: template
id: panel_a
name: Panel A
codes:
- "1234"
- "5678"
- platform: template
id: panel_b
name: Panel B
codes:
- "1234"
- "5678"
- platform: template
id: panel_none
name: Panel None
codes: []
@@ -0,0 +1,20 @@
"""Tests for the template alarm control panel codes codegen."""
from collections.abc import Callable
from pathlib import Path
def test_codes_share_one_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Identical code lists share one nullptr-terminated flash table; empty lists set none."""
main_cpp = generate_main(component_config_path("alarm_codes.yaml"))
assert (
'static constexpr const char * alarm_codes[] PROGMEM = {"1234", "5678", nullptr};'
in main_cpp
)
assert "panel_a->set_codes(alarm_codes);" in main_cpp
assert "panel_b->set_codes(alarm_codes);" in main_cpp
assert "panel_none->set_codes(" not in main_cpp
+3 -3
View File
@@ -1,5 +1,5 @@
"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an
on_time or on_time_sync automation exists, so the define must follow them."""
"""automation.cpp (CronTrigger) is only compiled when an on_time automation
exists, so the define must follow it; on_time_sync needs no class."""
from collections.abc import Callable
from pathlib import Path
@@ -14,7 +14,7 @@ from esphome.core import CORE
[
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", True),
("on_time_sync.yaml", False),
],
)
def test_triggers_define_follows_automations(
@@ -0,0 +1,22 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: password1
udp:
addresses:
- 10.0.0.1
button:
- platform: template
name: send
on_press:
- udp.write: [0xAA, 0x55, 0x01, 0x02]
- udp.write: [0xAA, 0x55, 0x01, 0x02]
- udp.write: ""
- udp.write: !lambda return {0x10, 0x20};
@@ -0,0 +1,17 @@
"""Tests for the udp.write action codegen."""
from collections.abc import Callable
from pathlib import Path
def test_write_payloads_share_a_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Identical constant payloads share one flash table; an empty one needs none."""
main_cpp = generate_main(component_config_path("udp_write.yaml"))
assert main_cpp.count("udp_data[] PROGMEM = {0xAA, 0x55, 0x01, 0x02};") == 1
assert main_cpp.count("->set_data_static(udp_data, 4);") == 2
assert "->set_data_static(nullptr, 0);" in main_cpp
assert "->set_data_template(" in main_cpp
@@ -0,0 +1 @@
body { color: red; }
@@ -0,0 +1 @@
console.log("hi");
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
css_include: include.css
js_include: include.js
@@ -0,0 +1,33 @@
"""Tests for the web_server resources emitted as flash tables."""
from collections.abc import Callable
import gzip
from pathlib import Path
import re
def _table(main_cpp: str, name: str) -> bytes:
# Anchored so a static table does not match
match = re.search(
rf"^const uint8_t ESPHOME_WEBSERVER_{name}\[\] PROGMEM = \{{([^}}]*)\}};",
main_cpp,
re.MULTILINE,
)
assert match is not None
return bytes(int(byte) for byte in match.group(1).split(","))
def test_resources_are_externally_linked_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""web_server.cpp reads these through extern declarations, so they must not be static."""
main_cpp = generate_main(component_config_path("includes.yaml"))
assert b"<esp-app></esp-app>" in _table(main_cpp, "INDEX_HTML")
assert gzip.decompress(_table(main_cpp, "CSS_INCLUDE")) == b"body { color: red; }\n"
assert gzip.decompress(_table(main_cpp, "JS_INCLUDE")) == b'console.log("hi");\n'
css_size = len(_table(main_cpp, "CSS_INCLUDE"))
assert (
f"constexpr size_t ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE = {css_size};" in main_cpp
)
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: test
password: password1
time:
- platform: sntp
id: sntp_time
wireguard:
time_id: sntp_time
address: 172.16.34.100
netmask: 255.255.255.0
private_key: wPBMxtNYH3mChicrbpsRpZIasIdPq3yZuthn23FbGG8=
peer_public_key: Hs2JfikvYU03/Kv3YoAs1hrUIPPTEkpsZKSPUljE9yc=
peer_endpoint: wg.server.example
peer_allowed_ips:
- 172.16.34.0/24
- 192.168.0.0/16
@@ -0,0 +1,21 @@
"""Tests for the wireguard allowed IPs codegen."""
from collections.abc import Callable
from pathlib import Path
def test_allowed_ips_are_a_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Allowed IPs live in a flash table ended by an empty entry instead of a heap vector."""
main_cpp = generate_main(component_config_path("allowed_ips.yaml"))
table = main_cpp.split(
"wireguard::AllowedIP wireguard_allowed_ips[] PROGMEM = ", 1
)[1]
table = table.split(";", 1)[0]
assert '.ip = "172.16.34.0"' in table
assert '.netmask = "255.255.0.0"' in table
assert ".ip = nullptr" in table
assert "->set_allowed_ips(wireguard_allowed_ips);" in main_cpp
+13
View File
@@ -0,0 +1,13 @@
adalight:
light:
- platform: neopixelbus
id: adalight_light
name: Neopixelbus Light
method: esp8266_uart
num_leds: 5
pin: 2
type: GRBW
variant: SK6812
effects:
- adalight:
@@ -0,0 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
adalight: !include common.yaml
@@ -1,23 +0,0 @@
sensor:
- platform: adc
pin: VDDHDIV5
name: "VDDH Voltage"
update_interval: 5sec
filters:
- multiply: 5
- platform: adc
pin: VDD
name: "VDD Voltage"
update_interval: 5sec
- platform: adc
pin: AIN0
name: "AIN0 Voltage"
update_interval: 5sec
- platform: adc
pin: P0.03
name: "AIN1 Voltage"
update_interval: 5sec
- platform: adc
name: "AIN2 Voltage"
update_interval: 5sec
pin: 4
@@ -1,3 +1,5 @@
# Config only: every LibreTiny chip compiles the same ADC code, covered by the
# ln882x-ard build; this checks the BK72xx analog pin names.
sensor:
- id: adc_my_sensor
platform: adc
@@ -1,3 +1,5 @@
# Config only: the P4 compiles the same ADC code as the esp32-s3-idf build
# (curve fitting calibration), only the pin table differs.
sensor:
- id: adc_my_sensor
platform: adc
@@ -1,3 +1,6 @@
# Config only: the S2 shares the C2's line fitting path (no default_vref) and
# the original ESP32's ADC init (no clk_src); both are compiled by those
# builds, only the S2 pin table differs.
sensor:
- id: adc_my_sensor
platform: adc
+1
View File
@@ -1,6 +1,7 @@
sensor:
- platform: ags10
id: ags10_1
i2c_id: i2c_bus
tvoc:
name: AGS10 TVOC
update_interval: 60s
+20
View File
@@ -1,6 +1,12 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.const import CONF_ESPHOME, CONF_INCLUDES
from esphome.core import CORE
from tests.testing_helpers import ComponentManifestOverride
TESTS_DIR = Path(__file__).parent
def override_manifest(manifest: ComponentManifestOverride) -> None:
# USE_API compiles every api source, so emit what they need. No socket
@@ -8,6 +14,20 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
cg.add_define("USE_API")
cg.add_define("USE_API_PLAINTEXT")
# Linked wizard inputs only exist next to homeassistant entities, which need this
cg.add_define("USE_API_HOMEASSISTANT_STATES")
# test_wizard.cpp supplies the tables that codegen emits for a real build;
# it is only compiled when api's own tests are, not when api is a dependency
if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]):
for define in (
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
):
cg.add_define(define)
cg.add_define("API_WIZARD_DATA_SIZE", 200)
cg.add_define("API_WIZARD_INPUT_COUNT", 2)
cg.add_define("API_MAX_SEND_QUEUE", 8)
cg.add_define("MAX_API_CONNECTIONS", 1)
cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS")
+108
View File
@@ -0,0 +1,108 @@
api:
homeassistant_services: true
wizard:
pages:
- title: Features
description: Choose which features to enable
entities:
- id: feature_enable_switch
description: "[%key:component::example::feature::enable_switch%]"
- id: guest_mode_switch
description: Guest Mode
- id: plain_switch
- title: Sources
inputs:
- entity: outdoor_temp
description: Outdoor temperature
target:
entity:
- integration: met
domain: sensor
device_class: [temperature, humidity]
supported_features: weather.WeatherEntityFeature.FORECAST_DAILY
- domain: [sensor, number]
- entity: default_temp
- entity: door_open
- entity: house_mode
- entity: target_temp
- entity: ha_light_switch
target:
entity:
- domain: light
- entity: ha_text
- entity: ha_select
target:
entity:
- domain: input_select
- entity: ha_button
- id: weather_input
description: Weather source
target:
entity:
- domain: weather
- id: unset_input
switch:
- platform: template
id: feature_enable_switch
name: Feature Enable
optimistic: true
- platform: template
id: guest_mode_switch
name: Guest Mode
optimistic: true
- platform: template
id: plain_switch
name: Plain Switch
optimistic: true
- platform: homeassistant
id: ha_light_switch
sensor:
- platform: homeassistant
id: outdoor_temp
- platform: homeassistant
id: default_temp
- platform: homeassistant
id: fixed_temp
entity_id: sensor.fixed_temperature
binary_sensor:
- platform: homeassistant
id: door_open
text_sensor:
- platform: homeassistant
id: house_mode
number:
- platform: homeassistant
id: target_temp
text:
- platform: homeassistant
id: ha_text
select:
- platform: homeassistant
id: ha_select
button:
- platform: homeassistant
id: ha_button
- platform: homeassistant
id: fixed_button
entity_id: button.restart
interval:
- interval: 1h
then:
- if:
condition:
api.wizard.input_is_set: weather_input
then:
- homeassistant.action:
action: weather.get_forecasts
data:
type: daily
entity_id: !lambda 'return id(weather_input).entity_id().c_str();'
@@ -0,0 +1,6 @@
packages:
wizard: !include common-wizard.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,6 @@
packages:
wizard: !include common-wizard.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,4 @@
packages:
wizard: !include common-wizard.yaml
network:
@@ -0,0 +1,88 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// Sub-message whose body is raw bytes, so the body size is set directly by the test.
struct BlobMessage {
const uint8_t *data;
uint32_t len;
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const BlobMessage *>(self);
// An empty vector's data() may be null, and memcpy needs a valid source even for zero bytes
if (msg.len == 0)
return pos;
return ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data, msg.len);
}
static uint32_t calc_size_msg(const void *self) { return static_cast<const BlobMessage *>(self)->len; }
};
static void append_varint(std::vector<uint8_t> &out, uint32_t value) {
while (value > 0x7F) {
out.push_back(static_cast<uint8_t>(value | 0x80));
value >>= 7;
}
out.push_back(static_cast<uint8_t>(value));
}
static std::vector<uint8_t> make_body(uint32_t len) {
std::vector<uint8_t> body(len);
for (uint32_t i = 0; i < len; i++)
body[i] = static_cast<uint8_t>(i * 7 + 1);
return body;
}
static std::vector<uint8_t> expected_field(uint32_t field_id, const std::vector<uint8_t> &body) {
std::vector<uint8_t> out;
append_varint(out, (field_id << 3) | 2);
append_varint(out, body.size());
out.insert(out.end(), body.begin(), body.end());
return out;
}
// Encodes into a buffer of exactly the expected size, so any overrun trips ASan or the debug bounds check.
template<bool OPTIONAL> static void verify(uint32_t field_id, uint32_t body_len) {
std::vector<uint8_t> body = make_body(body_len);
std::vector<uint8_t> expected = expected_field(field_id, body);
if (OPTIONAL && body_len == 0)
expected.clear();
BlobMessage msg{body.data(), body_len};
APIBuffer buf;
ASSERT_TRUE(buf.resize(expected.empty() ? 1 : expected.size()));
uint8_t *pos = buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = buf.data() + buf.size();
#endif
uint8_t *end;
if constexpr (OPTIONAL) {
end = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, field_id, msg);
} else {
end = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, field_id, msg);
}
ASSERT_EQ(static_cast<size_t>(end - buf.data()), expected.size()) << "field " << field_id << " body " << body_len;
EXPECT_EQ(std::vector<uint8_t>(buf.data(), end), expected) << "field " << field_id << " body " << body_len;
}
TEST(ProtoSubMessage, OneByteTag) { verify<false>(4, 10); }
TEST(ProtoSubMessage, TwoByteTag) { verify<false>(20, 10); }
TEST(ProtoSubMessage, EmptyBody) { verify<false>(20, 0); }
TEST(ProtoSubMessage, LongestOneByteLength) { verify<false>(20, 127); }
// The length outgrows its reserved byte, so the body is moved forward
TEST(ProtoSubMessage, TwoByteLength) {
verify<false>(20, 128);
verify<false>(25, 200);
}
TEST(ProtoSubMessage, ThreeByteLength) { verify<false>(4, 20000); }
TEST(ProtoOptionalSubMessage, EmptyIsSkipped) { verify<true>(22, 0); }
TEST(ProtoOptionalSubMessage, OneByteTag) { verify<true>(1, 10); }
TEST(ProtoOptionalSubMessage, TwoByteTagAndLength) { verify<true>(22, 200); }
} // namespace esphome::api::testing
+148
View File
@@ -0,0 +1,148 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_wizard.h"
#include "esphome/components/api/proto.h"
namespace esphome::api {
// RAM buffers of the inputs, as codegen defines them
static char *wizard_input_weather() {
static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = "sensor.default";
return buffer;
}
static char *wizard_input_other() {
static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = "";
return buffer;
}
static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d;
static constexpr uint32_t OTHER_KEY = 0x11223344;
// The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two
// bytes as a varint.
const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = {
0x03, 0x0a, 0x11, 0x18, 0x1f, 0x26, 0x2d, 0x34, 0x3b, 0x42, 0x49, 0x50, 0x57, 0x5e, 0x65, 0x6c, 0x73, 0x7a, 0x81,
0x88, 0x8f, 0x96, 0x9d, 0xa4, 0xab, 0xb2, 0xb9, 0xc0, 0xc7, 0xce, 0xd5, 0xdc, 0xe3, 0xea, 0xf1, 0xf8, 0xff, 0x06,
0x0d, 0x14, 0x1b, 0x22, 0x29, 0x30, 0x37, 0x3e, 0x45, 0x4c, 0x53, 0x5a, 0x61, 0x68, 0x6f, 0x76, 0x7d, 0x84, 0x8b,
0x92, 0x99, 0xa0, 0xa7, 0xae, 0xb5, 0xbc, 0xc3, 0xca, 0xd1, 0xd8, 0xdf, 0xe6, 0xed, 0xf4, 0xfb, 0x02, 0x09, 0x10,
0x17, 0x1e, 0x25, 0x2c, 0x33, 0x3a, 0x41, 0x48, 0x4f, 0x56, 0x5d, 0x64, 0x6b, 0x72, 0x79, 0x80, 0x87, 0x8e, 0x95,
0x9c, 0xa3, 0xaa, 0xb1, 0xb8, 0xbf, 0xc6, 0xcd, 0xd4, 0xdb, 0xe2, 0xe9, 0xf0, 0xf7, 0xfe, 0x05, 0x0c, 0x13, 0x1a,
0x21, 0x28, 0x2f, 0x36, 0x3d, 0x44, 0x4b, 0x52, 0x59, 0x60, 0x67, 0x6e, 0x75, 0x7c, 0x83, 0x8a, 0x91, 0x98, 0x9f,
0xa6, 0xad, 0xb4, 0xbb, 0xc2, 0xc9, 0xd0, 0xd7, 0xde, 0xe5, 0xec, 0xf3, 0xfa, 0x01, 0x08, 0x0f, 0x16, 0x1d, 0x24,
0x2b, 0x32, 0x39, 0x40, 0x47, 0x4e, 0x55, 0x5c, 0x63, 0x6a, 0x71, 0x78, 0x7f, 0x86, 0x8d, 0x94, 0x9b, 0xa2, 0xa9,
0xb0, 0xb7, 0xbe, 0xc5, 0xcc, 0xd3, 0xda, 0xe1, 0xe8, 0xef, 0xf6, 0xfd, 0x04, 0x0b, 0x12, 0x19, 0x20, 0x27, 0x2e,
0x35, 0x3c, 0x43, 0x4a, 0x51, 0x58, 0x5f, 0x66, 0x6d, 0x74};
const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = {
{WEATHER_KEY, wizard_input_weather()},
{OTHER_KEY, wizard_input_other()},
};
using Bytes = std::vector<uint8_t>;
static Bytes encode(const ProtoMessage &msg, uint32_t (*calc)(const void *), ProtoEncodeFn enc) {
APIBuffer buffer;
uint32_t size = calc(&msg);
EXPECT_TRUE(buffer.resize(size));
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = buffer.data() + buffer.size();
#endif
uint8_t *end = enc(&msg, buffer.data() PROTO_ENCODE_DEBUG_ARG);
EXPECT_EQ(static_cast<size_t>(end - buffer.data()), size);
return Bytes(buffer.data(), buffer.data() + size);
}
TEST(DeviceWizard, ResponseSendsTheDataUnchanged) {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = API_WIZARD_DATA_SIZE;
// Field 1, length delimited, then the 200 byte length as a two byte varint, then the data
Bytes expected{0x0a, 0xc8, 0x01};
expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, ShortDataHasAOneByteLength) {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = 3;
Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]};
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, NoDataEncodesNothing) {
DeviceWizardResponse resp;
EXPECT_EQ(resp.calculate_size(), 0u);
EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg).empty());
}
TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) {
DeviceCapabilitiesResponse resp;
EXPECT_TRUE(
encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg).empty());
resp.wizard.configured = true;
// Field 5 (the wizard), length delimited, holding field 1 (configured) set to 1
EXPECT_EQ(encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg),
(Bytes{0x2a, 0x02, 0x08, 0x01}));
}
// A request for the input with the given key and entity id
static const char *set_input(uint32_t key, const char *entity_id) {
WizardInputSetRequest request;
request.key = key;
request.entity_id = StringRef(entity_id);
return wizard_set_input(request);
}
TEST(DeviceWizard, SetInputStoresTheEntityIdInTheBuffer) {
EXPECT_EQ(set_input(OTHER_KEY, "sensor.outdoor"), wizard_input_other());
EXPECT_STREQ(wizard_input_other(), "sensor.outdoor");
// A shorter id replaces a longer one completely
EXPECT_EQ(set_input(OTHER_KEY, "light.a"), wizard_input_other());
EXPECT_STREQ(wizard_input_other(), "light.a");
// The longest valid id fills the buffer
std::string longest = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE - 1 - 7, 'x');
EXPECT_EQ(set_input(OTHER_KEY, longest.c_str()), wizard_input_other());
EXPECT_EQ(std::string(wizard_input_other()), longest);
}
TEST(DeviceWizard, SetInputFindsTheInputByItsKey) {
EXPECT_EQ(set_input(WEATHER_KEY, "weather.home"), wizard_input_weather());
EXPECT_STREQ(wizard_input_weather(), "weather.home");
EXPECT_EQ(set_input(OTHER_KEY, "weather.away"), wizard_input_other());
EXPECT_STREQ(wizard_input_weather(), "weather.home");
}
TEST(DeviceWizard, SetInputIgnoresWhatIsNotAnEntityId) {
std::string before = wizard_input_weather();
EXPECT_EQ(set_input(WEATHER_KEY, ""), nullptr);
EXPECT_EQ(set_input(WEATHER_KEY, "nodot"), nullptr);
std::string too_long = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE, 'x');
EXPECT_EQ(set_input(WEATHER_KEY, too_long.c_str()), nullptr);
EXPECT_EQ(set_input(0xdeadbeef, "sensor.a"), nullptr);
EXPECT_EQ(std::string(wizard_input_weather()), before);
}
TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) {
strcpy(wizard_input_other(), "");
WizardInput input(wizard_input_other());
EXPECT_FALSE(input.has_entity_id());
EXPECT_TRUE(input.entity_id().empty());
ASSERT_EQ(set_input(OTHER_KEY, "weather.home"), wizard_input_other());
EXPECT_TRUE(input.has_entity_id());
EXPECT_EQ(input.entity_id(), "weather.home");
}
} // namespace esphome::api
#endif // USE_HOST
@@ -0,0 +1,6 @@
media_source:
- platform: audio_http
id: audio_http_ca_source
ca_certificate_path: $component_dir/test_ca.pem
<<: !include common.yaml
@@ -0,0 +1,13 @@
# http_request requires a network component; audio_http's common.yaml declares none.
wifi:
ssid: MySSID
password: password1
http_request:
ca_certificate_path: $component_dir/test_ca.pem
media_source:
- platform: audio_http
id: audio_http_inherit_ca_source
<<: !include common.yaml
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu
dXNlZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM
BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5mMUB1hOgLmlnXtsvcGMP
XkhAqZaR0dDPW5OS8VEopWLJCX9Y0cvNCqiDI8cnP8pP8XJGU1hGLvA5PJzWnWZz
AgMBAAGjUzBRMB0GA1UdDgQWBBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAfBgNVHSME
GDAWgBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
SIb3DQEBCwUAA0EAKqZFf6+f8FPDbKyPCpssquojgn7fEXqr/I/yz0R5CowGdMms
H3WH3aKP4lLSHdPTBtfIoJi3gEIZjFxp3S1TWw==
-----END CERTIFICATE-----
+1
View File
@@ -61,6 +61,7 @@ sensor:
motion:
- platform: bmi270
id: bmi270_motion
i2c_id: i2c_bus
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
@@ -0,0 +1,34 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/bytebuffer/bytebuffer.h"
namespace esphome::bytebuffer {
// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce.
// Doubles are written as float literals, so they carry float precision.
TEST(ByteBufferWrapValues, MatchesGeneratedConstants) {
EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector<uint8_t>{0x12}));
EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector<uint8_t>{0x12, 0x00}));
EXPECT_EQ(ByteBuffer::wrap(uint16_t(4660), BIG).get_data(), (std::vector<uint8_t>{0x12, 0x34}));
EXPECT_EQ(ByteBuffer::wrap(uint32_t(305419896), LITTLE).get_data(), (std::vector<uint8_t>{0x78, 0x56, 0x34, 0x12}));
EXPECT_EQ(ByteBuffer::wrap(uint64_t(1311768467294899695ULL), BIG).get_data(),
(std::vector<uint8_t>{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef}));
EXPECT_EQ(ByteBuffer::wrap(int8_t(-5), LITTLE).get_data(), (std::vector<uint8_t>{0xfb}));
EXPECT_EQ(ByteBuffer::wrap(int16_t(-2), BIG).get_data(), (std::vector<uint8_t>{0xff, 0xfe}));
EXPECT_EQ(ByteBuffer::wrap(int32_t(-2), LITTLE).get_data(), (std::vector<uint8_t>{0xfe, 0xff, 0xff, 0xff}));
EXPECT_EQ(ByteBuffer::wrap(int64_t(-2), LITTLE).get_data(),
(std::vector<uint8_t>{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}));
EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector<uint8_t>{0x42, 0xf6, 0x33, 0x33}));
EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector<uint8_t>{0xcd, 0xcc, 0xcc, 0x3d}));
EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(),
(std::vector<uint8_t>{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f}));
EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(),
(std::vector<uint8_t>{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}));
}
} // namespace esphome::bytebuffer
#endif
+4
View File
@@ -18,6 +18,7 @@ canbus:
lambda: "return x[0] == 0x11;"
then:
logger.log: Truth
- canbus.send: !lambda return x;
- can_id: 0b00000000000000000000001000000
can_id_mask: 0b11111000000000011111111000000
use_extended_id: true
@@ -59,6 +60,9 @@ button:
data: [0, 1, 2]
- canbus.send: "abc"
- canbus.send: [0, 1, 2]
# Identical on purpose: both sends share one constant payload
- canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send: !lambda return {0, 1, 2};
# Test canbus.send with lambda that references a component (function pointer)
- canbus.send: !lambda |-
+4
View File
@@ -30,6 +30,8 @@ cc1101:
then:
- lambda: |-
ESP_LOGD("cc1101", "packet %s freq_offset %.0f Hz rssi %.1f dBm lqi %u", format_hex(x).c_str(), freq_offset, rssi, lqi);
- cc1101.send_packet: !lambda |-
return x;
button:
- platform: template
@@ -42,6 +44,8 @@ button:
- cc1101.reset: transceiver
- cc1101.send_packet:
data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- cc1101.send_packet: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- cc1101.send_packet: "ABCDEFGH"
- cc1101.send_packet: !lambda |-
return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
+8
View File
@@ -1,5 +1,13 @@
sensor:
- platform: cm1106
id: cm1106_sensor
co2:
name: CM1106 CO2 Value
on_value:
then:
- cm1106.abc_enable: cm1106_sensor
- cm1106.abc_disable: cm1106_sensor
update_interval: 15s
automatic_baseline_calibration:
cycle: 7
baseline: 420
@@ -0,0 +1,15 @@
sensor:
- platform: template
id: template_temperature1
lambda: return 0.6;
- platform: template
id: template_temperature2
lambda: return 0.8;
- platform: combination
type: linear
name: Linearly combined temperatures
sources:
- source: template_temperature1
coeffecient: 0.5
- source: template_temperature2
coefficient: 1.5
@@ -0,0 +1,43 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/core/automation.h"
namespace esphome::testing {
static const uint8_t PAYLOAD[] = {1, 2, 3};
static std::vector<uint8_t> repeat(int count) { return std::vector<uint8_t>(count, 7); }
TEST(TemplatableBytesTest, VisitStaticTable) {
TemplatableBytes<> bytes;
bytes.set_static(PAYLOAD, sizeof(PAYLOAD));
std::vector<uint8_t> seen;
bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); });
EXPECT_EQ(seen, (std::vector<uint8_t>{1, 2, 3}));
}
TEST(TemplatableBytesTest, VisitWithLargerStackBuffer) {
TemplatableBytes<> bytes;
bytes.set_static(PAYLOAD, sizeof(PAYLOAD));
size_t seen = 0;
bytes.visit<256>([&](const uint8_t *, size_t len) { seen = len; });
EXPECT_EQ(seen, sizeof(PAYLOAD));
}
TEST(TemplatableBytesTest, VisitEmptyStaticTable) {
TemplatableBytes<> bytes;
bytes.set_static(nullptr, 0);
size_t seen = 1;
bytes.visit([&](const uint8_t *, size_t len) { seen = len; });
EXPECT_EQ(seen, 0u);
}
TEST(TemplatableBytesTest, VisitLambdaWithArgument) {
TemplatableBytes<int> bytes;
bytes.set_template(repeat);
std::vector<uint8_t> seen;
bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }, 4);
EXPECT_EQ(seen, (std::vector<uint8_t>(4, 7)));
}
} // namespace esphome::testing
@@ -0,0 +1,10 @@
datetime:
- platform: template
name: Datetime Trigger Test
id: datetime_trigger_test
type: datetime
optimistic: true
on_value:
- logger.log:
format: "New value %04d-%02d-%02d"
args: [x.year, x.month, x.day_of_month]
@@ -0,0 +1,3 @@
packages:
datetime: !include common.yaml
triggers: !include common-triggers.yaml
+2 -1
View File
@@ -1,4 +1,5 @@
<<: !include common.yaml
packages:
debug: !include common.yaml
sensor:
- platform: debug

Some files were not shown because too many files have changed in this diff Show More