mirror of
https://github.com/esphome/esphome.git
synced 2026-10-11 13:54:12 +00:00
Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts: # esphome/components/ble_client/__init__.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2
@@ -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
|
||||
+2
@@ -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
|
||||
+3
@@ -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,6 +1,7 @@
|
||||
sensor:
|
||||
- platform: ags10
|
||||
id: ags10_1
|
||||
i2c_id: i2c_bus
|
||||
tvoc:
|
||||
name: AGS10 TVOC
|
||||
update_interval: 60s
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu
|
||||
dXNlZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM
|
||||
BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5mMUB1hOgLmlnXtsvcGMP
|
||||
XkhAqZaR0dDPW5OS8VEopWLJCX9Y0cvNCqiDI8cnP8pP8XJGU1hGLvA5PJzWnWZz
|
||||
AgMBAAGjUzBRMB0GA1UdDgQWBBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAfBgNVHSME
|
||||
GDAWgBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
|
||||
SIb3DQEBCwUAA0EAKqZFf6+f8FPDbKyPCpssquojgn7fEXqr/I/yz0R5CowGdMms
|
||||
H3WH3aKP4lLSHdPTBtfIoJi3gEIZjFxp3S1TWw==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -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
|
||||
@@ -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 |-
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user