mirror of
https://github.com/esphome/esphome.git
synced 2026-09-24 13:34:07 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-436
# Conflicts: # esphome/config_validation.py
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""Tests for the ADC sensor component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_adc_temperature_pin_is_deprecated(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""`pin: TEMPERATURE` still works, but warns and points at internal_temperature."""
|
||||
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
|
||||
|
||||
assert "adc_temperature->set_is_temperature();" in main_cpp
|
||||
assert "`pin: TEMPERATURE` is deprecated" in caplog.text
|
||||
assert "internal_temperature" in caplog.text
|
||||
assert "2027.2.0" in caplog.text
|
||||
|
||||
|
||||
def test_adc_regular_pin_is_not_deprecated(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A normal ADC pin does not emit the temperature deprecation warning."""
|
||||
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
|
||||
|
||||
assert "adc_voltage->set_is_temperature();" not in main_cpp
|
||||
assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
sensor:
|
||||
- platform: adc
|
||||
pin: TEMPERATURE
|
||||
name: Deprecated ADC Temperature
|
||||
id: adc_temperature
|
||||
|
||||
- platform: adc
|
||||
pin: 26
|
||||
name: ADC Voltage
|
||||
id: adc_voltage
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for user-defined action field metadata (description / example)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.api import (
|
||||
_action_strings,
|
||||
_action_strings_size,
|
||||
_has_action_metadata,
|
||||
_validate_esp8266_action_strings,
|
||||
validate_variable,
|
||||
)
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import safe_exp
|
||||
from esphome.helpers import fnv1_hash
|
||||
from tests.component_tests.helpers import get_define_value
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
CONFIG = "tests/component_tests/api/test_action_metadata.yaml"
|
||||
CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml"
|
||||
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
|
||||
|
||||
|
||||
def test_metadata_is_emitted_as_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""Every action string is a PROGMEM array referenced from one PROGMEM table."""
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert (
|
||||
'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp
|
||||
)
|
||||
assert (
|
||||
'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";'
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";'
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
"static constexpr const char * api_action0_strings[] PROGMEM = {"
|
||||
"api_action_str0, api_action_str1, api_action_str2, api_action_str3, "
|
||||
"api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp
|
||||
)
|
||||
# An action without metadata still carries the metadata slots (as nullptr)
|
||||
assert (
|
||||
"static constexpr const char * api_action1_strings[] PROGMEM = {"
|
||||
"api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp
|
||||
)
|
||||
assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp
|
||||
assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines}
|
||||
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None
|
||||
|
||||
|
||||
def test_esp8266_sizes_scratch_buffer_for_largest_action(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""ESP8266 gets a scratch buffer define equal to the byte total of the largest action."""
|
||||
generate_main(CONFIG_ESP8266)
|
||||
|
||||
# play_buzzer: name, description, two variable names, one description, one example,
|
||||
# each with a terminator
|
||||
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117"
|
||||
|
||||
|
||||
def test_shorthand_variables_emit_no_metadata(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""The name: type shorthand emits a name-only table and no define."""
|
||||
main_cpp = generate_main(CONFIG_SHORTHAND)
|
||||
|
||||
assert (
|
||||
"static constexpr const char * api_action0_strings[] PROGMEM = "
|
||||
"{api_action_str0, api_action_str1};" in main_cpp
|
||||
)
|
||||
assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_variable_shorthand_normalizes_to_mapping() -> None:
|
||||
"""A bare type string validates to the mapping form."""
|
||||
assert validate_variable("string") == {"type": "string"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
{"description": "no type given"},
|
||||
{"type": "string", "selector": "text"},
|
||||
"stringy",
|
||||
{"type": "stringy"},
|
||||
],
|
||||
)
|
||||
def test_variable_rejects_invalid(value: object) -> None:
|
||||
"""Missing or unknown type and unknown keys raise in both forms."""
|
||||
with pytest.raises(Invalid):
|
||||
validate_variable(value)
|
||||
|
||||
|
||||
def _oversized_action_config() -> dict:
|
||||
return {
|
||||
"actions": [
|
||||
{
|
||||
"action": "big",
|
||||
"description": "x" * 300,
|
||||
"variables": {"a": {"type": "string", "example": "y" * 300}},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_esp8266_rejects_actions_over_string_budget(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"):
|
||||
_validate_esp8266_action_strings(_oversized_action_config())
|
||||
|
||||
|
||||
def test_other_platforms_have_no_string_budget(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = _oversized_action_config()
|
||||
assert _validate_esp8266_action_strings(config) is config
|
||||
|
||||
|
||||
def test_empty_metadata_is_unset_and_not_counted() -> None:
|
||||
"""An empty description or example emits nullptr and takes no scratch space."""
|
||||
conf = {
|
||||
"action": "a",
|
||||
"description": "",
|
||||
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
|
||||
}
|
||||
strings = _action_strings(conf, has_metadata=True)
|
||||
assert strings == ["a", None, "b", None, "ex"]
|
||||
# Every emitted string counts its terminator: "a" + "b" + "ex"
|
||||
assert _action_strings_size(strings) == 2 + 2 + 3
|
||||
|
||||
|
||||
def test_empty_metadata_does_not_enable_the_define() -> None:
|
||||
actions = [
|
||||
{
|
||||
"action": "a",
|
||||
"description": "",
|
||||
"variables": {"b": {"type": "int", "example": ""}},
|
||||
}
|
||||
]
|
||||
assert not _has_action_metadata(actions)
|
||||
actions[0]["variables"]["b"]["example"] = "1"
|
||||
assert _has_action_metadata(actions)
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
packages:
|
||||
api: !include test_action_metadata_common.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
api:
|
||||
actions:
|
||||
- action: play_buzzer
|
||||
description: Play an RTTTL melody on the buzzer
|
||||
variables:
|
||||
song_str:
|
||||
type: string
|
||||
description: RTTTL melody string
|
||||
example: "two_short:d=4,o=5,b=100:16e6,16e6"
|
||||
volume:
|
||||
type: int
|
||||
then:
|
||||
- logger.log: Action Called
|
||||
- action: plain_action
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log: Action Called
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
packages:
|
||||
api: !include test_action_metadata_common.yaml
|
||||
@@ -0,0 +1,19 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: plain_action
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log: Action Called
|
||||
@@ -9,7 +9,7 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main):
|
||||
|
||||
assert (
|
||||
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
|
||||
'("zero_copy_args", {"message"})' in main_cpp
|
||||
"(api_action0_strings," in main_cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_response_callback_args_are_owning(generate_main):
|
||||
|
||||
assert (
|
||||
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
|
||||
'("response_args", {"message"})' in main_cpp
|
||||
"(api_action1_strings," in main_cpp
|
||||
)
|
||||
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
|
||||
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
|
||||
|
||||
|
||||
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A plain string with a return statement compiles as a lambda and warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert main_cpp.count('add_variable(ESPHOME_F("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
|
||||
assert "missing the !lambda tag" in caplog.text
|
||||
|
||||
|
||||
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."""
|
||||
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 "static value" not in caplog.text
|
||||
|
||||
|
||||
def test_static_id_value_stays_literal_with_hint(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Lambda source without a return stays literal text but warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
|
||||
assert "sent as literal text" in caplog.text
|
||||
|
||||
|
||||
def test_explicit_lambda_tag_is_compiled_as_lambda(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""A !lambda value keeps working unchanged."""
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
|
||||
assert "return App.get_name();" in main_cpp
|
||||
@@ -0,0 +1,32 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
# Plain strings with a return statement compile as lambdas
|
||||
- homeassistant.event:
|
||||
event: esphome.test_event
|
||||
data_template:
|
||||
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
|
||||
variables:
|
||||
lambda_var: |-
|
||||
return millis();
|
||||
static_var: static value
|
||||
tagged_var: !lambda return App.get_name();
|
||||
hint_var: id(test_sensor).state
|
||||
- homeassistant.action:
|
||||
action: notify.notify
|
||||
data_template:
|
||||
message: "{{ lambda_var }} {{ static_var }}"
|
||||
variables:
|
||||
lambda_var: |-
|
||||
return millis();
|
||||
static_var: static value
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Config-validation tests for the aqi sensor component."""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.aqi import CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE
|
||||
from esphome.components.aqi.sensor import _validate_extended_range
|
||||
|
||||
|
||||
def test_extended_range_rejected_with_caqi():
|
||||
"""extended_range has no meaning for CAQI (no spec maximum) and must be rejected."""
|
||||
with pytest.raises(Invalid, match="CAQI"):
|
||||
_validate_extended_range(
|
||||
{CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: True}
|
||||
)
|
||||
|
||||
|
||||
def test_extended_range_rejected_with_caqi_even_when_false():
|
||||
"""The option is not allowed at all with CAQI, regardless of its value."""
|
||||
with pytest.raises(Invalid, match="CAQI"):
|
||||
_validate_extended_range(
|
||||
{CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: False}
|
||||
)
|
||||
|
||||
|
||||
def test_extended_range_allowed_with_aqi():
|
||||
"""extended_range is valid for the US AQI calculation."""
|
||||
config = {CONF_CALCULATION_TYPE: "AQI", CONF_EXTENDED_RANGE: True}
|
||||
assert _validate_extended_range(config) is config
|
||||
|
||||
|
||||
def test_caqi_without_extended_range_ok():
|
||||
"""CAQI is fine as long as extended_range is not set."""
|
||||
config = {CONF_CALCULATION_TYPE: "CAQI"}
|
||||
assert _validate_extended_range(config) is config
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-n
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-q
|
||||
|
||||
bk72xx:
|
||||
board: wa2
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-t
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231t-qfn32-tuya
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-7238
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-7252
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The non-5.x family rejection lives in to_code (config validation must stay
|
||||
family-agnostic for the validate-only CI fixtures), so codegen is the only
|
||||
place it can be pinned."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "match"),
|
||||
[
|
||||
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
|
||||
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
|
||||
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
|
||||
("test_bk7238.yaml", "BK7238.*bootloader"),
|
||||
],
|
||||
)
|
||||
def test_unsupported_family_rejected(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
match: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with pytest.raises(EsphomeError, match=match):
|
||||
generate_main(component_config_path(config_file))
|
||||
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
|
||||
# board), but it warns before codegen raises.
|
||||
assert "cannot compile" in caplog.text
|
||||
|
||||
|
||||
def test_ble5_family_generates(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
|
||||
assert "bk72xx_ble::BK72xxBLE" in main_cpp
|
||||
@@ -0,0 +1,45 @@
|
||||
esphome:
|
||||
name: bk-trigger-codegen
|
||||
on_boot:
|
||||
then:
|
||||
- bk72xx_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
# Bare form: restores the configured scan_parameters mode — no
|
||||
# set_continuous emitted (asserted in the codegen test).
|
||||
- bk72xx_ble_tracker.start_scan:
|
||||
- bk72xx_ble_tracker.stop_scan
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
continuous: false
|
||||
active: false
|
||||
on_ble_advertise:
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
- 11:22:33:44:55:66
|
||||
then:
|
||||
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
|
||||
- service_uuid: ABCDABCD
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- manufacturer_id: ABCD
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
|
||||
- manufacturer_id: ABCDABCD
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
|
||||
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
|
||||
on_scan_end:
|
||||
- then:
|
||||
- lambda: 'ESP_LOGD("t", "end");'
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Codegen tests for the tracker automations.
|
||||
|
||||
The shared trigger classes (ble_device_base/automation.h) are compiled by every
|
||||
esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's
|
||||
action templates and restart_scan_duration() — compiles on no CI board (the
|
||||
bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and
|
||||
validate fixtures never run to_code. The generated main is therefore the only
|
||||
automated check on the setter spellings and the listener accounting."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.components import ble_device_base
|
||||
from tests.component_tests.helpers import get_define_value
|
||||
|
||||
|
||||
def test_trigger_codegen(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_automations.yaml"))
|
||||
|
||||
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
|
||||
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
|
||||
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
|
||||
assert (
|
||||
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
|
||||
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
|
||||
)
|
||||
assert "set_address(0xAC3743775F4CULL)" in main_cpp
|
||||
# 32-bit middle branch of the width dispatch
|
||||
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
|
||||
# All three manufacturer widths: getattr() builds these names as strings,
|
||||
# so a misspelling only ever fails here.
|
||||
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
|
||||
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
|
||||
assert (
|
||||
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
|
||||
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
|
||||
)
|
||||
# scan-control actions: templatable continuous lambda + parented actions.
|
||||
# Exactly one set_continuous: the bare start_scan emits none, pinning the
|
||||
# restore-configured-mode divergence from esp32 against a future default=.
|
||||
assert main_cpp.count("->set_continuous(") == 1
|
||||
assert "startscanaction_id->set_continuous(" in main_cpp
|
||||
assert "stopscanaction_id->set_parent(" in main_cpp
|
||||
# scan_parameters continuous: false reaches the YAML-mode setter, not the
|
||||
# runtime override.
|
||||
assert "->set_configured_continuous(false)" in main_cpp
|
||||
# active: false (non-default) flows through to the setter.
|
||||
assert "->set_scan_active(false)" in main_cpp
|
||||
# Constructor call, not just the declaration: the parent argument is what
|
||||
# registers the trigger as a listener.
|
||||
assert re.search(
|
||||
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
|
||||
)
|
||||
|
||||
# Seven triggers register as listeners; an undercount silently drops the
|
||||
# last trigger at runtime (StaticVector::push_back past capacity), so the
|
||||
# define is the assertion that matters most.
|
||||
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for ble_client config validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_client import (
|
||||
CONF_DESCRIPTOR_UUID,
|
||||
CONF_ON_NOTIFY,
|
||||
notify_from_on_notify,
|
||||
validate_descriptor_not_notify,
|
||||
)
|
||||
from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA
|
||||
from esphome.components.ble_client.text_sensor import (
|
||||
CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_CHARACTERISTIC_UUID,
|
||||
CONF_NAME,
|
||||
CONF_NOTIFY,
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DESCRIPTOR_CONFIG: ConfigType = {
|
||||
CONF_NAME: "test",
|
||||
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
CONF_DESCRIPTOR_UUID: "2902",
|
||||
}
|
||||
|
||||
|
||||
def test_notify_with_descriptor_uuid_rejected() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"}
|
||||
with pytest.raises(cv.Invalid, match="cannot send notifications"):
|
||||
validate_descriptor_not_notify(config)
|
||||
|
||||
|
||||
def test_on_notify_with_descriptor_uuid_rejected() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_NOTIFY: False,
|
||||
CONF_ON_NOTIFY: [{}],
|
||||
CONF_DESCRIPTOR_UUID: "2902",
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot send notifications"):
|
||||
validate_descriptor_not_notify(config)
|
||||
|
||||
|
||||
def test_descriptor_uuid_without_notify_allowed() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"}
|
||||
assert validate_descriptor_not_notify(config) is config
|
||||
|
||||
|
||||
def test_notify_without_descriptor_uuid_allowed() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: True}
|
||||
assert validate_descriptor_not_notify(config) is config
|
||||
|
||||
|
||||
def test_sensor_schema_rejects_notify_with_descriptor() -> None:
|
||||
config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True}
|
||||
with pytest.raises(cv.Invalid, match="cannot send notifications"):
|
||||
SENSOR_SCHEMA(config)
|
||||
|
||||
|
||||
def test_text_sensor_schema_rejects_notify_with_descriptor() -> None:
|
||||
config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True}
|
||||
with pytest.raises(cv.Invalid, match="cannot send notifications"):
|
||||
TEXT_SENSOR_SCHEMA(config)
|
||||
|
||||
|
||||
def test_sensor_schema_allows_descriptor_polling() -> None:
|
||||
assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"})
|
||||
|
||||
|
||||
def test_text_sensor_schema_allows_descriptor_polling() -> None:
|
||||
assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG))
|
||||
|
||||
|
||||
def test_on_notify_implies_notify() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]}
|
||||
assert notify_from_on_notify(config)[CONF_NOTIFY] is True
|
||||
|
||||
|
||||
def test_notify_unchanged_without_on_notify() -> None:
|
||||
config: ConfigType = {CONF_NOTIFY: False}
|
||||
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-controller
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-tracker
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: slotcount-esp32-proxy
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: slotcount-esp32-tracker
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-ln882h-tracker
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882h
|
||||
|
||||
ln882h_ble_tracker:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-rp2-controller
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
rp2040_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-rp2-tracker
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
rp2_ble_tracker:
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the BLE hub provider registry and the missing-hub diagnostics."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_TARGET_PLATFORM, Platform
|
||||
from esphome.core import CORE, ID, KEY_CORE
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
|
||||
COMPONENTS_DIR = Path(ble_device_base.__file__).parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub_registry() -> Generator[set[str]]:
|
||||
"""Save/restore _HUB_PROVIDERS — a module global with no reset hook.
|
||||
|
||||
CORE state needs no bookkeeping here: conftest's autouse reset_core
|
||||
fixture reassigns it after every test.
|
||||
"""
|
||||
saved = set(ble_device_base._HUB_PROVIDERS)
|
||||
yield ble_device_base._HUB_PROVIDERS
|
||||
ble_device_base._HUB_PROVIDERS.clear()
|
||||
ble_device_base._HUB_PROVIDERS.update(saved)
|
||||
|
||||
|
||||
def _generated_id() -> ID:
|
||||
"""An ID as cv.GenerateID leaves it before the ID-assignment pass."""
|
||||
return ID(None, is_declaration=False, type="ble_device_base::BLEHub")
|
||||
|
||||
|
||||
def _set_platform(platform: str | None) -> None:
|
||||
core_data = CORE.data.setdefault(KEY_CORE, {})
|
||||
if platform is None:
|
||||
core_data.pop(KEY_TARGET_PLATFORM, None)
|
||||
else:
|
||||
core_data[KEY_TARGET_PLATFORM] = platform
|
||||
|
||||
|
||||
# The missing-hub diagnostics: one test per path so a regression in one
|
||||
# scenario cannot mask the others. The hub binding must fail with a
|
||||
# tracker-naming message, not use_id's C++-class error, regardless of
|
||||
# config-step ordering internals.
|
||||
|
||||
|
||||
def test_empty_registry_names_every_in_tree_tracker(hub_registry: set[str]) -> None:
|
||||
# The common failure: a fresh CLI process where the tracker was simply
|
||||
# forgotten, so no tracker module was ever imported and the registry is
|
||||
# empty. The error must still name the in-tree trackers.
|
||||
hub_registry.clear()
|
||||
CORE.loaded_integrations.clear()
|
||||
_set_platform(None)
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match="add one of: bk72xx_ble_tracker, esp32_ble_tracker, ln882h_ble_tracker, rp2_ble_tracker",
|
||||
):
|
||||
ble_device_base._require_hub(_generated_id())
|
||||
|
||||
|
||||
def test_platform_filters_the_suggested_trackers(hub_registry: set[str]) -> None:
|
||||
hub_registry.clear()
|
||||
CORE.loaded_integrations.clear()
|
||||
_set_platform("esp32")
|
||||
with pytest.raises(cv.Invalid, match="add one of: esp32_ble_tracker$"):
|
||||
ble_device_base._require_hub(_generated_id())
|
||||
|
||||
|
||||
def test_ble_less_platform_is_not_misdirected(hub_registry: set[str]) -> None:
|
||||
# A known platform with no in-tree hub must not be pointed at other
|
||||
# platforms' trackers; out-of-tree BLE hubs are not supported.
|
||||
hub_registry.clear()
|
||||
CORE.loaded_integrations.clear()
|
||||
_set_platform("esp8266")
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match="No BLE tracker exists for esp8266; BLE components are not supported",
|
||||
):
|
||||
ble_device_base._require_hub(_generated_id())
|
||||
|
||||
|
||||
def test_explicit_id_bypasses_the_registry(hub_registry: set[str]) -> None:
|
||||
# Explicit ble_hub_id: is the multi-hub disambiguation case; the ID pass
|
||||
# owns that diagnosis and its error names the missing id.
|
||||
hub_registry.clear()
|
||||
CORE.loaded_integrations.clear()
|
||||
explicit = ID("my_hub", is_declaration=False, type="ble_device_base::BLEHub")
|
||||
assert ble_device_base._require_hub(explicit) is explicit
|
||||
|
||||
|
||||
def test_registered_and_loaded_provider_passes(hub_registry: set[str]) -> None:
|
||||
hub_registry.add("esp32_ble_tracker")
|
||||
CORE.loaded_integrations.add("esp32_ble_tracker")
|
||||
generated = _generated_id()
|
||||
assert ble_device_base._require_hub(generated) is generated
|
||||
|
||||
|
||||
def _module_name(path: Path) -> str:
|
||||
"""Dotted module name for a file under esphome/components."""
|
||||
rel = path.relative_to(COMPONENTS_DIR.parent)
|
||||
parts = rel.with_suffix("").parts
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
return "esphome." + ".".join(parts)
|
||||
|
||||
|
||||
def _hub_component_modules() -> list[str]:
|
||||
"""Components whose codegen class inherits ble_device_base.BLEHub.
|
||||
|
||||
The source-text pass only selects import candidates (importing all ~900
|
||||
component packages is too slow); membership is decided by the class
|
||||
hierarchy via MockObjClass.inherits_from on every module whose source
|
||||
matched — nested declaring modules included — so a comment mentioning
|
||||
BLEHub in a consumer cannot produce a false positive.
|
||||
"""
|
||||
hub_modules = []
|
||||
for pkg in sorted(COMPONENTS_DIR.iterdir()):
|
||||
if pkg.name == "ble_device_base" or not (pkg / "__init__.py").is_file():
|
||||
continue
|
||||
matched = [
|
||||
path
|
||||
for path in pkg.rglob("*.py")
|
||||
if "BLEHub" in path.read_text(encoding="utf-8")
|
||||
]
|
||||
if not matched:
|
||||
continue
|
||||
for path in matched:
|
||||
mod = import_module(_module_name(path))
|
||||
if any(
|
||||
isinstance(attr, MockObjClass)
|
||||
and attr is not ble_device_base.BLEHub
|
||||
and attr.inherits_from(ble_device_base.BLEHub)
|
||||
for attr in vars(mod).values()
|
||||
):
|
||||
hub_modules.append(pkg.name)
|
||||
break
|
||||
return hub_modules
|
||||
|
||||
|
||||
def test_every_in_tree_hub_registers_as_provider() -> None:
|
||||
"""A BLEHub subclass that forgets register_hub_provider() makes _require_hub
|
||||
reject valid configs for that platform — fail CI instead of the user."""
|
||||
hub_modules = _hub_component_modules()
|
||||
assert hub_modules, "hub discovery found no BLEHub subclasses — scan stale?"
|
||||
for name in hub_modules:
|
||||
assert name in ble_device_base._HUB_PROVIDERS, (
|
||||
f"{name} subclasses ble_device_base.BLEHub but never calls "
|
||||
"register_hub_provider(); a valid config using it would be rejected"
|
||||
)
|
||||
# The per-platform error table must know every in-tree hub, keyed by real
|
||||
# platform names — a typo'd key would silently route that platform into
|
||||
# the no-in-tree-tracker branch.
|
||||
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS.values()) == set(hub_modules)
|
||||
platforms = {platform.value for platform in Platform}
|
||||
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS) <= platforms
|
||||
|
||||
|
||||
def test_ble_device_schema_declares_the_binding_key(hub_registry: set[str]) -> None:
|
||||
"""Extending BLE_DEVICE_SCHEMA keeps ble_hub_id a declared key on a strict
|
||||
schema, for both the generated and the explicit form, and the missing-hub
|
||||
rejection surfaces through the schema itself."""
|
||||
schema = cv.Schema({}).extend(ble_device_base.BLE_DEVICE_SCHEMA)
|
||||
hub_registry.clear()
|
||||
CORE.loaded_integrations.discard("esp32_ble_tracker")
|
||||
with pytest.raises(cv.Invalid, match="No BLE tracker configured"):
|
||||
schema({})
|
||||
hub_registry.add("esp32_ble_tracker")
|
||||
CORE.loaded_integrations.add("esp32_ble_tracker")
|
||||
generated = schema({})[ble_device_base.CONF_BLE_HUB_ID]
|
||||
assert isinstance(generated, ID) and generated.id is None
|
||||
explicit = schema({"ble_hub_id": "my_hub"})[ble_device_base.CONF_BLE_HUB_ID]
|
||||
assert explicit.id == "my_hub"
|
||||
|
||||
|
||||
def test_rename_legacy_hub_id_migrates_the_old_key() -> None:
|
||||
validator = ble_device_base.rename_legacy_hub_id("my_sensor")
|
||||
migrated = validator({"esp32_ble_id": "tracker1"})
|
||||
assert migrated == {ble_device_base.CONF_BLE_HUB_ID: "tracker1"}
|
||||
untouched = validator({"name": "x"})
|
||||
assert untouched == {"name": "x"}
|
||||
|
||||
|
||||
def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
emitted: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"esphome.components.ble_device_base.cg.add", lambda e: emitted.append(str(e))
|
||||
)
|
||||
var = cg.MockObj("trig")
|
||||
ble_device_base.add_service_uuid(var, "11AA")
|
||||
ble_device_base.add_service_uuid(var, "11223344")
|
||||
ble_device_base.add_service_uuid(var, "11223344-5566-7788-99aa-bbccddeeff00")
|
||||
assert "set_service_uuid16" in emitted[0]
|
||||
assert "set_service_uuid32" in emitted[1]
|
||||
assert "set_service_uuid128" in emitted[2]
|
||||
# BLE wire order: the 128-bit array must be byte-reversed — as_hex_array
|
||||
# in its place would still emit the right setter name and silently never
|
||||
# match on-air.
|
||||
assert "0x00,0xff,0xee,0xdd" in emitted[2]
|
||||
with pytest.raises(ValueError, match="Unsupported UUID format"):
|
||||
ble_device_base.add_service_uuid(var, "123")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_name", "define"),
|
||||
[
|
||||
("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"),
|
||||
("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"),
|
||||
("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"),
|
||||
("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"),
|
||||
],
|
||||
)
|
||||
def test_every_tracker_emits_its_alias_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_name: str,
|
||||
define: str,
|
||||
) -> None:
|
||||
"""Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the
|
||||
ble_hub_impl.h alias ladder selects on it. Checked through real codegen
|
||||
(the other two legs of the invariant, the ladder arm and the defines.h
|
||||
mirror, are compile-enforced: a missing arm fails any build containing a
|
||||
BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses
|
||||
on every tracker platform - and clang-tidy compiles each arm's
|
||||
static_assert)."""
|
||||
generate_main(component_config_path(config_name))
|
||||
|
||||
assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for the shared BLE tracker scan parameter validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.bk72xx_ble_tracker import (
|
||||
SCAN_PARAMETERS_SCHEMA as BK72XX_SCHEMA,
|
||||
)
|
||||
from esphome.components.ble_device_base import to_ble_units
|
||||
from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA
|
||||
from esphome.components.ln882h_ble_tracker import (
|
||||
SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA,
|
||||
)
|
||||
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
|
||||
|
||||
|
||||
def _validate(**kwargs: str | bool) -> dict:
|
||||
"""Run a scan_parameters config through the bk72xx tracker's real schema."""
|
||||
return BK72XX_SCHEMA(kwargs)
|
||||
|
||||
|
||||
# --- to_ble_units ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("2500us", 4), # controller minimum, 2.5 ms
|
||||
("30ms", 48),
|
||||
("100ms", 160),
|
||||
("10240ms", 16384), # controller maximum, 0x4000
|
||||
],
|
||||
)
|
||||
def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None:
|
||||
"""A time is converted to whole 0.625 ms units."""
|
||||
assert to_ble_units(cv.positive_time_period(value)) == expected
|
||||
|
||||
|
||||
def test_to_ble_units_truncates() -> None:
|
||||
"""Sub-unit remainders are dropped, which is what makes collapse possible."""
|
||||
assert to_ble_units(cv.positive_time_period("3000us")) == 4
|
||||
assert to_ble_units(cv.positive_time_period("2500us")) == 4
|
||||
|
||||
|
||||
# --- the real per-chip schemas ---
|
||||
|
||||
|
||||
def test_bk72xx_defaults_are_valid() -> None:
|
||||
"""bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window —
|
||||
and exposes active (default on, like every active-capable tracker)."""
|
||||
config = _validate()
|
||||
assert to_ble_units(config["interval"]) == 160
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
assert config["active"] is True
|
||||
|
||||
|
||||
def test_esp32_defaults_are_valid() -> None:
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
|
||||
|
||||
Without wifi loaded, the conditional window default falls back to the
|
||||
historical 30 ms; the wifi-aware resolution is covered by the
|
||||
esp32_ble_tracker component tests.
|
||||
"""
|
||||
config = ESP32_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 512
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
assert config["active"] is True
|
||||
|
||||
|
||||
def test_rp2_defaults_are_valid() -> None:
|
||||
"""rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the
|
||||
shared CYW43 radio mostly free for WiFi — and exposes active (default on)."""
|
||||
config = RP2_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 160
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
assert config["active"] is True
|
||||
|
||||
|
||||
def test_ln882h_defaults_are_valid() -> None:
|
||||
"""ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window
|
||||
(50 % duty) — and exposes active (default on)."""
|
||||
config = LN882H_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 160
|
||||
assert to_ble_units(config["window"]) == 80
|
||||
assert config["active"] is True
|
||||
|
||||
|
||||
def test_esp32_active_can_disable() -> None:
|
||||
config = ESP32_SCHEMA({"active": False})
|
||||
assert config["active"] is False
|
||||
|
||||
|
||||
def test_bk72xx_active_can_disable() -> None:
|
||||
config = _validate(active=False)
|
||||
assert config["active"] is False
|
||||
|
||||
|
||||
# --- accepted configurations ---
|
||||
|
||||
|
||||
def test_minimum_separation_accepted() -> None:
|
||||
"""Values one unit apart at the 2.5 ms floor are honest, not collapsed."""
|
||||
config = _validate(interval="5000us", window="2500us")
|
||||
assert to_ble_units(config["interval"]) == 8
|
||||
assert to_ble_units(config["window"]) == 4
|
||||
|
||||
|
||||
def test_maximum_interval_accepted() -> None:
|
||||
"""The documented 10240 ms ceiling is inclusive, and maps to 0x4000.
|
||||
|
||||
Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the
|
||||
reject cases alone would let the bound silently become exclusive.
|
||||
"""
|
||||
config = _validate(interval="10240ms", window="30ms")
|
||||
assert to_ble_units(config["interval"]) == 16384
|
||||
|
||||
|
||||
def test_maximum_window_accepted() -> None:
|
||||
"""The ceiling applies to the window too, and is likewise inclusive."""
|
||||
config = _validate(interval="10240ms", window="10240ms")
|
||||
assert to_ble_units(config["window"]) == 16384
|
||||
|
||||
|
||||
def test_window_equal_to_interval_accepted() -> None:
|
||||
"""A deliberate 100 % duty cycle is allowed; only an accidental one is not."""
|
||||
config = _validate(interval="100ms", window="100ms")
|
||||
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
|
||||
|
||||
|
||||
def test_duration_equal_to_three_intervals_accepted() -> None:
|
||||
"""The three-interval floor is inclusive, mirroring the ceilings above."""
|
||||
_validate(duration="3s", interval="1s", window="500ms")
|
||||
|
||||
|
||||
# --- rejected configurations ---
|
||||
|
||||
|
||||
def test_window_larger_than_interval_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_validate(interval="30ms", window="100ms")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("interval", "window", "offender"),
|
||||
[
|
||||
("2ms", "1ms", "interval"), # below the 2.5 ms controller floor
|
||||
("20s", "1s", "interval"), # above the 10240 ms controller ceiling
|
||||
("100ms", "1ms", "window"), # window below the floor
|
||||
],
|
||||
)
|
||||
def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None:
|
||||
"""Values the controller cannot represent are rejected, not silently wrapped."""
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms"
|
||||
):
|
||||
_validate(interval=interval, window=window)
|
||||
|
||||
|
||||
def test_unit_collapse_rejected() -> None:
|
||||
"""3000us/2500us both floor to 4 units — a hidden 100 % duty cycle."""
|
||||
with pytest.raises(cv.Invalid, match="both truncate to 4 x 0.625 ms"):
|
||||
_validate(interval="3000us", window="2500us")
|
||||
|
||||
|
||||
def test_duration_shorter_than_three_intervals_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"):
|
||||
_validate(duration="1s", interval="500ms", window="100ms")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for the shared slot_counter codegen factory.
|
||||
|
||||
The factory is exercised end to end through the real controllers: a tracker
|
||||
config must emit the platform's scan listener count define, and a
|
||||
controller-only config must emit nothing so the guarded StaticVector storage
|
||||
compiles out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..helpers import get_define_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "define"),
|
||||
[
|
||||
("bk72xx_tracker.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
|
||||
("rp2_tracker.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
|
||||
],
|
||||
)
|
||||
def test_tracker_requests_one_slot(
|
||||
config: str,
|
||||
define: str,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The tracker's to_code requests a slot; the FINAL job emits the count.
|
||||
|
||||
The neutral listener count must stay absent from the same build: no BLE
|
||||
consumer registered through register_ble_device().
|
||||
"""
|
||||
generate_main(component_config_path(config))
|
||||
assert get_define_value(define) == "1"
|
||||
assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "define"),
|
||||
[
|
||||
("bk72xx_controller_only.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
|
||||
("rp2_controller_only.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
|
||||
],
|
||||
)
|
||||
def test_controller_only_emits_no_count(
|
||||
config: str,
|
||||
define: str,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""No consumer, no define — the guarded listener storage compiles out."""
|
||||
generate_main(component_config_path(config))
|
||||
assert get_define_value(define) is None
|
||||
|
||||
|
||||
def test_neutral_listener_count_emitted_when_requested() -> None:
|
||||
"""Registering through register_ble_device() emits the neutral count.
|
||||
|
||||
No in-tree sensor registers through ble_device_base.register_ble_device()
|
||||
yet (consumer migration is a follow-up), so the coroutine is driven with a
|
||||
mock hub instead of a config; every tracker's #ifdef-guarded listener
|
||||
storage keys on this define, and a broken emit path would compile the
|
||||
storage out silently.
|
||||
"""
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base
|
||||
from esphome.core import ID
|
||||
|
||||
hub_id = ID("hub", type=ble_device_base.BLEHub)
|
||||
CORE.register_variable(hub_id, cg.MockObj("hub"))
|
||||
CORE.add_job(
|
||||
ble_device_base.register_ble_device,
|
||||
cg.MockObj("listener"),
|
||||
{ble_device_base.CONF_BLE_HUB_ID: hub_id},
|
||||
)
|
||||
CORE.flush_tasks()
|
||||
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1"
|
||||
|
||||
|
||||
def test_esp32_tracker_handler_counts(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A bare tracker registers its four esp32_ble handlers and nothing else."""
|
||||
generate_main(component_config_path("esp32_tracker_only.yaml"))
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1"
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1"
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1"
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None
|
||||
# No advertisement listener or client is registered, so both storages compile out.
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None
|
||||
|
||||
|
||||
def test_esp32_bluetooth_proxy_requests_client_slots_only(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The proxy requests a client slot per connection (three by default with
|
||||
active: true); advertisements and scanner state arrive through the hub
|
||||
callbacks, so no listener slot exists."""
|
||||
generate_main(component_config_path("esp32_bluetooth_proxy.yaml"))
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"
|
||||
# One neutral GATT backend slot per connection (the hub-model flip).
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
|
||||
|
||||
|
||||
def test_counts_reset_between_compiles(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A second compile in the same process starts from zero.
|
||||
|
||||
The module level counters this change removes leaked across compiles in a
|
||||
long lived host process (dashboard, device-builder), growing the handler
|
||||
counts by one per compile and oversizing the StaticCallbackManager storage.
|
||||
"""
|
||||
generate_main(component_config_path("esp32_tracker_only.yaml"))
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
|
||||
CORE.reset()
|
||||
generate_main(component_config_path("esp32_tracker_only.yaml"))
|
||||
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together.
|
||||
|
||||
The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and
|
||||
the esp32 schema builder lazily imports esp32_ble and asserts the two values
|
||||
agree, but that assert only fires while building the esp32 schema. This test
|
||||
catches drift when the upstream constant changes without any esp32 config
|
||||
being validated.
|
||||
"""
|
||||
|
||||
from esphome.components import esp32_ble
|
||||
from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_mirror_matches_esp32_ble() -> None:
|
||||
assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, (
|
||||
"bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with "
|
||||
"esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk
|
||||
them without importing the esp32 BLE stack; pin the two declarations together.
|
||||
|
||||
The outer schema carries no defaults (the per-platform schema applies them), so
|
||||
drift cannot surface in validation output — a key renamed or removed in
|
||||
_esp32_config_schema() but not here would silently vanish from the dashboard's
|
||||
field extractor. This test is what catches that. The outer schema bounds
|
||||
connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range
|
||||
walkers see a real Range; per-platform schemas tighten it, and the cap itself
|
||||
is pinned by test_idf_max_connections_mirror.
|
||||
"""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
|
||||
|
||||
|
||||
def _esp32_schema_keys() -> dict[str, object]:
|
||||
# The builder names its platform explicitly, so no CORE state is needed
|
||||
# (this also mirrors how the language-schema dumper calls it).
|
||||
return _keys(_schema_of(_esp32_config_schema()))
|
||||
|
||||
|
||||
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
|
||||
# plumbing (derived, so a future core key does not fail this component's test),
|
||||
# generated IDs (not user-walkable options), and connections (must validate
|
||||
# exactly once — see the comment above CONFIG_SCHEMA).
|
||||
_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | {
|
||||
"connections"
|
||||
}
|
||||
|
||||
|
||||
def _schema_of(validator: cv.All) -> vol.Schema:
|
||||
"""The vol.Schema stage of a cv.All chain, found by type rather than by
|
||||
position so reordering the chain cannot silently break these tests."""
|
||||
schemas = [v for v in validator.validators if isinstance(v, vol.Schema)]
|
||||
assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}"
|
||||
return schemas[0]
|
||||
|
||||
|
||||
def _keys(schema: vol.Schema) -> dict[str, object]:
|
||||
return {str(key.schema): key for key in schema.schema}
|
||||
|
||||
|
||||
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
|
||||
outer = _keys(_schema_of(CONFIG_SCHEMA))
|
||||
esp32 = _esp32_schema_keys()
|
||||
missing = set(outer) - set(esp32)
|
||||
assert not missing, (
|
||||
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
|
||||
"does not; update one of them in "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
|
||||
|
||||
def test_esp32_scalars_all_walkable() -> None:
|
||||
"""Every non-generated esp32 scalar option must appear in the outer schema
|
||||
(connections is deliberately excluded — it must validate exactly once)."""
|
||||
outer = _keys(_schema_of(CONFIG_SCHEMA))
|
||||
esp32 = _esp32_schema_keys()
|
||||
scalar = {
|
||||
name
|
||||
for name, key in esp32.items()
|
||||
if isinstance(key, vol.Optional)
|
||||
and not isinstance(key, cv.GenerateID)
|
||||
and name not in _NOT_MIRRORED
|
||||
}
|
||||
missing = scalar - set(outer)
|
||||
assert not missing, (
|
||||
f"esp32 scalar options {sorted(missing)} are missing from the outer "
|
||||
"CONFIG_SCHEMA and invisible to schema tooling; update "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""The three platform-gate branches: BLE-less platforms are rejected with the
|
||||
real reason, hub platforms reject GATT-only options by name, and the
|
||||
advertisement-only arm applies its own defaults."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy
|
||||
from esphome.config_helpers import frameworks_for_platforms
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
# Advertisement-only hub platforms; rp2 runs the full proxy and has its own
|
||||
# tests below.
|
||||
HUB_PLATFORM_FRAMEWORKS = [
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
]
|
||||
|
||||
HUB_TRACKERS = {
|
||||
PLATFORM_BK72XX: "bk72xx_ble_tracker",
|
||||
PLATFORM_LN882X: "ln882h_ble_tracker",
|
||||
PLATFORM_RP2: "rp2_ble_tracker",
|
||||
}
|
||||
|
||||
|
||||
def test_hub_platform_list_covers_every_hub_platform() -> None:
|
||||
# A platform added to _HUB_PLATFORMS would otherwise get no gate coverage
|
||||
# at all; GATT platforms have their own tests.
|
||||
advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set(
|
||||
bluetooth_connection.HUB_MAX_CONNECTIONS
|
||||
)
|
||||
assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only
|
||||
assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS)
|
||||
|
||||
|
||||
def _set_platform(platform: str | None) -> None:
|
||||
# For arms set_core_config cannot express (bare platform, no framework).
|
||||
CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform
|
||||
|
||||
|
||||
def _register_tracker(platform: str) -> None:
|
||||
# The ble_hub_id guard needs a loaded tracker, normally registered as an
|
||||
# import side effect of the tracker module.
|
||||
tracker = HUB_TRACKERS[platform]
|
||||
ble_device_base.register_hub_provider(tracker)
|
||||
CORE.loaded_integrations.add(tracker)
|
||||
|
||||
|
||||
def test_ble_less_platform_gets_the_real_reason(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({})
|
||||
|
||||
|
||||
def test_ble_less_platform_connection_keys_fall_through(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# The key-level rejection must not fire here — it would imply an
|
||||
# advertisement-only proxy exists on this platform.
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
|
||||
|
||||
|
||||
def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None:
|
||||
# set_core_config cannot express "no platform"; script/build_codeowners.py
|
||||
# sets exactly this shape, and the key gate returns early on it so the
|
||||
# platform gate is what reports.
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}
|
||||
with pytest.raises(cv.Invalid, match="not supported on None"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
|
||||
def test_hub_platform_rejects_active(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
) -> None:
|
||||
set_core_config(platform_framework)
|
||||
_register_tracker(platform_framework.value[0])
|
||||
with pytest.raises(cv.Invalid, match="Active connections are not supported"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"active": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("connection_slots", 2),
|
||||
("cache_services", True),
|
||||
# Absent from the outer CONFIG_SCHEMA, so this gate is the only test
|
||||
# that touches it.
|
||||
("connections", [{}]),
|
||||
],
|
||||
)
|
||||
def test_hub_platform_rejects_connection_keys_by_name(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
key: str,
|
||||
value: object,
|
||||
) -> None:
|
||||
set_core_config(platform_framework)
|
||||
with pytest.raises(cv.Invalid, match=f"'{key}' requires active"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({key: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
|
||||
def test_hub_platform_accepts_the_advertisement_only_shape(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
) -> None:
|
||||
set_core_config(platform_framework)
|
||||
_register_tracker(platform_framework.value[0])
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({})
|
||||
assert validated[CONF_ACTIVE] is False
|
||||
|
||||
|
||||
def test_rp2_defaults_to_the_full_proxy(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# esp32 parity: active defaults to true, with the platform's slot limit,
|
||||
# and one populated connection entry for the codegen to index.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({})
|
||||
assert validated[CONF_ACTIVE] is True
|
||||
assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3
|
||||
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3
|
||||
|
||||
|
||||
def test_rp2_accepts_explicit_passive(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False})
|
||||
assert validated[CONF_ACTIVE] is False
|
||||
assert bluetooth_proxy.CONF_CONNECTIONS not in validated
|
||||
|
||||
|
||||
def test_rp2_rejects_slots_beyond_the_btstack_limit(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
with pytest.raises(cv.Invalid, match="at most 3 connection slot"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4})
|
||||
# Fewer slots than the cap stay accepted (the prebuilt single-client pool
|
||||
# path for 1, the wrap path for 2).
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1})
|
||||
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
|
||||
# Values past even the loosest platform cap stop at the outer walkable
|
||||
# schema, which stays bounded for range walkers (device-builder sync);
|
||||
# in-range values get the platform message above.
|
||||
with pytest.raises(cv.Invalid, match="at most 9"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12})
|
||||
|
||||
|
||||
def test_rp2_rejects_esp32_only_keys_by_name(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True})
|
||||
with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
|
||||
|
||||
|
||||
def test_hub_source_filter_covers_every_hub_platform() -> None:
|
||||
# bluetooth_connection cannot import this module to derive the hub.cpp
|
||||
# framework set, so pin it here: a platform admitted to the proxy but
|
||||
# missing from the filter would validate, then fail at link.
|
||||
expected = frameworks_for_platforms(
|
||||
[*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32]
|
||||
)
|
||||
hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[
|
||||
"bluetooth_connection_hub.cpp"
|
||||
]
|
||||
assert expected == hub_frameworks
|
||||
|
||||
|
||||
def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
|
||||
# The backend registers with its platform BLE stack (and the Bluedroid
|
||||
# header includes the tracker's), so that closure lives here and
|
||||
# consumers stay platform-blind; the platform-less arm is the union for
|
||||
# manifest-resolving tooling.
|
||||
_set_platform("esp32")
|
||||
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"]
|
||||
_set_platform("rp2")
|
||||
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"]
|
||||
_set_platform("ln882x")
|
||||
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
|
||||
_set_platform(None)
|
||||
assert bluetooth_connection.AUTO_LOAD() == [
|
||||
"ble_device_base",
|
||||
"esp32_ble_tracker",
|
||||
"rp2040_ble",
|
||||
]
|
||||
|
||||
|
||||
def test_every_registered_hub_platform_has_a_schema_arm() -> None:
|
||||
# A platform added to HUB_MAX_CONNECTIONS without a schema builder or
|
||||
# _HUB_PLATFORMS entry would only fail when a config for it is validated
|
||||
# (or not even then); pin both couplings here. Connection codegen is
|
||||
# shared (bluetooth_connection.new_gatt_backend), so it needs no arm.
|
||||
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
|
||||
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
|
||||
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
|
||||
# Hub platforms must also be in the backend registry the shared codegen
|
||||
# helpers dispatch on.
|
||||
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
|
||||
# The outer walkable schema's bound must stay the loosest platform cap.
|
||||
assert (
|
||||
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
|
||||
<= bluetooth_proxy._IDF_MAX_CONNECTIONS
|
||||
)
|
||||
|
||||
|
||||
def test_defines_h_mirrors_the_rp2_slot_cap() -> None:
|
||||
# esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS
|
||||
# for static analysis; pin it to the real rp2 cap.
|
||||
defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text()
|
||||
cap = bluetooth_connection.RP2_MAX_CONNECTIONS
|
||||
# The rp2 arm's define, tolerating blank/comment lines in between.
|
||||
match = re.search(
|
||||
r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)",
|
||||
defines,
|
||||
)
|
||||
assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS"
|
||||
assert int(match.group(1)) == cap, (
|
||||
f"defines.h rp2 arm carries {match.group(1)}, expected {cap}"
|
||||
)
|
||||
# The static-analysis client count scales with the same cap. Scoped to
|
||||
# the USE_RP2 block: the esp32 arm carries its own count.
|
||||
rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines)
|
||||
assert rp2_block is not None, "no USE_RP2 platform block in defines.h"
|
||||
match = re.search(
|
||||
r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1)
|
||||
)
|
||||
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block"
|
||||
assert int(match.group(1)) == cap, (
|
||||
f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
|
||||
)
|
||||
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
|
||||
CORE.reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_full_config() -> Generator[None]:
|
||||
"""Give each test a clean final-validate config and restore it after."""
|
||||
token = final_validate.full_config.set({})
|
||||
yield
|
||||
final_validate.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def set_core_config() -> Generator[SetCoreConfigCallable]:
|
||||
"""Fixture to set up the core configuration for tests."""
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""Tests for the deep sleep component."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import deep_sleep
|
||||
from esphome.const import CONF_WAKEUP_PIN, PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def test_deep_sleep_setup(generate_main):
|
||||
"""
|
||||
@@ -33,6 +41,43 @@ def test_deep_sleep_run_duration_simple(generate_main):
|
||||
assert "deepsleep->set_run_duration(10000);" in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_on_wake_trigger(generate_main):
|
||||
"""
|
||||
When deep sleep is configured with a component-level on_wake automation,
|
||||
a WakeTrigger component should be registered with the wakeup cause as
|
||||
the automation argument.
|
||||
"""
|
||||
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml")
|
||||
|
||||
assert "deep_sleep::WakeTrigger();" in main_cpp
|
||||
assert "Automation<deep_sleep::WakeupCause>" in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_ext1_on_wake_triggers(generate_main):
|
||||
"""
|
||||
Each esp32_ext1_wakeup pin with an on_wake automation should get its own
|
||||
Ext1WakeTrigger with the pin number, and all pins (including the legacy
|
||||
bare-pin shorthand) should contribute to the ext1 wakeup mask.
|
||||
"""
|
||||
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml")
|
||||
|
||||
assert "deep_sleep::Ext1WakeTrigger(2);" in main_cpp
|
||||
assert "deep_sleep::Ext1WakeTrigger(4);" in main_cpp
|
||||
# GPIO13 has no on_wake, so no trigger is created for it
|
||||
assert "deep_sleep::Ext1WakeTrigger(13)" not in main_cpp
|
||||
# mask covers GPIO2, GPIO4 and GPIO13
|
||||
assert ".mask = 8212," in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_no_on_wake_no_triggers(generate_main):
|
||||
"""
|
||||
Without any on_wake automations, no wake trigger code should be generated.
|
||||
"""
|
||||
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml")
|
||||
|
||||
assert "WakeTrigger" not in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_run_duration_dictionary(generate_main):
|
||||
"""
|
||||
When deep sleep is configured with dictionary run duration, it should be set.
|
||||
@@ -46,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main):
|
||||
" .gpio_cause = 30000,\n"
|
||||
"});"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error."""
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
config = {
|
||||
CONF_WAKEUP_PIN: [
|
||||
{"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
|
||||
],
|
||||
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not both"):
|
||||
deep_sleep.validate_config(config)
|
||||
|
||||
|
||||
def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry."""
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
config = {
|
||||
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}],
|
||||
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
|
||||
}
|
||||
result = deep_sleep.validate_config(config)
|
||||
|
||||
assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result
|
||||
assert (
|
||||
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
deep_sleep:
|
||||
id: deepsleep
|
||||
sleep_duration: 1min
|
||||
run_duration: 10s
|
||||
on_wake:
|
||||
- lambda: 'ESP_LOGD("test", "cause %d", static_cast<int>(cause));'
|
||||
esp32_ext1_wakeup:
|
||||
mode: ANY_HIGH
|
||||
pins:
|
||||
- pin: GPIO2
|
||||
on_wake:
|
||||
- lambda: 'ESP_LOGD("test", "left");'
|
||||
- pin:
|
||||
number: GPIO4
|
||||
on_wake:
|
||||
- lambda: 'ESP_LOGD("test", "right");'
|
||||
- number: GPIO13
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for emontx sensor tag defaults."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import sensor
|
||||
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
DEVICE_CLASS_APPARENT_POWER,
|
||||
DEVICE_CLASS_CURRENT,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_FREQUENCY,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_POWER_FACTOR,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_EMPTY,
|
||||
UNIT_HERTZ,
|
||||
UNIT_PULSES,
|
||||
UNIT_VOLT,
|
||||
UNIT_VOLT_AMPS,
|
||||
UNIT_WATT,
|
||||
UNIT_WATT_HOURS,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_via_config_schema(tag: str) -> dict:
|
||||
"""Run a minimal config through the real CONFIG_SCHEMA pipeline, the
|
||||
same path a user's YAML goes through."""
|
||||
return CONFIG_SCHEMA(
|
||||
{"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"}
|
||||
)
|
||||
|
||||
|
||||
def test_config_schema_applies_tag_default_state_class():
|
||||
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
|
||||
default wins over apply_tag_defaults' per-prefix value, and E1 would
|
||||
resolve to measurement instead of total_increasing. Driving the real
|
||||
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
|
||||
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
|
||||
"""
|
||||
result = _resolve_via_config_schema("E1")
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
|
||||
STATE_CLASS_TOTAL_INCREASING
|
||||
)
|
||||
|
||||
|
||||
def test_config_schema_applies_tag_default_accuracy_decimals():
|
||||
"""Same root cause as the state_class regression: reintroducing
|
||||
sensor_schema(accuracy_decimals=...) would make V1 resolve to the
|
||||
schema-level default instead of the prefix-specific value of 2.
|
||||
"""
|
||||
result = _resolve_via_config_schema("V1")
|
||||
assert result[CONF_ACCURACY_DECIMALS] == 2
|
||||
|
||||
|
||||
def _make_config(tag: str) -> dict:
|
||||
"""Minimal config dict with only tag_name set — no overrides."""
|
||||
return {"tag_name": tag}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "expected_state_class", "expected_decimals"),
|
||||
[
|
||||
# Known numeric-index prefixes
|
||||
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("P1", STATE_CLASS_MEASUREMENT, 0),
|
||||
("V1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("I1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("T1", STATE_CLASS_MEASUREMENT, 2),
|
||||
# Known patterns
|
||||
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("PF1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("AP1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("AP12", STATE_CLASS_MEASUREMENT, 2),
|
||||
# Frequency: reported as a single, un-numbered tag
|
||||
("F", STATE_CLASS_MEASUREMENT, 2),
|
||||
# Unknown / free-form tags fall back to generic defaults
|
||||
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
|
||||
("X", STATE_CLASS_MEASUREMENT, 0),
|
||||
# "F1" is not the exact "F" tag, so it falls back to generic defaults
|
||||
("F1", STATE_CLASS_MEASUREMENT, 0),
|
||||
# "PULSE" (no index) is how some real emonTx firmware reports a
|
||||
# single pulse counter, so it still resolves to the PULSE defaults
|
||||
("PULSE", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
# Real firmware sends this lowercase; tag_upper's case-folding must
|
||||
# still match it against the PULSE pattern
|
||||
("pulse", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
# PF/AP require a numeric index; the bare prefix alone (no index)
|
||||
# falls back to generic defaults
|
||||
("PF", STATE_CLASS_MEASUREMENT, 0),
|
||||
("AP", STATE_CLASS_MEASUREMENT, 0),
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
|
||||
"""apply_tag_defaults must inject the correct state_class and accuracy_decimals
|
||||
for each tag type when no user overrides are present."""
|
||||
config = _make_config(tag)
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class)
|
||||
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "expected_unit", "expected_device_class"),
|
||||
[
|
||||
# Known numeric-index prefixes
|
||||
("E1", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
|
||||
("E12", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
|
||||
("P1", UNIT_WATT, DEVICE_CLASS_POWER),
|
||||
("V1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE),
|
||||
("I1", UNIT_AMPERE, DEVICE_CLASS_CURRENT),
|
||||
("T1", UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE),
|
||||
# Known patterns
|
||||
("PULSE1", UNIT_PULSES, DEVICE_CLASS_ENERGY),
|
||||
("PULSE12", UNIT_PULSES, DEVICE_CLASS_ENERGY),
|
||||
# Bare "PULSE" (no index), as reported by some real emonTx firmware
|
||||
("PULSE", UNIT_PULSES, DEVICE_CLASS_ENERGY),
|
||||
# Real firmware sends this lowercase; tag_upper's case-folding must
|
||||
# still match it against the PULSE pattern
|
||||
("pulse", UNIT_PULSES, DEVICE_CLASS_ENERGY),
|
||||
("PF1", UNIT_EMPTY, DEVICE_CLASS_POWER_FACTOR),
|
||||
("AP1", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
|
||||
("AP12", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
|
||||
# Frequency: reported as a single, un-numbered tag
|
||||
("F", UNIT_HERTZ, DEVICE_CLASS_FREQUENCY),
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults_unit_and_device_class(
|
||||
tag, expected_unit, expected_device_class
|
||||
):
|
||||
"""apply_tag_defaults must inject the correct, validated unit_of_measurement
|
||||
and device_class for each tag type when no user overrides are present."""
|
||||
config = _make_config(tag)
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert result[CONF_UNIT_OF_MEASUREMENT] == sensor.validate_unit_of_measurement(
|
||||
expected_unit
|
||||
)
|
||||
assert result[CONF_DEVICE_CLASS] == sensor.validate_device_class(
|
||||
expected_device_class
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
[
|
||||
"CUSTOM1",
|
||||
"X",
|
||||
# Non-numeric suffixes must not collide with a PATTERN_CONFIGS prefix
|
||||
# (e.g. "APPLE" starting with "AP", "PFX" starting with "PF").
|
||||
"APPLE",
|
||||
"PFX",
|
||||
"PULSE_A",
|
||||
# "F1" is not the exact "F" tag
|
||||
"F1",
|
||||
# Bare "PF"/"AP" (no numeric index) don't match; unlike "PULSE",
|
||||
# real firmware never reports these without an index
|
||||
"PF",
|
||||
"AP",
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults_unknown_tag_has_no_unit_or_device_class(tag):
|
||||
"""Unknown / free-form tags only get generic state_class and
|
||||
accuracy_decimals defaults; unit_of_measurement and device_class are left
|
||||
for the user to set explicitly."""
|
||||
config = _make_config(tag)
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert CONF_UNIT_OF_MEASUREMENT not in result
|
||||
assert CONF_DEVICE_CLASS not in result
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
|
||||
STATE_CLASS_MEASUREMENT
|
||||
)
|
||||
assert result[CONF_ACCURACY_DECIMALS] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "user_state_class", "user_decimals"),
|
||||
[
|
||||
# User overrides must not be clobbered by defaults
|
||||
("E1", STATE_CLASS_MEASUREMENT, 3),
|
||||
("PULSE1", STATE_CLASS_MEASUREMENT, 1),
|
||||
("V1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4),
|
||||
],
|
||||
)
|
||||
def test_apply_tag_defaults_respects_user_overrides(
|
||||
tag, user_state_class, user_decimals
|
||||
):
|
||||
"""apply_tag_defaults must not overwrite values already set by the user."""
|
||||
config = _make_config(tag)
|
||||
config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class)
|
||||
config[CONF_ACCURACY_DECIMALS] = user_decimals
|
||||
|
||||
result = apply_tag_defaults(config)
|
||||
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class)
|
||||
assert result[CONF_ACCURACY_DECIMALS] == user_decimals
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO19
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: t133a01
|
||||
dc_pin: GPIO21
|
||||
reset_pin: GPIO38
|
||||
cs_pin: GPIO10
|
||||
cs1_pin: GPIO2
|
||||
busy_pin: GPIO13
|
||||
update_interval: never
|
||||
dimensions:
|
||||
width: 200
|
||||
height: 200
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-e1001
|
||||
@@ -439,6 +439,23 @@ def test_enable_pin_multiple(
|
||||
assert all(pin["mode"]["output"] is True for pin in enable_pins)
|
||||
|
||||
|
||||
def test_uc8179_e1001_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that the reTerminal E1001 model generates the UC8179 driver and init sequence."""
|
||||
main_cpp = generate_main(component_config_path("uc8179_e1001_test.yaml"))
|
||||
|
||||
# The model must instantiate the UC8179 driver class with the panel dimensions
|
||||
assert "epaper_spi::EPaperUC8179" in main_cpp
|
||||
assert re.search(r'"SEEED-RETERMINAL-E1001",\s*800,\s*480', main_cpp)
|
||||
|
||||
# The generated init sequence must contain the UC8179 resolution setting
|
||||
# for 800x480: command 0x61, 4 data bytes 0x03 0x20 0x01 0xE0
|
||||
# (rendered as decimal in the generated array)
|
||||
assert "97, 4, 3, 32, 1, 224" in main_cpp
|
||||
|
||||
|
||||
def test_enable_pin_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
@@ -462,3 +479,24 @@ def test_enable_pin_code_generation(
|
||||
# Both pin objects must be passed to the display via set_enable_pins() as a
|
||||
# std::vector initializer list, in the configured order.
|
||||
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
|
||||
|
||||
|
||||
def test_model_with_no_default_init_sequence_generates(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that code generation succeeds for a model with no default init sequence.
|
||||
|
||||
The base "t133a01" model (used directly, not via one of its `.extend()`
|
||||
variants) doesn't override `get_init_sequence()` or pass `initsequence` to
|
||||
its constructor, and the user didn't supply `init_sequence:` either.
|
||||
`EpaperModel.get_init_sequence()` used to default to `None` in this case,
|
||||
which made `flatten_sequence()` raise a `TypeError` during code
|
||||
generation. Regression test for that crash.
|
||||
"""
|
||||
main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml"))
|
||||
|
||||
# The generated constructor call takes (name, width, height, init_sequence,
|
||||
# init_sequence_length, ...); a length of 0 confirms the empty init
|
||||
# sequence array was generated instead of raising during code generation.
|
||||
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
libraries:
|
||||
- NetworkClientSecure
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: arduino
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
use_full_certificate_bundle: true
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
http_request:
|
||||
verify_ssl: true
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
sdkconfig_options:
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE: y
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
i2c:
|
||||
sda: 21
|
||||
scl: 22
|
||||
|
||||
output:
|
||||
- platform: ledc
|
||||
id: ledc_out
|
||||
pin: 25
|
||||
- platform: ac_dimmer
|
||||
id: dimmer_out
|
||||
gate_pin: 26
|
||||
zero_cross_pin: 27
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
esp32_camera_web_server:
|
||||
port: 8080
|
||||
mode: stream
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
espnow:
|
||||
channel: 1
|
||||
auto_add_peer: true
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
sensor:
|
||||
- platform: internal_temperature
|
||||
name: Internal Temperature
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
mqtt:
|
||||
broker: "10.0.0.1"
|
||||
@@ -0,0 +1,20 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
uart:
|
||||
tx_pin: 17
|
||||
rx_pin: 16
|
||||
baud_rate: 115200
|
||||
|
||||
display:
|
||||
- platform: nextion
|
||||
tft_url: "http://10.0.0.1/display.tft"
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
sdkconfig_options:
|
||||
CONFIG_NVS_ENCRYPTION: y
|
||||
CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC: y
|
||||
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID: "0"
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
web_server:
|
||||
version: 3
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
sensor:
|
||||
- platform: internal_temperature
|
||||
name: Internal Temperature
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
sdkconfig_options:
|
||||
CONFIG_NVS_ENCRYPTION: n
|
||||
@@ -6,6 +6,8 @@ esp32:
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
mdns:
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
|
||||
network:
|
||||
priority:
|
||||
- ethernet
|
||||
- wifi
|
||||
@@ -6,6 +6,8 @@ esp32:
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
mdns:
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32c6
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
signing_scheme: ecdsa256
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
signing_scheme: ecdsa_v1
|
||||
verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
signing_scheme: rsa3072
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
signing_scheme: rsa3072
|
||||
signing_key: ../../../components/esp32/dummy_signing_key.pem
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
signed_ota_verification:
|
||||
signing_scheme: rsa3072
|
||||
verification_keys:
|
||||
- ../../../components/esp32/dummy_signing_key.pem
|
||||
@@ -10,14 +10,21 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
KEY_FATFS_REQUIRED,
|
||||
KEY_VFS_DIR_REQUIRED,
|
||||
KEY_VFS_SELECT_REQUIRED,
|
||||
KEY_VFS_TERMIOS_REQUIRED,
|
||||
VARIANT_ESP32,
|
||||
VARIANTS,
|
||||
NetworkSdkconfigData,
|
||||
RawSdkconfigValue,
|
||||
_ota_downgrade_protection_errors,
|
||||
_reconcile_network_sdkconfig,
|
||||
_reconcile_vfs_fatfs_sdkconfig,
|
||||
)
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_ESP32,
|
||||
KEY_EXCLUDE_COMPONENTS,
|
||||
KEY_NETWORK_SDKCONFIG,
|
||||
KEY_SDKCONFIG_OPTIONS,
|
||||
KEY_VARIANT,
|
||||
@@ -108,6 +115,38 @@ def test_esp32_default_toolchain_is_esp_idf(
|
||||
assert CORE.toolchain == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_toolchain",
|
||||
[Toolchain.SDK_NRF.value, "nonsense"],
|
||||
)
|
||||
def test_esp32_rejects_unsupported_toolchains(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
config_toolchain: str,
|
||||
) -> None:
|
||||
"""Toolchains esp32 does not support are rejected at validation time."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
from esphome.components.esp32 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
|
||||
|
||||
|
||||
def test_esp32_rejects_unsupported_cli_toolchain(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A --toolchain the platform cannot serve fails instead of silently
|
||||
building with PlatformIO (the CLI path bypasses the YAML validator)."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
from esphome.components.esp32 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "error_match"),
|
||||
[
|
||||
@@ -213,6 +252,172 @@ def test_esp32_configuration_errors(
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "reincluded"),
|
||||
[
|
||||
pytest.param(
|
||||
"exclusion_reincludes.yaml",
|
||||
("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.
|
||||
pytest.param(
|
||||
"exclusion_reincludes_http_request.yaml",
|
||||
("esp-tls", "esp_http_client"),
|
||||
id="http_request",
|
||||
),
|
||||
pytest.param(
|
||||
# "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it
|
||||
# is a managed component and never leaves the exclusion set.
|
||||
"exclusion_reincludes_mqtt.yaml",
|
||||
("esp-tls",),
|
||||
id="mqtt",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_web_server.yaml",
|
||||
("esp-tls", "esp_http_server"),
|
||||
id="web_server_idf",
|
||||
),
|
||||
pytest.param(
|
||||
"nvs_encryption_s3.yaml",
|
||||
("nvs_sec_provider",),
|
||||
id="nvs_encryption",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_nvs_sdkconfig.yaml",
|
||||
("nvs_sec_provider",),
|
||||
id="nvs_encryption_raw_sdkconfig",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_camera_web_server.yaml",
|
||||
("esp_http_server",),
|
||||
id="esp32_camera_web_server",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_nextion.yaml",
|
||||
("esp-tls", "esp_http_client"),
|
||||
id="nextion",
|
||||
),
|
||||
pytest.param(
|
||||
# esp_wifi/wpa_supplicant from request_wifi(), bt from
|
||||
# request_bluetooth(), esp_coex from esp32_ble_tracker's software
|
||||
# coexistence (defaults on with wifi). esp_phy stays excluded;
|
||||
# IDF requirement expansion pulls it back via esp_wifi.
|
||||
"exclusion_reincludes_wifi_ble.yaml",
|
||||
("esp_wifi", "wpa_supplicant", "bt", "esp_coex"),
|
||||
id="wifi_ble",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_espnow.yaml",
|
||||
("esp_wifi",),
|
||||
id="espnow",
|
||||
),
|
||||
pytest.param(
|
||||
# temprature_sens_read() on the original ESP32 lives in the esp_phy blob.
|
||||
"exclusion_reincludes_internal_temperature.yaml",
|
||||
("esp_phy",),
|
||||
id="internal_temperature",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_default_exclusions_reincluded_by_owning_components(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
reincluded: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Components whose IDF driver is excluded by default must re-include it
|
||||
during codegen; a dropped include_builtin_idf_component() call would only
|
||||
surface as a missing-header failure in a full compile job."""
|
||||
generate_main(component_config_path(config_file))
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
for name in reincluded:
|
||||
assert name not in excluded, f"{name} should have been re-included"
|
||||
|
||||
# Components no part of this config touches stay excluded.
|
||||
assert "unity" in excluded
|
||||
assert "fatfs" in excluded
|
||||
# The HTTP server only comes back for configs that run one.
|
||||
assert ("esp_http_server" in excluded) == ("esp_http_server" not in reincluded)
|
||||
|
||||
|
||||
def test_esp_phy_stays_excluded_for_internal_temperature_on_newer_variants(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Only the original ESP32 reads the PHY blob; other variants use esp_driver_tsens."""
|
||||
generate_main(component_config_path("exclusion_stays_internal_temperature_s3.yaml"))
|
||||
assert "esp_phy" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
|
||||
def test_nvs_sec_provider_stays_excluded_when_encryption_is_off(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded."""
|
||||
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
|
||||
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
|
||||
_BUNDLE_OPTIONS = (
|
||||
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE",
|
||||
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN",
|
||||
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "expected"),
|
||||
[
|
||||
pytest.param("exclusion_reincludes.yaml", (False, None, None), id="no_tls"),
|
||||
pytest.param(
|
||||
"certificate_bundle_http_request.yaml",
|
||||
(True, True, False),
|
||||
id="http_request",
|
||||
),
|
||||
pytest.param(
|
||||
"exclusion_reincludes_http_request.yaml",
|
||||
(False, None, None),
|
||||
id="http_request_no_verify",
|
||||
),
|
||||
pytest.param(
|
||||
"certificate_bundle_full.yaml", (True, None, True), id="full_option"
|
||||
),
|
||||
pytest.param(
|
||||
"certificate_bundle_arduino_tls.yaml",
|
||||
(True, True, False),
|
||||
id="arduino_network_client_secure",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_certificate_bundle_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
expected: tuple[bool | None, ...],
|
||||
) -> None:
|
||||
"""The bundle and its CMN/FULL variant are written only when requested."""
|
||||
generate_main(component_config_path(config_file))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert tuple(sdkconfig.get(name) for name in _BUNDLE_OPTIONS) == expected
|
||||
|
||||
|
||||
def test_user_sdkconfig_certificate_bundle_wins(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A raw sdkconfig_options bundle setting is kept and still pins CMN."""
|
||||
generate_main(component_config_path("certificate_bundle_sdkconfig.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
value = sdkconfig["CONFIG_MBEDTLS_CERTIFICATE_BUNDLE"]
|
||||
assert isinstance(value, RawSdkconfigValue)
|
||||
assert value.value == "y"
|
||||
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN") is True
|
||||
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
|
||||
|
||||
|
||||
def test_execute_from_psram_s3_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
@@ -252,6 +457,53 @@ def test_nvs_encryption_sdkconfig(
|
||||
assert "PERMANENT and IRREVERSIBLE" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "multi_key", "idf_on_update"),
|
||||
[
|
||||
# Externally-signed RSA with a declared trusted-key list hands
|
||||
# verification to ESPHome's multi-key verifier, so IDF's single-block
|
||||
# on-update check must be OFF. It defaults ON under
|
||||
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False
|
||||
# explicitly -- not merely omitted.
|
||||
("signed_ota_verification_keys_s3.yaml", True, False),
|
||||
# Externally-signed RSA without a trusted-key list has no trust anchor,
|
||||
# so it falls back to IDF's built-in check.
|
||||
("signed_ota_external_rsa_s3.yaml", False, True),
|
||||
# Build-time signing and the other schemes keep IDF's check.
|
||||
("signed_ota_signing_key_s3.yaml", False, True),
|
||||
("signed_ota_ecdsa256_c6.yaml", False, True),
|
||||
("signed_ota_ecdsa_v1.yaml", False, True),
|
||||
],
|
||||
)
|
||||
def test_signed_ota_verification_sdkconfig(
|
||||
fixture: str,
|
||||
multi_key: bool,
|
||||
idf_on_update: bool,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Only external RSA disables IDF's on-update check and uses ESPHome's verifier."""
|
||||
generate_main(component_config_path(fixture))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
# The padded, externally-signable image is always produced.
|
||||
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True
|
||||
# Explicit value (never left to the Kconfig default) decides who verifies.
|
||||
assert (
|
||||
sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update
|
||||
)
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key
|
||||
if multi_key:
|
||||
# The padding / reserved signature sector the verifier depends on keys
|
||||
# off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS
|
||||
# (which the explicit `n` above drives to n). Pin the real dependency.
|
||||
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True
|
||||
# The compiled-in trust anchor: the fixture lists one key.
|
||||
define_values = {define.name: str(define.value) for define in CORE.defines}
|
||||
assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1"
|
||||
assert "OTA_TRUSTED_KEY_DIGESTS" in define_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "expect_warning"),
|
||||
[
|
||||
@@ -454,26 +706,18 @@ def test_flash_mode_unset_leaves_defaults(
|
||||
),
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
NetworkSdkconfigData(
|
||||
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
|
||||
),
|
||||
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
|
||||
{},
|
||||
{
|
||||
"CONFIG_BT_ENABLED": True,
|
||||
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
|
||||
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
|
||||
"CONFIG_SW_COEXIST_ENABLE": True,
|
||||
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
|
||||
"CONFIG_LWIP_DHCPS": False,
|
||||
},
|
||||
id="idf_wifi_ble_tracker_coexistence",
|
||||
),
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
NetworkSdkconfigData(bluetooth=True),
|
||||
{},
|
||||
{"CONFIG_BT_ENABLED": True},
|
||||
id="idf_ble_server_only_no_ble42",
|
||||
),
|
||||
# --- IDF: user sdkconfig_options always win ---
|
||||
pytest.param(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
@@ -557,6 +801,160 @@ def test_reconcile_network_sdkconfig(
|
||||
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requires", "fatfs_required", "disables", "preset", "expected"),
|
||||
[
|
||||
# Nothing required and every disable_* flag off (NOT the shipped defaults, which
|
||||
# disable everything): VFS enabled, FATFS left untouched entirely.
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
(False, False, False, False),
|
||||
{},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
},
|
||||
id="nothing_disabled_nothing_required",
|
||||
),
|
||||
# The shipped out-of-the-box path: every disable_* flag defaults to True and nothing
|
||||
# is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume).
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
(True, True, True, True),
|
||||
{},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": False,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": False,
|
||||
"CONFIG_VFS_SUPPORT_DIR": False,
|
||||
"CONFIG_FATFS_LFN_NONE": True,
|
||||
"CONFIG_FATFS_VOLUME_COUNT": 1,
|
||||
},
|
||||
id="all_disabled_fatfs_fallback",
|
||||
),
|
||||
# A component's require_* beats the user's disable_* flag for every VFS feature.
|
||||
pytest.param(
|
||||
{
|
||||
KEY_VFS_TERMIOS_REQUIRED: True,
|
||||
KEY_VFS_SELECT_REQUIRED: True,
|
||||
KEY_VFS_DIR_REQUIRED: True,
|
||||
},
|
||||
False,
|
||||
(True, True, True, False),
|
||||
{},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
},
|
||||
id="require_beats_disable",
|
||||
),
|
||||
# A user sdkconfig_options preset wins over a require (the set_opt guard).
|
||||
pytest.param(
|
||||
{KEY_VFS_SELECT_REQUIRED: True},
|
||||
False,
|
||||
(False, False, False, False),
|
||||
{"CONFIG_VFS_SUPPORT_SELECT": False},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": False,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
},
|
||||
id="user_preset_wins_over_require",
|
||||
),
|
||||
# require_fatfs() with no user preset: long filenames on the heap, 255 chars,
|
||||
# four volumes.
|
||||
pytest.param(
|
||||
{},
|
||||
True,
|
||||
(False, False, False, False),
|
||||
{},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
"CONFIG_FATFS_LFN_NONE": False,
|
||||
"CONFIG_FATFS_LFN_HEAP": True,
|
||||
"CONFIG_FATFS_MAX_LFN": 255,
|
||||
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
||||
},
|
||||
id="fatfs_required_defaults",
|
||||
),
|
||||
# CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member
|
||||
# (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice.
|
||||
pytest.param(
|
||||
{},
|
||||
True,
|
||||
(False, False, False, False),
|
||||
{"CONFIG_FATFS_LFN_STACK": "y"},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
"CONFIG_FATFS_LFN_STACK": "y",
|
||||
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
||||
},
|
||||
id="fatfs_user_lfn_stack_untouched",
|
||||
),
|
||||
# disable_fatfs (the shipped default) with a user LFN pick: the choice group is the
|
||||
# user's -- no LFN_NONE=y written next to their member, only the volume fallback.
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
(False, False, False, True),
|
||||
{"CONFIG_FATFS_LFN_HEAP": "y"},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
"CONFIG_FATFS_LFN_HEAP": "y",
|
||||
"CONFIG_FATFS_VOLUME_COUNT": 1,
|
||||
},
|
||||
id="disable_fatfs_user_lfn_untouched",
|
||||
),
|
||||
# Same for an explicit LFN_NONE preset: the group is the user's, only the volume
|
||||
# count default is added.
|
||||
pytest.param(
|
||||
{},
|
||||
True,
|
||||
(False, False, False, False),
|
||||
{"CONFIG_FATFS_LFN_NONE": "y"},
|
||||
{
|
||||
"CONFIG_VFS_SUPPORT_TERMIOS": True,
|
||||
"CONFIG_VFS_SUPPORT_SELECT": True,
|
||||
"CONFIG_VFS_SUPPORT_DIR": True,
|
||||
"CONFIG_FATFS_LFN_NONE": "y",
|
||||
"CONFIG_FATFS_VOLUME_COUNT": 4,
|
||||
},
|
||||
id="fatfs_user_lfn_none_untouched",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reconcile_vfs_fatfs_sdkconfig(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
requires: dict[str, bool],
|
||||
fatfs_required: bool,
|
||||
disables: tuple[bool, bool, bool, bool],
|
||||
preset: dict[str, Any],
|
||||
expected: dict[str, Any],
|
||||
) -> None:
|
||||
"""The FINAL-priority reconciler resolves the VFS feature flags and the FATFS
|
||||
defaults from the recorded require_* calls, with user sdkconfig_options winning
|
||||
and the LFN Kconfig choice treated as one group."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)}
|
||||
if fatfs_required:
|
||||
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
|
||||
for key, value in requires.items():
|
||||
CORE.data[key] = value
|
||||
|
||||
asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables))
|
||||
|
||||
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
|
||||
|
||||
|
||||
def test_network_wifi_only_reconciles_end_to_end(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
@@ -567,6 +965,14 @@ def test_network_wifi_only_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
# request_wifi() also puts the WiFi components back in the build set;
|
||||
# esp_phy stays excluded, IDF requirement expansion pulls it back.
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
assert "esp_wifi" not in excluded
|
||||
assert "wpa_supplicant" not in excluded
|
||||
assert "esp_phy" in excluded
|
||||
# With wifi present mdns keeps its predefined interfaces.
|
||||
assert "CONFIG_MDNS_PREDEF_NETIF_STA" not in sdkconfig
|
||||
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
assert "CONFIG_BT_ENABLED" not in sdkconfig
|
||||
@@ -582,6 +988,12 @@ def test_network_ethernet_only_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
|
||||
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
|
||||
# The whole radio stack stays out of the build set as well.
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded
|
||||
# Without wifi, mdns drops its predefined STA/AP interfaces.
|
||||
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_STA") is False
|
||||
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_AP") is False
|
||||
|
||||
|
||||
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
@@ -594,6 +1006,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
|
||||
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
|
||||
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
|
||||
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
@@ -601,6 +1014,23 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
|
||||
|
||||
def test_network_wifi_ethernet_priority_keeps_wifi_enabled(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""End-to-end: with both WiFi and Ethernet declared under network: priority:,
|
||||
the reconciler must NOT disable the WiFi stack or coexistence (the
|
||||
multi-interface case unlocked by composing network priority with the
|
||||
sdkconfig reconciler)."""
|
||||
generate_main(component_config_path("network_wifi_ethernet_priority.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig
|
||||
# WiFi has no AP here, so SoftAP/DHCP server are still dropped.
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
|
||||
|
||||
def test_esp32_build_internals_are_yaml_only() -> None:
|
||||
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
|
||||
|
||||
@@ -679,6 +1109,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
|
||||
# V1 ECDSA: exactly one of signing key / verification key.
|
||||
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
|
||||
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
|
||||
# External RSA with a compiled-in trusted-key list (digests).
|
||||
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]},
|
||||
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]},
|
||||
],
|
||||
)
|
||||
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
|
||||
@@ -733,6 +1166,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -
|
||||
},
|
||||
"not both",
|
||||
),
|
||||
# A trusted-key list only applies to external RSA.
|
||||
(
|
||||
{"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]},
|
||||
"only used with signing scheme 'rsa3072'",
|
||||
),
|
||||
# Can't both auto-sign and verify against a fixed trusted set.
|
||||
(
|
||||
{
|
||||
"signing_scheme": "rsa3072",
|
||||
"signing_key": "key.pem",
|
||||
"verification_keys": ["ab" * 32],
|
||||
},
|
||||
"cannot be combined with",
|
||||
),
|
||||
# The singular V1 key and the RSA trusted-key list are mutually exclusive.
|
||||
(
|
||||
{
|
||||
"signing_scheme": "rsa3072",
|
||||
"verification_key": "key.bin",
|
||||
"verification_keys": ["ab" * 32],
|
||||
},
|
||||
"at most one",
|
||||
),
|
||||
# Duplicate trusted keys are rejected.
|
||||
(
|
||||
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]},
|
||||
"must be unique",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
|
||||
@@ -740,3 +1201,117 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
|
||||
|
||||
with pytest.raises(cv.Invalid, match=match):
|
||||
_validate_signed_ota_keys(config)
|
||||
|
||||
|
||||
def test_sbv2_rsa_key_digest_known_answer() -> None:
|
||||
"""The compiled-in trust anchor is the block-format digest the device
|
||||
computes per signature block; pin it to espsecure's known output for the
|
||||
shipped dummy key so a future change to the derivation can't drift silently.
|
||||
"""
|
||||
from esphome.components.esp32 import _sbv2_rsa_key_digest
|
||||
|
||||
key = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "components"
|
||||
/ "esp32"
|
||||
/ "dummy_signing_key.pem"
|
||||
)
|
||||
assert (
|
||||
_sbv2_rsa_key_digest(key).hex()
|
||||
== "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65"
|
||||
)
|
||||
|
||||
|
||||
def test_validate_trusted_key_hex_forms() -> None:
|
||||
"""The digest-input branch: the same key as an uppercase 64-hex digest
|
||||
normalizes to the PEM-derived value (the two forms are interchangeable), and
|
||||
a mangled digest fails clearly instead of as a missing file.
|
||||
"""
|
||||
from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key
|
||||
|
||||
key = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "components"
|
||||
/ "esp32"
|
||||
/ "dummy_signing_key.pem"
|
||||
)
|
||||
pem_digest = _sbv2_rsa_key_digest(key).hex()
|
||||
assert _validate_trusted_key(pem_digest.upper()) == pem_digest
|
||||
for bad in (pem_digest[:-1], "0x" + pem_digest):
|
||||
with pytest.raises(cv.Invalid, match="64 hex"):
|
||||
_validate_trusted_key(bad)
|
||||
# An unquoted 0x.../all-digit digest reaches the validator as a YAML int.
|
||||
with pytest.raises(cv.Invalid, match="Quote the digest"):
|
||||
_validate_trusted_key(0x957671F5EC1B55B3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# Full x.y.z versions are rewritten into pioarduino release URLs
|
||||
(
|
||||
"55.3.30",
|
||||
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip",
|
||||
),
|
||||
(
|
||||
"55.3.31-2",
|
||||
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip",
|
||||
),
|
||||
# Non-version values pass through untouched
|
||||
(
|
||||
"https://github.com/pioarduino/platform-espressif32.git#develop",
|
||||
"https://github.com/pioarduino/platform-espressif32.git#develop",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_pio_platform_version(value: str, expected: str) -> None:
|
||||
from esphome.components.esp32 import _parse_pio_platform_version
|
||||
|
||||
assert _parse_pio_platform_version(value) == expected
|
||||
|
||||
|
||||
def test_esp32_s31_gpio_validation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
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
|
||||
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
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S31}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# Reserved for the SPI flash interface, but can be bypassed with
|
||||
# ignore_pin_validation_error
|
||||
for num in (26, 27, 28, 30, 31, 32):
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} is reserved"):
|
||||
validate_gpio_pin(
|
||||
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
)
|
||||
pin = {CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
||||
assert validate_gpio_pin(pin)[CONF_NUMBER] == num
|
||||
|
||||
for num in (29, 41):
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
|
||||
validate_gpio_pin(
|
||||
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
)
|
||||
# Also rejected in validate_supports so ignore_pin_validation_error
|
||||
# cannot bypass it
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
|
||||
validate_supports({CONF_NUMBER: num, CONF_MODE: input_mode})
|
||||
|
||||
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
|
||||
with caplog.at_level("WARNING"):
|
||||
validate_supports(pin)
|
||||
assert "GPIO36 is a strapping PIN" in caplog.text
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
esphome:
|
||||
name: scan-window-explicit
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
window: 30ms
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: scan-window-raised
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: scan-window-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: scan-window-user-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
connection_scan_window: 20ms
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for the esp32_ble_tracker conditional scan window default.
|
||||
|
||||
The scan window default depends on wifi coexistence and the IDF version:
|
||||
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
|
||||
configured window (espressif/esp-idf#18931), so on fixed versions the
|
||||
historical 30 ms default would only listen 9.4 % of the time and miss most
|
||||
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
|
||||
window instead defaults to the interval, as Espressif recommends; without the
|
||||
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_ble_tracker import (
|
||||
CONF_SOFTWARE_COEXISTENCE,
|
||||
CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_INTERVAL, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_esp32(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> Callable[..., None]:
|
||||
"""Stage an esp32 build with a given IDF version and wifi presence."""
|
||||
|
||||
def stage(idf: str, *, wifi: bool) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
if wifi:
|
||||
# Makes cv.OnlyWith default software_coexistence to True, exactly
|
||||
# as a real config with wifi: does.
|
||||
CORE.loaded_integrations.add("wifi")
|
||||
|
||||
return stage
|
||||
|
||||
|
||||
def _scan_params(config: ConfigType) -> ConfigType:
|
||||
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("idf", "config", "expected_units"),
|
||||
[
|
||||
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
|
||||
("6.0.1", {}, 512), # any newer version behaves the same
|
||||
# Follows a user-set interval.
|
||||
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
|
||||
],
|
||||
)
|
||||
def test_wifi_on_fixed_idf_defaults_window_to_interval(
|
||||
stage_esp32: Callable[..., None],
|
||||
idf: str,
|
||||
config: ConfigType,
|
||||
expected_units: int,
|
||||
) -> None:
|
||||
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
|
||||
stage_esp32(idf, wifi=True)
|
||||
params = _scan_params(config)
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_WINDOW]) == expected_units
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("idf", "wifi", "config"),
|
||||
[
|
||||
# Buggy IDF over-scans anyway; keep the 30 ms default.
|
||||
("5.5.4", True, {}),
|
||||
# No wifi (e.g. ethernet) means no radio contention.
|
||||
("5.5.5", False, {}),
|
||||
# Coexistence disabled: no arbiter, so a full-duty scan would starve
|
||||
# wifi outright.
|
||||
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
|
||||
],
|
||||
)
|
||||
def test_30ms_default_kept(
|
||||
stage_esp32: Callable[..., None],
|
||||
idf: str,
|
||||
wifi: bool,
|
||||
config: ConfigType,
|
||||
) -> None:
|
||||
stage_esp32(idf, wifi=wifi)
|
||||
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
|
||||
|
||||
|
||||
@pytest.mark.parametrize("window", ["60ms", "30ms"])
|
||||
def test_explicit_window_is_never_touched(
|
||||
stage_esp32: Callable[..., None], window: str
|
||||
) -> None:
|
||||
"""A user-set window wins over the conditional default.
|
||||
|
||||
The explicit 30 ms case matters: it is indistinguishable from the
|
||||
defaulted value by inspection, so the defaulted flag must separate them.
|
||||
"""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({"scan_parameters": {"window": window}})
|
||||
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
|
||||
cv.positive_time_period(window)
|
||||
)
|
||||
|
||||
|
||||
def test_short_interval_without_window_still_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""The provisional 30 ms default validates against the interval as before."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_scan_params({"scan_parameters": {"interval": "20ms"}})
|
||||
|
||||
|
||||
# The connection-time fallback window: while a GATT connection is active the
|
||||
# scanner drops from a raised full-duty window back to this value so the
|
||||
# connection gets guaranteed airtime.
|
||||
|
||||
|
||||
def test_raise_arms_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
|
||||
|
||||
|
||||
def test_user_connection_scan_window_survives_raise(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
|
||||
|
||||
|
||||
def test_unraised_window_gets_no_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.4", wifi=True)
|
||||
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_interval_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_window_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window above the (post-raise) window would widen the scan
|
||||
during connections; the reject runs after the raise so a fallback below a
|
||||
raised window still validates (covered by the survives-raise test)."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params(
|
||||
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
|
||||
)
|
||||
|
||||
|
||||
def test_connection_scan_window_truncation_collapse_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window that truncates into the interval's 0.625 ms unit
|
||||
would silently program a full-duty scan during connections."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
|
||||
_scan_params(
|
||||
{
|
||||
"scan_parameters": {
|
||||
"interval": "320.5ms",
|
||||
"connection_scan_window": "320.2ms",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "window_call", "connection_call", "warns"),
|
||||
[
|
||||
# Raised window with GATT clients: the injected fallback is emitted.
|
||||
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
|
||||
# Explicit window: nothing injected.
|
||||
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
|
||||
# Scan-only build compiles the path out: the injected default is
|
||||
# dropped silently, a user-set value warns.
|
||||
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
|
||||
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
|
||||
],
|
||||
)
|
||||
def test_connection_scan_window_codegen(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
config_file: str,
|
||||
window_call: str,
|
||||
connection_call: bool,
|
||||
warns: bool,
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path(config_file))
|
||||
assert window_call in main_cpp
|
||||
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
|
||||
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wifi", "params", "expect_warning"),
|
||||
[
|
||||
(True, {"interval": "1100ms", "window": "1100ms"}, True),
|
||||
(True, {"interval": "1100ms", "window": "601ms"}, True),
|
||||
(True, {"interval": "1100ms", "window": "600ms"}, False),
|
||||
(False, {"interval": "1100ms", "window": "1100ms"}, False),
|
||||
],
|
||||
)
|
||||
def test_long_window_with_wifi_warns(
|
||||
stage_esp32: Callable[..., None],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
wifi: bool,
|
||||
params: ConfigType,
|
||||
expect_warning: bool,
|
||||
) -> None:
|
||||
"""A scan window above 600 ms warns only when wifi shares the radio."""
|
||||
stage_esp32("5.5.5", wifi=wifi)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_scan_params({"scan_parameters": params})
|
||||
assert ("starves wifi" in caplog.text) is expect_warning
|
||||
|
||||
|
||||
def test_long_window_warns_with_coexistence_disabled(
|
||||
stage_esp32: Callable[..., None],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Disabling the arbiter is the worst case for a long window, so it still warns."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_scan_params(
|
||||
{
|
||||
CONF_SOFTWARE_COEXISTENCE: False,
|
||||
"scan_parameters": {"interval": "1100ms", "window": "1100ms"},
|
||||
}
|
||||
)
|
||||
assert "BLE scan window of 1100ms" in caplog.text
|
||||
|
||||
|
||||
def test_raised_window_warning_points_at_interval(
|
||||
stage_esp32: Callable[..., None],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""When the window was raised to a long interval, the warning names the interval."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_scan_params({"scan_parameters": {"interval": "1s"}})
|
||||
assert "BLE scan interval of 1s" in caplog.text
|
||||
assert "BLE scan window of" not in caplog.text
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the esp32_hosted ESP-IDF version gate."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_hosted import _final_validate
|
||||
from esphome.const import PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
|
||||
def test_final_validate_accepts_supported_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF 5.3 and newer passes validation unchanged."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
|
||||
def test_final_validate_rejects_old_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF older than 5.3 is rejected with a clear error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
|
||||
_final_validate({})
|
||||
@@ -0,0 +1,21 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
interface: spi2
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
miso_pin: GPIO19
|
||||
|
||||
ethernet:
|
||||
id: eth_component
|
||||
type: W5500
|
||||
spi_id: spi_bus
|
||||
cs_pin: GPIO5
|
||||
interrupt_pin: GPIO36
|
||||
reset_pin: GPIO22
|
||||
clock_speed: 20MHz
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
ethernet:
|
||||
id: eth_component
|
||||
type: W5500
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
miso_pin: GPIO19
|
||||
cs_pin: GPIO5
|
||||
interrupt_pin: GPIO36
|
||||
reset_pin: GPIO22
|
||||
clock_speed: 20MHz
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for the ethernet final-validation coexistence gate and schema bounds."""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
|
||||
from esphome.components.network import _validate_priority_list
|
||||
from esphome.const import CONF_PRIORITY, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
_CH390_CONFIG = {
|
||||
"type": "CH390",
|
||||
"clk_pin": 47,
|
||||
"mosi_pin": 48,
|
||||
"miso_pin": 14,
|
||||
"cs_pin": 21,
|
||||
}
|
||||
|
||||
|
||||
def test_rejects_wifi_and_ethernet_without_priority() -> None:
|
||||
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
|
||||
fv.full_config.set({"wifi": {}, "ethernet": {}})
|
||||
with pytest.raises(Invalid, match="cannot be used together with component wifi"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
|
||||
"""A priority list missing an interface is rejected and names what's missing."""
|
||||
fv.full_config.set(
|
||||
{
|
||||
"wifi": {},
|
||||
"ethernet": {},
|
||||
"network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])},
|
||||
}
|
||||
)
|
||||
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
|
||||
def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum(
|
||||
set_core_config: SetCoreConfigCallable, clock_speed: str
|
||||
) -> None:
|
||||
"""CH390 SCK is rated to 72MHz, so the schema must accept the whole range."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
KEY_IDF_VERSION: cv.Version(5, 3, 2),
|
||||
},
|
||||
)
|
||||
# _validate derives use_address from the node name, which has no default here.
|
||||
CORE.name = "ch390-test"
|
||||
config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed})
|
||||
assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed)
|
||||
|
||||
|
||||
def test_ch390_rejects_clock_speed_above_the_datasheet_maximum(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The shared 80MHz ceiling is out of spec for this part."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
KEY_IDF_VERSION: cv.Version(5, 3, 2),
|
||||
},
|
||||
)
|
||||
# _validate derives use_address from the node name, which has no default here.
|
||||
CORE.name = "ch390-test"
|
||||
with pytest.raises(Invalid, match="value must be at most 72000000"):
|
||||
CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Tests for the ethernet `spi_id:` option (attach to a shared spi bus)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.ethernet import CONF_INTERFACE, CONFIG_SCHEMA, _final_validate
|
||||
from esphome.components.rp2.const import KEY_BOARD as RP2_KEY_BOARD
|
||||
|
||||
# Registers the rp2 pin schema so RP2 configs can validate pins.
|
||||
import esphome.components.rp2.gpio # noqa: F401
|
||||
from esphome.components.spi import CONF_INTERFACE_INDEX
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
CONF_ID,
|
||||
CONF_MISO_PIN,
|
||||
CONF_MOSI_PIN,
|
||||
CONF_SPI,
|
||||
CONF_SPI_ID,
|
||||
CONF_TYPE,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
_W5500_PIN_CONFIG = {
|
||||
"type": "W5500",
|
||||
"clk_pin": 47,
|
||||
"mosi_pin": 48,
|
||||
"miso_pin": 14,
|
||||
"cs_pin": 21,
|
||||
}
|
||||
|
||||
_W5500_SPI_ID_CONFIG = {
|
||||
"type": "W5500",
|
||||
"spi_id": "spi_bus",
|
||||
"cs_pin": 21,
|
||||
}
|
||||
|
||||
|
||||
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
KEY_IDF_VERSION: cv.Version(5, 3, 2),
|
||||
},
|
||||
)
|
||||
# _validate derives use_address from the node name, which has no default here.
|
||||
CORE.name = "spi-id-test"
|
||||
|
||||
|
||||
def test_spi_id_accepted_without_pins_or_interface(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""With spi_id set, the pin options are not required and no interface is defaulted."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
config = CONFIG_SCHEMA(dict(_W5500_SPI_ID_CONFIG))
|
||||
assert config[CONF_SPI_ID] == ID("spi_bus")
|
||||
# The interface comes from the referenced bus; no default may be injected.
|
||||
assert CONF_INTERFACE not in config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
(CONF_CLK_PIN, 47),
|
||||
(CONF_MOSI_PIN, 48),
|
||||
(CONF_MISO_PIN, 14),
|
||||
(CONF_INTERFACE, "spi2"),
|
||||
],
|
||||
)
|
||||
def test_spi_id_rejects_bus_options(
|
||||
set_core_config: SetCoreConfigCallable, key: str, value: int | str
|
||||
) -> None:
|
||||
"""Options provided by the referenced bus must be rejected alongside spi_id."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
with pytest.raises(Invalid, match=f"'{key}' cannot be used together with 'spi_id'"):
|
||||
CONFIG_SCHEMA({**_W5500_SPI_ID_CONFIG, key: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN])
|
||||
def test_bus_pins_still_required_without_spi_id(
|
||||
set_core_config: SetCoreConfigCallable, key: str
|
||||
) -> None:
|
||||
"""Without spi_id, the bus pin options stay required."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
config = {k: v for k, v in _W5500_PIN_CONFIG.items() if k != key}
|
||||
with pytest.raises(
|
||||
Invalid, match=f"'{key}' is a required option when 'spi_id' is not set"
|
||||
):
|
||||
CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def test_spi_id_rejected_on_rp2(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""spi_id is ESP32-only; the RP2 path is unchanged."""
|
||||
set_core_config(
|
||||
PlatformFramework.RP2_ARDUINO, platform_data={RP2_KEY_BOARD: "rpipicow"}
|
||||
)
|
||||
CORE.name = "spi-id-test"
|
||||
config = {
|
||||
"type": "W5500",
|
||||
"spi_id": "spi_bus",
|
||||
"clk_pin": 18,
|
||||
"mosi_pin": 19,
|
||||
"miso_pin": 16,
|
||||
"cs_pin": 17,
|
||||
}
|
||||
with pytest.raises(Invalid, match="only available on"):
|
||||
CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def _eth_spi_id_final_config() -> dict:
|
||||
return {CONF_TYPE: "W5500", CONF_SPI_ID: ID("spi_bus")}
|
||||
|
||||
|
||||
class _FakeFinalConfig(dict):
|
||||
"""Dict-backed FinalValidateConfig with just enough ID resolution for
|
||||
fv.id_declaration_match_schema to find an spi bus fragment."""
|
||||
|
||||
def get_path_for_id(self, id: ID) -> list:
|
||||
for index, conf in enumerate(self[CONF_SPI]):
|
||||
if conf[CONF_ID] == id:
|
||||
return [CONF_SPI, index, CONF_ID]
|
||||
raise KeyError(id)
|
||||
|
||||
def get_config_for_path(self, path: list) -> dict:
|
||||
return self[path[0]][path[1]]
|
||||
|
||||
|
||||
def _set_spi_buses(*buses: dict) -> None:
|
||||
fv.full_config.set(_FakeFinalConfig({CONF_SPI: list(buses)}))
|
||||
|
||||
|
||||
_SHAREABLE_BUS = {
|
||||
CONF_ID: ID("spi_bus"),
|
||||
CONF_INTERFACE_INDEX: 0,
|
||||
CONF_MISO_PIN: {},
|
||||
CONF_MOSI_PIN: {},
|
||||
}
|
||||
|
||||
|
||||
def test_final_validate_accepts_hardware_bus_with_data_pins(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A hardware spi bus that declares miso_pin and mosi_pin may be shared."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
# An unrelated bus first: the ID lookup must skip past it.
|
||||
_set_spi_buses({CONF_ID: ID("other_bus"), CONF_INTERFACE_INDEX: 1}, _SHAREABLE_BUS)
|
||||
_final_validate(_eth_spi_id_final_config())
|
||||
|
||||
|
||||
def test_final_validate_rejects_software_bus(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A software spi bus (no hardware interface index) cannot be shared."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != CONF_INTERFACE_INDEX}
|
||||
_set_spi_buses(bus)
|
||||
with pytest.raises(Invalid, match="requires this spi bus to use a hardware"):
|
||||
_final_validate(_eth_spi_id_final_config())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pin_key", [CONF_MISO_PIN, CONF_MOSI_PIN])
|
||||
def test_final_validate_rejects_bus_without_data_pin(
|
||||
set_core_config: SetCoreConfigCallable, pin_key: str
|
||||
) -> None:
|
||||
"""The shared bus must declare both data pins to drive the ethernet chip."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != pin_key}
|
||||
_set_spi_buses(bus)
|
||||
with pytest.raises(Invalid, match=f"requires this spi bus to declare a {pin_key}"):
|
||||
_final_validate(_eth_spi_id_final_config())
|
||||
|
||||
|
||||
def test_final_validate_rejects_colliding_host_without_spi_id(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Without spi_id, claiming the same host as an spi bus stays an error."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
|
||||
config = {CONF_TYPE: "W5500", CONF_INTERFACE: "spi2"}
|
||||
with pytest.raises(Invalid, match="both using interface 'SPI2_HOST'"):
|
||||
_final_validate(config)
|
||||
|
||||
|
||||
def test_final_validate_accepts_distinct_host_without_spi_id(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Without spi_id, a different host than the spi bus is accepted."""
|
||||
_set_esp32_s3(set_core_config)
|
||||
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
|
||||
_final_validate({CONF_TYPE: "W5500", CONF_INTERFACE: "spi3"})
|
||||
|
||||
|
||||
def test_generated_code_uses_spi_parent(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""With spi_id, codegen wires the spi parent and skips the bus options."""
|
||||
main_cpp = generate_main(component_config_path("spi_id_shared_bus.yaml"))
|
||||
|
||||
assert "eth_component->set_spi_parent(spi_bus);" in main_cpp
|
||||
assert "eth_component->set_cs_pin(5);" in main_cpp
|
||||
assert "eth_component->set_clk_pin(" not in main_cpp
|
||||
assert "eth_component->set_miso_pin(" not in main_cpp
|
||||
assert "eth_component->set_mosi_pin(" not in main_cpp
|
||||
assert "eth_component->set_interface(" not in main_cpp
|
||||
|
||||
|
||||
def test_generated_code_without_spi_id_initializes_own_bus(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Without spi_id, codegen still emits the pin and interface setters."""
|
||||
main_cpp = generate_main(component_config_path("spi_own_bus.yaml"))
|
||||
|
||||
assert "eth_component->set_spi_parent(" not in main_cpp
|
||||
assert "eth_component->set_clk_pin(18);" in main_cpp
|
||||
assert "eth_component->set_miso_pin(19);" in main_cpp
|
||||
assert "eth_component->set_mosi_pin(23);" in main_cpp
|
||||
assert "eth_component->set_cs_pin(5);" in main_cpp
|
||||
assert "eth_component->set_interface(::SPI3_HOST);" in main_cpp
|
||||
@@ -1,16 +1,21 @@
|
||||
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
|
||||
"""Tests for the external_components config pass."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.external_components import do_external_components_pass
|
||||
from esphome.const import (
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_PATH,
|
||||
CONF_REFRESH,
|
||||
CONF_SOURCE,
|
||||
CONF_URL,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE, TimePeriodSeconds
|
||||
|
||||
@@ -69,3 +74,112 @@ def test_external_components_normal_refresh(
|
||||
mock_clone_or_update.assert_called_once()
|
||||
call_args = mock_clone_or_update.call_args
|
||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||
|
||||
|
||||
def test_external_components_logs_built_in_override(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that provides a component with the same name as a built-in one logs an info message."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
for name in ("gpio", "some_custom_component"):
|
||||
component_dir = tmp_path / "components" / name
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert (
|
||||
"External components are overriding built-in components:\n"
|
||||
" source: https://github.com/test/components\n"
|
||||
" components: gpio" in caplog.text
|
||||
)
|
||||
assert "some_custom_component" not in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_ref(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a ref logs the ref appended to the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components.git@main\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_git_path(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a subdirectory path logs the path after the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components (components)\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_local_source(
|
||||
tmp_path: Path,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A local source logs its resolved path."""
|
||||
components_dir = tmp_path / "my_components"
|
||||
gpio_dir = components_dir / "gpio"
|
||||
gpio_dir.mkdir(parents=True)
|
||||
(gpio_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
CORE.config_path = tmp_path / "dummy.yaml"
|
||||
config = {
|
||||
CONF_EXTERNAL_COMPONENTS: [
|
||||
{CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert f" source: {components_dir}\n" in caplog.text
|
||||
assert " components: gpio" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_no_override_no_log(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that only provides components not shipped with ESPHome logs nothing."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert "are overriding built-in components" not in caplog.text
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT"
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_basic_setup(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
@@ -69,3 +74,62 @@ def test_gpio_binary_sensor_explicit_polling_mode(
|
||||
)
|
||||
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_interrupt_emits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
An interrupt-mode sensor must emit the define that compiles the ISR code,
|
||||
since the platform ISR pin implementation is only built when needed
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_polling_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
A polling-only config must not emit the interrupt define, so the ISR code
|
||||
(and its reference to ISRInternalGPIOPin) is compiled out
|
||||
"""
|
||||
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml")
|
||||
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_mixed_modes_emit_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""
|
||||
With one interrupt and one polling sensor, the define is emitted and the
|
||||
polling instance still opts out via its setter
|
||||
"""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml"
|
||||
)
|
||||
|
||||
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
|
||||
assert "bs_polling->set_use_interrupt(false);" in main_cpp
|
||||
assert "bs_interrupt->set_use_interrupt" not in main_cpp
|
||||
|
||||
|
||||
def test_gpio_binary_sensor_expander_pin_omits_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
An expander pin can't use interrupts: final validation falls back to
|
||||
polling and the interrupt define must not be emitted. This is the config
|
||||
that fails to link if the ISR code is compiled without an internal pin
|
||||
"""
|
||||
with caplog.at_level(logging.INFO):
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml"
|
||||
)
|
||||
|
||||
assert "bs_expander->set_use_interrupt(false);" in main_cpp
|
||||
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
|
||||
assert "falling back to polling mode" in caplog.text
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2c:
|
||||
scl: 16
|
||||
sda: 17
|
||||
|
||||
ch422g:
|
||||
- id: ch422g_hub
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
name: "Expander Sensor"
|
||||
id: bs_expander
|
||||
pin:
|
||||
ch422g: ch422g_hub
|
||||
number: 1
|
||||
mode: INPUT
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
pin: 5
|
||||
name: "Interrupt Sensor"
|
||||
id: bs_interrupt
|
||||
|
||||
- platform: gpio
|
||||
pin: 4
|
||||
name: "Polling Sensor"
|
||||
id: bs_polling
|
||||
use_interrupt: false
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the shared io expander interrupt_pin validator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.gpio_expander import validate_interrupt_pin
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_esp32(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
|
||||
def test_plain_pin_accepted(stage_esp32: None) -> None:
|
||||
value = validate_interrupt_pin(
|
||||
{"number": 16, "mode": {"input": True, "pullup": True}}
|
||||
)
|
||||
assert value["number"] == 16
|
||||
|
||||
|
||||
def test_inverted_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "inverted": True})
|
||||
|
||||
|
||||
def test_allow_other_uses_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "allow_other_uses": True})
|
||||
|
||||
|
||||
# mcp23017 covers the shared mcp23xxx_base schema
|
||||
@pytest.mark.parametrize(
|
||||
"component",
|
||||
[
|
||||
"pcf8574",
|
||||
"pca9554",
|
||||
"tca9555",
|
||||
"pca6416a",
|
||||
"pi4ioe5v6408",
|
||||
"mcp23016",
|
||||
"mcp23017",
|
||||
],
|
||||
)
|
||||
def test_component_schemas_route_through_validator(
|
||||
stage_esp32: None, component: str
|
||||
) -> None:
|
||||
module = importlib.import_module(f"esphome.components.{component}")
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
module.CONFIG_SCHEMA(
|
||||
{"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}}
|
||||
)
|
||||
@@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""The cache path is derived from (and stable for) the URL."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
|
||||
first = gsl._cache_path(VALID_URL)
|
||||
assert first == gsl._cache_path(VALID_URL)
|
||||
assert first != gsl._cache_path("https://example.com/other.bin")
|
||||
assert first.parent == tmp_path
|
||||
assert first.parent == tmp_path / "gsl3670"
|
||||
|
||||
|
||||
def test_firmware_path_prefers_local_file(tmp_path: Path) -> None:
|
||||
@@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A ``url`` source resolves to the cache path for that URL."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
|
||||
assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL)
|
||||
|
||||
|
||||
@@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates(
|
||||
) -> None:
|
||||
"""A url source downloads the content and validates its structure."""
|
||||
data = _make_firmware()
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
|
||||
assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL}
|
||||
|
||||
@@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected(
|
||||
) -> None:
|
||||
"""A configured SHA-256 that does not match the download is rejected."""
|
||||
data = _make_firmware()
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
|
||||
with pytest.raises(cv.Invalid, match="SHA-256 mismatch"):
|
||||
gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32})
|
||||
@@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Downloaded content that is not a valid blob is rejected."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02"
|
||||
)
|
||||
@@ -254,6 +244,31 @@ def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None:
|
||||
assert CONF_RESET_PIN in result
|
||||
|
||||
|
||||
def test_config_guition_model_applies_defaults(tmp_path: Path) -> None:
|
||||
"""The GUITION model populates transform and calibration defaults."""
|
||||
fw = _write_firmware(tmp_path)
|
||||
result = gsl.CONFIG_SCHEMA(
|
||||
{
|
||||
"model": "guition-jc8012p4a1",
|
||||
"firmware": {"file": str(fw)},
|
||||
}
|
||||
)
|
||||
assert result[CONF_MODEL] == "GUITION-JC8012P4A1"
|
||||
# Transform defaults from the model.
|
||||
assert result[CONF_TRANSFORM] == {
|
||||
"swap_xy": True,
|
||||
"mirror_x": True,
|
||||
"mirror_y": False,
|
||||
}
|
||||
# Calibration defaults from the model.
|
||||
assert result[CONF_CALIBRATION]["x_min"] == 20
|
||||
assert result[CONF_CALIBRATION]["x_max"] == 880
|
||||
assert result[CONF_CALIBRATION]["y_min"] == 20
|
||||
assert result[CONF_CALIBRATION]["y_max"] == 1648
|
||||
assert result[CONF_INTERRUPT_PIN]["number"] == 21
|
||||
assert result[CONF_RESET_PIN]["number"] == 22
|
||||
|
||||
|
||||
def test_config_rejects_non_dict() -> None:
|
||||
"""A non-dict configuration is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="expected a dictionary"):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests for the heatpumpir climate config validation."""
|
||||
|
||||
from esphome.components.heatpumpir.climate import _default_visual
|
||||
from esphome.const import CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, CONF_VISUAL
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def test_default_visual_seeds_from_required_min_max() -> None:
|
||||
"""Without a visual block, the required min/max_temperature seed the visual
|
||||
range so the entity reports it in Home Assistant instead of 0-100 (#17983)."""
|
||||
config: ConfigType = {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30}
|
||||
_default_visual(config)
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18
|
||||
assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30
|
||||
|
||||
|
||||
def test_default_visual_keeps_explicit() -> None:
|
||||
"""An explicit visual min/max is not overwritten by the required temps."""
|
||||
config: ConfigType = {
|
||||
CONF_MIN_TEMPERATURE: 16,
|
||||
CONF_MAX_TEMPERATURE: 32,
|
||||
CONF_VISUAL: {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30},
|
||||
}
|
||||
_default_visual(config)
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18
|
||||
assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30
|
||||
@@ -27,3 +27,19 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int:
|
||||
match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp)
|
||||
assert match, f"configure call not found for {var_name}"
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def get_define_value(name: str) -> str | None:
|
||||
"""Rendered value of a CORE define, or None when absent.
|
||||
|
||||
Values are codegen expressions (IntLiteral); they are compared rendered.
|
||||
A value-less define (e.g. USE_BK72XX_BLE) is present but renders as the
|
||||
string "None", while an absent define returns the None object — easy to
|
||||
conflate in assertions, so use this helper for valued defines only.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
for define in CORE.defines:
|
||||
if define.name == name:
|
||||
return str(define.value)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
watchdog_timeout: 20s
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
watchdog_timeout: 60s
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
http_request:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user