mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 09:20:22 +00:00
Merge branch 'dev' into jesserockz-2026-503
This commit is contained in:
@@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None:
|
||||
|
||||
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
|
||||
then ``update_storage_json`` writes both the StorageJSON sidecar and
|
||||
the ``.validated.yaml`` compiled-config cache.
|
||||
the ``.validated.json`` compiled-config cache.
|
||||
"""
|
||||
CORE.config_path = yaml_path
|
||||
config = read_config({}, skip_external_update=True)
|
||||
|
||||
@@ -32,9 +32,10 @@ class SerialProxy {
|
||||
api::enums::SerialProxyPortType get_port_type() const { return {}; }
|
||||
api::APIConnection *get_api_connection() { return nullptr; }
|
||||
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
|
||||
void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {}
|
||||
void write_from_client(const uint8_t *data, size_t len) {}
|
||||
void set_modem_pins(uint32_t line_states) {}
|
||||
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
|
||||
uint32_t stop_bits, uint32_t data_size) {}
|
||||
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
|
||||
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
|
||||
uint32_t get_modem_pins() const { return 0; }
|
||||
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
|
||||
|
||||
|
||||
@@ -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,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: generic-bk7252
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: slotcount-tracker
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
|
||||
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"
|
||||
+64
-14
@@ -1,16 +1,24 @@
|
||||
"""Tests for bk72xx_ble_tracker scan parameter validation."""
|
||||
"""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, to_ble_units
|
||||
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) -> dict:
|
||||
"""Run a scan_parameters config through the schema, applying defaults."""
|
||||
return SCAN_PARAMETERS_SCHEMA(dict(kwargs))
|
||||
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 ---
|
||||
@@ -36,14 +44,55 @@ def test_to_ble_units_truncates() -> None:
|
||||
assert to_ble_units(cv.positive_time_period("2500us")) == 4
|
||||
|
||||
|
||||
# --- accepted configurations ---
|
||||
# --- the real per-chip schemas ---
|
||||
|
||||
|
||||
def test_defaults_are_valid() -> None:
|
||||
"""The documented default 100 ms / 30 ms pair validates."""
|
||||
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)."""
|
||||
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:
|
||||
@@ -75,6 +124,11 @@ def test_window_equal_to_interval_accepted() -> None:
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -100,12 +154,8 @@ def test_out_of_range_rejected(interval: str, window: str, offender: str) -> Non
|
||||
|
||||
|
||||
def test_unit_collapse_rejected() -> None:
|
||||
"""Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.
|
||||
|
||||
This is the configuration that previously validated and programmed the radio
|
||||
permanently on despite asking for roughly 83 %.
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"):
|
||||
"""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")
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -462,3 +462,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,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,11 +10,16 @@ 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,
|
||||
_ota_downgrade_protection_errors,
|
||||
_reconcile_network_sdkconfig,
|
||||
_reconcile_vfs_fatfs_sdkconfig,
|
||||
)
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_ESP32,
|
||||
@@ -108,6 +113,24 @@ 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})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "error_match"),
|
||||
[
|
||||
@@ -252,6 +275,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 +524,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 +619,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],
|
||||
@@ -594,6 +810,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
|
||||
@@ -696,6 +913,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:
|
||||
@@ -750,6 +970,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:
|
||||
@@ -759,6 +1007,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
|
||||
_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"),
|
||||
[
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
"""Tests for the ethernet final-validation coexistence gate."""
|
||||
"""Tests for the ethernet final-validation coexistence gate and schema bounds."""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.ethernet import _final_validate
|
||||
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
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_full_config():
|
||||
@@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
|
||||
)
|
||||
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,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,216 @@
|
||||
"""Tests for the ld6002b validators that reach across platforms.
|
||||
|
||||
wake needs a pin on its own hub, apply_area needs a select on its own hub, and
|
||||
area_config needs both a button and a select on its own hub. Every one of them
|
||||
is a same-instance check, which is the half that breaks quietly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.ld6002b.button import (
|
||||
CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA,
|
||||
)
|
||||
from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN
|
||||
from esphome.components.ld6002b.number import (
|
||||
CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA,
|
||||
)
|
||||
from esphome.config import Config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AREA_ID,
|
||||
CONF_BUTTON,
|
||||
CONF_ID,
|
||||
CONF_WAKEUP_PIN,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
HUB_ID = "ld6002b_hub"
|
||||
OTHER_HUB_ID = "ld6002b_other"
|
||||
|
||||
|
||||
def _full_config(
|
||||
hub: ConfigType,
|
||||
*,
|
||||
selects: list[ConfigType] | None = None,
|
||||
buttons: list[ConfigType] | None = None,
|
||||
) -> Config:
|
||||
"""A full config carrying one ld6002b hub, as the ID pass leaves it.
|
||||
|
||||
final_validate resolves the hub through get_path_for_id, so the declaring
|
||||
path has to be registered the way validate_config registers it: the path of
|
||||
the id value itself, whose parent is the hub's own config.
|
||||
|
||||
The platform lists are what the cross-platform validators scan, so a test can
|
||||
say which of them exist and which hub each one names.
|
||||
"""
|
||||
full = Config()
|
||||
full["ld6002b"] = [hub]
|
||||
full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID]))
|
||||
if selects is not None:
|
||||
full["select"] = selects
|
||||
if buttons is not None:
|
||||
full[CONF_BUTTON] = buttons
|
||||
return full
|
||||
|
||||
|
||||
def _hub(*, wakeup_pin: bool) -> ConfigType:
|
||||
hub: ConfigType = {CONF_ID: ID(HUB_ID, is_declaration=True, type="ld6002b")}
|
||||
if wakeup_pin:
|
||||
hub[CONF_WAKEUP_PIN] = {"number": 4}
|
||||
return hub
|
||||
|
||||
|
||||
def _buttons(**buttons: str) -> ConfigType:
|
||||
"""A button platform config naming the given buttons on the shared hub."""
|
||||
config: ConfigType = {
|
||||
"ld6002b_id": ID(HUB_ID, is_declaration=False, type="ld6002b")
|
||||
}
|
||||
config.update({key: {"name": name} for key, name in buttons.items()})
|
||||
return config
|
||||
|
||||
|
||||
def _select(*, hub_id: str = HUB_ID) -> ConfigType:
|
||||
"""A select platform config naming area_id on the given hub."""
|
||||
return {
|
||||
"ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"),
|
||||
CONF_AREA_ID: {"name": "Area ID"},
|
||||
}
|
||||
|
||||
|
||||
def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType:
|
||||
"""A number platform config carrying one area_config bound."""
|
||||
return {
|
||||
"ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"),
|
||||
CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}},
|
||||
}
|
||||
|
||||
|
||||
def _validated(config: ConfigType) -> ConfigType:
|
||||
"""Run the button schema, then the final validation the hub is checked in."""
|
||||
config = BUTTON_CONFIG_SCHEMA(config)
|
||||
BUTTON_FINAL_VALIDATE_SCHEMA(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validated_numbers(config: ConfigType) -> ConfigType:
|
||||
"""The same two passes for the number platform."""
|
||||
config = NUMBER_CONFIG_SCHEMA(config)
|
||||
NUMBER_FINAL_VALIDATE_SCHEMA(config)
|
||||
return config
|
||||
|
||||
|
||||
def test_wake_without_wakeup_pin_is_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The wake button drives the pin directly, so a hub without one cannot serve it."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
|
||||
)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="wake requires wakeup_pin"):
|
||||
_validated(_buttons(wake="Wake"))
|
||||
|
||||
|
||||
def test_wake_with_wakeup_pin_passes(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=True))
|
||||
)
|
||||
|
||||
_validated(_buttons(wake="Wake"))
|
||||
|
||||
|
||||
def test_other_buttons_do_not_need_the_pin(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Only wake drives the pin; the query buttons stay usable without one."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
|
||||
)
|
||||
|
||||
_validated(_buttons(get_delay="Get Delay"))
|
||||
|
||||
|
||||
def test_apply_area_without_select_is_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""apply_area sends the staged bounds to whichever area the select names."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=(
|
||||
r"^apply_area requires select\.area_id for the same ld6002b instance"
|
||||
r" @ data\['apply_area'\]$"
|
||||
),
|
||||
):
|
||||
_validated(_buttons(apply_area="Apply Area"))
|
||||
|
||||
|
||||
def test_apply_area_select_on_another_hub_is_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A select exists, but on a second ld6002b -- which cannot serve this one."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
full_config=_full_config(
|
||||
_hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)]
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=(
|
||||
r"^apply_area requires select\.area_id for the same ld6002b instance"
|
||||
r" @ data\['apply_area'\]$"
|
||||
),
|
||||
):
|
||||
_validated(_buttons(apply_area="Apply Area"))
|
||||
|
||||
|
||||
def test_area_config_without_apply_area_is_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The six numbers only stage a write; apply_area is what sends it."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=(
|
||||
r"^area_config requires button\.apply_area for the same ld6002b instance"
|
||||
r" @ data\['area_config'\]$"
|
||||
),
|
||||
):
|
||||
_validated_numbers(_area_numbers())
|
||||
|
||||
|
||||
def test_area_config_without_select_is_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The validator's other half: the staged bounds also need an area to land in."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
full_config=_full_config(
|
||||
_hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")]
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=(
|
||||
r"^area_config requires select\.area_id for the same ld6002b instance"
|
||||
r" @ data\['area_config'\]$"
|
||||
),
|
||||
):
|
||||
_validated_numbers(_area_numbers())
|
||||
@@ -0,0 +1,44 @@
|
||||
esphome:
|
||||
name: ln-trigger-codegen
|
||||
on_boot:
|
||||
then:
|
||||
- ln882h_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
# Bare form: restores the configured scan_parameters mode — no
|
||||
# set_continuous emitted (asserted in the codegen test).
|
||||
- ln882h_ble_tracker.start_scan:
|
||||
- ln882h_ble_tracker.stop_scan:
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882h
|
||||
|
||||
ln882h_ble_tracker:
|
||||
scan_parameters:
|
||||
continuous: 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,55 @@
|
||||
"""Codegen tests for the tracker automations: the generated main is the
|
||||
automated check on the setter calls and the listener accounting (the
|
||||
test.ln882x-ard.yaml compile fixture proves linkage, not codegen shape)."""
|
||||
|
||||
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
|
||||
# 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,60 @@
|
||||
esphome:
|
||||
name: test-set-z-index
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- label:
|
||||
id: label_a
|
||||
text: "A"
|
||||
- label:
|
||||
id: label_b
|
||||
text: "B"
|
||||
- button:
|
||||
id: trigger_btn
|
||||
on_click:
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: top
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: bottom
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: up
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: down
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: 3
|
||||
- lvgl.widget.set_z_index:
|
||||
id: label_a
|
||||
position: -2
|
||||
- lvgl.widget.set_z_index:
|
||||
id: [label_a, label_b]
|
||||
position: up
|
||||
@@ -169,14 +169,27 @@ class TestTimingSchema:
|
||||
def test_round_trip_string(self) -> None:
|
||||
assert TIMING_SCHEMA("round_trip")["type"] == "round_trip"
|
||||
|
||||
def test_round_trip_default_pause(self) -> None:
|
||||
# Back-compat default: no pause, matching the pre-existing round_trip behavior.
|
||||
assert TIMING_SCHEMA("round_trip")["pause"] == pytest.approx(0.0)
|
||||
|
||||
def test_round_trip_pause_percentage_string(self) -> None:
|
||||
result = TIMING_SCHEMA({"type": "round_trip", "pause": "50%"})
|
||||
assert result["pause"] == pytest.approx(0.5)
|
||||
|
||||
def test_round_trip_pause_rejects_one(self) -> None:
|
||||
# pause == 1.0 would make moving_length_ zero and divide by zero in map_progress.
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
TIMING_SCHEMA({"type": "round_trip", "pause": 1.0})
|
||||
|
||||
def test_ease_in_out_default_weight(self) -> None:
|
||||
result = TIMING_SCHEMA("ease_in_out")
|
||||
assert result["type"] == "ease_in_out"
|
||||
assert result["weight"] == pytest.approx(2.0)
|
||||
assert result["weight"] == pytest.approx(1.0)
|
||||
|
||||
def test_ease_in_out_custom_weight(self) -> None:
|
||||
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3})
|
||||
assert result["weight"] == pytest.approx(3.0)
|
||||
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5})
|
||||
assert result["weight"] == pytest.approx(0.5)
|
||||
|
||||
def test_gravity_defaults(self) -> None:
|
||||
result = TIMING_SCHEMA("gravity")
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for the ``lvgl.widget.set_z_index`` action: schema validation and
|
||||
code generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl.automation import SET_Z_INDEX_SCHEMA
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetZIndexSchemaValidation:
|
||||
"""Test that SET_Z_INDEX_SCHEMA accepts the documented forms and rejects
|
||||
everything else.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("position", ["top", "bottom", "up", "down"])
|
||||
def test_keyword_position_accepted(self, position: str) -> None:
|
||||
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
|
||||
assert config["position"] == position.upper()
|
||||
|
||||
@pytest.mark.parametrize("position", ["Top", "BOTTOM", "Up", "dOwN"])
|
||||
def test_keyword_position_case_insensitive(self, position: str) -> None:
|
||||
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
|
||||
assert config["position"] == position.upper()
|
||||
|
||||
@pytest.mark.parametrize("position", [0, 1, 5, -1, -5])
|
||||
def test_integer_position_accepted(self, position: int) -> None:
|
||||
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
|
||||
assert config["position"] == position
|
||||
|
||||
def test_unknown_keyword_rejected(self) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "sideways"})
|
||||
|
||||
def test_float_position_rejected(self) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": 1.5})
|
||||
|
||||
def test_missing_id_rejected(self) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
SET_Z_INDEX_SCHEMA({"position": "top"})
|
||||
|
||||
def test_missing_position_rejected(self) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
SET_Z_INDEX_SCHEMA({"id": "my_widget"})
|
||||
|
||||
def test_single_id_is_wrapped_in_list(self) -> None:
|
||||
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "top"})
|
||||
assert len(config["id"]) == 1
|
||||
assert config["id"][0]["id"].id == "my_widget"
|
||||
|
||||
def test_list_of_ids_accepted(self) -> None:
|
||||
config = SET_Z_INDEX_SCHEMA({"id": ["widget_a", "widget_b"], "position": "top"})
|
||||
assert [entry["id"].id for entry in config["id"]] == ["widget_a", "widget_b"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
"""Generate the C++ output for the shared set_z_index YAML config once
|
||||
per module. See ``test_widget_state.py`` for why this is module-scoped
|
||||
and self-contained rather than using the function-scoped ``generate_main``
|
||||
fixture from ``conftest.py``.
|
||||
"""
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
config_path = Path(request.fspath).parent / "config" / "set_z_index_test.yaml"
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_global_section + CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_top_emits_move_foreground(main_cpp: str) -> None:
|
||||
assert "lv_obj_move_foreground(label_a);" in main_cpp
|
||||
|
||||
|
||||
def test_bottom_emits_move_background(main_cpp: str) -> None:
|
||||
assert "lv_obj_move_background(label_a);" in main_cpp
|
||||
|
||||
|
||||
def test_up_emits_unguarded_index_increment(main_cpp: str) -> None:
|
||||
assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);" in main_cpp
|
||||
|
||||
|
||||
def test_down_emits_guarded_index_decrement(main_cpp: str) -> None:
|
||||
"""``down`` must be guarded so that a widget already at index 0 isn't
|
||||
reinterpreted by LVGL as "move to the top" (LVGL treats a negative
|
||||
index as "count from the back").
|
||||
"""
|
||||
assert "if (lv_obj_get_index(label_a) > 0) {" in main_cpp
|
||||
assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) - 1);" in main_cpp
|
||||
|
||||
|
||||
def test_positive_integer_emits_direct_index(main_cpp: str) -> None:
|
||||
assert "lv_obj_move_to_index(label_a, 3);" in main_cpp
|
||||
|
||||
|
||||
def test_negative_integer_emits_direct_index(main_cpp: str) -> None:
|
||||
assert "lv_obj_move_to_index(label_a, -2);" in main_cpp
|
||||
|
||||
|
||||
def test_list_of_ids_applies_to_each_widget(main_cpp: str) -> None:
|
||||
"""``id: [label_a, label_b]`` must emit the move call once per widget."""
|
||||
assert (
|
||||
main_cpp.count("lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);")
|
||||
== 2
|
||||
)
|
||||
assert "lv_obj_move_to_index(label_b, lv_obj_get_index(label_b) + 1);" in main_cpp
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
mitsubishi_cn105:
|
||||
id: ac_hub
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
mitsubishi_cn105_id: ac_hub
|
||||
name: AC
|
||||
current_temperature_min_interval: 30s
|
||||
uart_id: uart_bus
|
||||
update_interval: 10s
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tests for Mitsubishi CN105 climate configuration migration diagnostics."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.mitsubishi_cn105 import climate
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.yaml_util import load_yaml
|
||||
|
||||
|
||||
def test_top_level_hub_rejects_leftover_legacy_climate_keys(
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
config = load_yaml(
|
||||
component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml")
|
||||
)
|
||||
CORE.raw_config = config
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
climate.CONFIG_SCHEMA(config["climate"][0])
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "'current_temperature_min_interval'" in message
|
||||
assert "'uart_id'" in message
|
||||
assert "'update_interval'" in message
|
||||
assert "top-level 'mitsubishi_cn105:' block" in message
|
||||
assert "'telemetry_request_min_interval'" in message
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for modbus configuration validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus
|
||||
from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address
|
||||
from esphome.const import CONF_ADDRESS
|
||||
|
||||
|
||||
def test_server_address_accepts_valid_unit_address() -> None:
|
||||
# A normal unit address (1-247) is accepted and returned as an int.
|
||||
assert _validate_server_address(1) == 1
|
||||
assert _validate_server_address(247) == 247
|
||||
|
||||
|
||||
def test_server_address_accepts_hex_string() -> None:
|
||||
# hex_uint8_t parses hex strings, and the validator returns the parsed int.
|
||||
assert _validate_server_address("0x10") == 0x10
|
||||
|
||||
|
||||
def test_server_address_zero_rejected() -> None:
|
||||
# Address 0 is the Modbus broadcast address and cannot identify a server device.
|
||||
with pytest.raises(cv.Invalid, match="broadcast address"):
|
||||
_validate_server_address(0)
|
||||
|
||||
|
||||
def test_server_schema_rejects_address_zero() -> None:
|
||||
# The server-role schema wires in _validate_server_address, so address 0 is rejected there too.
|
||||
schema = modbus.modbus_device_schema(0x01, role="server")
|
||||
with pytest.raises(cv.Invalid, match="broadcast address"):
|
||||
schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})
|
||||
|
||||
|
||||
def test_client_schema_still_accepts_address_zero() -> None:
|
||||
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
|
||||
# stalls the hub for the full send-wait.
|
||||
schema = modbus.modbus_device_schema(0x01)
|
||||
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for modbus_client configuration validation.
|
||||
|
||||
Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions
|
||||
guard is a safety property: these tests pin it to every handler slot.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus_client
|
||||
from esphome.components.modbus_client import (
|
||||
CONF_ON_NO_RESPONSE,
|
||||
CONF_ON_NOT_SENT,
|
||||
CONF_ON_SENT,
|
||||
CONF_PDU,
|
||||
CONFIG_SCHEMA,
|
||||
MODBUS_CLIENT_SEND_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
# Every handler slot on modbus_client.send. All five must reject deferring actions.
|
||||
HANDLER_KEYS = [
|
||||
CONF_ON_SENT,
|
||||
CONF_ON_RESPONSE,
|
||||
CONF_ON_ERROR,
|
||||
CONF_ON_NO_RESPONSE,
|
||||
CONF_ON_NOT_SENT,
|
||||
]
|
||||
|
||||
# A deferring action (registered synchronous=False) and a synchronous one, for contrast.
|
||||
DEFERRING_ACTION = {"delay": "1s"}
|
||||
SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')}
|
||||
TRUE_CONDITION = {"lambda": Lambda("return true;")}
|
||||
|
||||
# The same deferring action buried inside nested control flow, which the guard must still find.
|
||||
NESTED_ACTIONS = [
|
||||
pytest.param(
|
||||
[{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}],
|
||||
id="if",
|
||||
),
|
||||
pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"),
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"repeat": {
|
||||
"count": 2,
|
||||
"then": [
|
||||
{
|
||||
"if": {
|
||||
"condition": TRUE_CONDITION,
|
||||
"then": [DEFERRING_ACTION],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
id="repeat_if",
|
||||
),
|
||||
]
|
||||
|
||||
DEFER_MESSAGE = "Deferring actions"
|
||||
|
||||
|
||||
def _config(handler_key: str, actions: list) -> ConfigType:
|
||||
"""A minimal valid modbus_client.send config with one handler populated."""
|
||||
return {
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
||||
handler_key: {"then": actions},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
||||
def test_synchronous_handler_accepted(handler_key: str) -> None:
|
||||
# The guard must not get in the way of an ordinary inline handler.
|
||||
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
||||
def test_deferring_action_rejected(handler_key: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
|
||||
@pytest.mark.parametrize("actions", NESTED_ACTIONS)
|
||||
def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None:
|
||||
# has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught.
|
||||
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions))
|
||||
|
||||
|
||||
def test_on_no_response_lambda_form_accepted() -> None:
|
||||
# The returning-lambda form has no action list; the guard is a no-op on it.
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
||||
CONF_ON_NO_RESPONSE: Lambda("return false;"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_on_no_response_retry_lambda_accepted() -> None:
|
||||
# The automation form may also carry a nested retry: lambda.
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
||||
CONF_ON_NO_RESPONSE: {
|
||||
"then": [SYNCHRONOUS_ACTION],
|
||||
"retry": Lambda("return true;"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
|
||||
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
|
||||
# components rather than this one.
|
||||
|
||||
|
||||
def test_component_requires_an_address() -> None:
|
||||
"""The address identifies the device on the bus, so there is no sensible default."""
|
||||
with pytest.raises(cv.Invalid, match=CONF_ADDRESS):
|
||||
CONFIG_SCHEMA({CONF_ID: "bare_client"})
|
||||
|
||||
|
||||
def test_component_requires_an_id() -> None:
|
||||
"""The device is reachable only through id() in a lambda, so a generated id would be dead config."""
|
||||
with pytest.raises(cv.Invalid, match=CONF_ID):
|
||||
CONFIG_SCHEMA({CONF_ADDRESS: 0x01})
|
||||
|
||||
|
||||
def test_component_accepts_an_id_and_address() -> None:
|
||||
"""modbus_id stays optional: it resolves to the single hub when only one is declared."""
|
||||
config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01})
|
||||
assert config[CONF_ADDRESS] == 0x01
|
||||
|
||||
|
||||
def test_component_rejects_an_out_of_range_address() -> None:
|
||||
"""A Modbus device address is one byte."""
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100})
|
||||
|
||||
|
||||
def test_multi_conf_no_default_is_set() -> None:
|
||||
"""Load-bearing: the modbus hub auto-loads this component to register its actions.
|
||||
|
||||
Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required
|
||||
address above - breaking every configuration that uses modbus but never declares a modbus_client
|
||||
block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason.
|
||||
"""
|
||||
assert modbus_client.MULTI_CONF is True
|
||||
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
|
||||
@@ -7,8 +7,13 @@ from esphome.components.modbus_server import (
|
||||
SERVER_SENSOR_VALUE_TYPE,
|
||||
_validate_no_overlapping_registers,
|
||||
_validate_register_ranges,
|
||||
_validate_unique_bit_addresses,
|
||||
)
|
||||
from esphome.components.modbus_server.const import (
|
||||
CONF_BITS,
|
||||
CONF_REGISTERS,
|
||||
CONF_VALUE_TYPE,
|
||||
)
|
||||
from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE
|
||||
from esphome.const import CONF_ADDRESS
|
||||
|
||||
|
||||
@@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _bits_config(addresses: list[int]) -> dict:
|
||||
return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]}
|
||||
|
||||
|
||||
def test_non_overlapping_registers_pass() -> None:
|
||||
# Values that tile the address space without gaps or overlaps are accepted.
|
||||
config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")])
|
||||
@@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None:
|
||||
_validate_no_overlapping_registers(config)
|
||||
|
||||
|
||||
def test_unique_bit_addresses_pass() -> None:
|
||||
config = _bits_config([0x00, 0x01, 0x02])
|
||||
assert _validate_unique_bit_addresses(config) is config
|
||||
|
||||
|
||||
def test_duplicate_bit_address_rejected() -> None:
|
||||
# Coils and discrete inputs share one bit address space, so a repeated address is rejected.
|
||||
config = _bits_config([0x05, 0x05])
|
||||
with pytest.raises(cv.Invalid, match="more than once"):
|
||||
_validate_unique_bit_addresses(config)
|
||||
|
||||
|
||||
def test_multi_register_value_overlapping_neighbour_rejected() -> None:
|
||||
# U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word.
|
||||
config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")])
|
||||
@@ -82,3 +103,5 @@ def test_raw_value_type_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
validator("RAW")
|
||||
assert validator("U_WORD") == "U_WORD"
|
||||
assert validator("U_WORD_S") == "U_WORD_S"
|
||||
assert validator("S_WORD_S") == "S_WORD_S"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: arduino
|
||||
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 18
|
||||
mosi_pin: 19
|
||||
miso_pin: 16
|
||||
cs_pin: 17
|
||||
interrupt_pin: 21
|
||||
reset_pin: 20
|
||||
mac_address: "02:AA:BB:CC:DD:01"
|
||||
|
||||
network:
|
||||
priority:
|
||||
- ethernet
|
||||
- wifi
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
network:
|
||||
priority:
|
||||
- wifi
|
||||
@@ -16,9 +16,10 @@ from esphome.components.network import (
|
||||
_validate_priority_list,
|
||||
get_network_priority,
|
||||
)
|
||||
from esphome.const import CONF_PRIORITY
|
||||
from esphome.const import CONF_PRIORITY, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -138,6 +139,22 @@ def test_final_validate_noop_without_priority_list() -> None:
|
||||
_final_validate({}) # must not raise
|
||||
|
||||
|
||||
def test_final_validate_rejects_unsupported_arbitration_interface(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The ethernet/wifi-only arbitration tripwire fails as a clean config error.
|
||||
|
||||
Unreachable through the public schema today (VALID_NETWORK_TYPES gates the
|
||||
list), so the config is hand-built to simulate a future interface type that
|
||||
was added to the schema without extending NetworkComponent::loop().
|
||||
"""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
fv.full_config.set({"openthread": {}, "wifi": {}})
|
||||
config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]}
|
||||
with pytest.raises(Invalid, match="arbitration does not support: openthread"):
|
||||
_final_validate(config)
|
||||
|
||||
|
||||
def _cpp_setup_priority(name: str) -> float:
|
||||
"""Read a setup_priority constant straight from esphome/core/component.h."""
|
||||
header = Path(__file__).parents[3] / "esphome" / "core" / "component.h"
|
||||
@@ -199,3 +216,55 @@ def test_no_primary_interface_define_without_priority(
|
||||
assert not any(
|
||||
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
|
||||
)
|
||||
|
||||
|
||||
def _dns_per_default_netif_option() -> bool | None:
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
|
||||
if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all
|
||||
return None
|
||||
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(
|
||||
"CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_file",
|
||||
[
|
||||
"priority_wifi_first.yaml",
|
||||
"priority_ethernet_first.yaml",
|
||||
"priority_arduino.yaml",
|
||||
],
|
||||
)
|
||||
def test_multi_interface_priority_enables_default_route_arbitration(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
) -> None:
|
||||
"""More than one interface in 'priority' enables default-route arbitration."""
|
||||
generate_main(component_config_path(config_file))
|
||||
assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines}
|
||||
assert _dns_per_default_netif_option() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_file",
|
||||
[
|
||||
# Single-entry priority list / no list at all.
|
||||
"priority_single.yaml",
|
||||
"wifi_only.yaml",
|
||||
# Dual-interface on rp2040: validates, but the arbitration is ESP32-only
|
||||
# (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the
|
||||
# define here would be a hard build break.
|
||||
"priority_rp2040.yaml",
|
||||
],
|
||||
)
|
||||
def test_single_interface_has_no_default_route_arbitration(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
) -> None:
|
||||
"""Single-interface and non-ESP32 configs must not compile in the arbitration."""
|
||||
generate_main(component_config_path(config_file))
|
||||
assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines}
|
||||
assert _dns_per_default_netif_option() is None
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import bundle
|
||||
from esphome.components.packages import (
|
||||
CONFIG_SCHEMA,
|
||||
_substitute_package_definition,
|
||||
@@ -1264,6 +1265,75 @@ def test_remote_packages_no_revert(
|
||||
]
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_packages_skipped_revert_does_not_retry(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml
|
||||
) -> None:
|
||||
"""When revert() reports the rollback was skipped, the load is not
|
||||
retried (the checkout is unchanged) and the error says so."""
|
||||
mock_revert = MagicMock(return_value=False)
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
|
||||
mock_is_file.return_value = True
|
||||
mock_load_yaml.side_effect = cv.Invalid("bad yaml")
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"pkg": {
|
||||
CONF_URL: "https://github.com/esphome/repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [{CONF_PATH: "file.yaml"}],
|
||||
CONF_REFRESH: "1d",
|
||||
}
|
||||
}
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="could not revert the cached checkout"):
|
||||
packages_pass(config)
|
||||
|
||||
assert mock_revert.call_count == 1
|
||||
assert mock_load_yaml.call_count == 1
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_packages_successful_revert_retries(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A successful revert retries the load against the reverted checkout and
|
||||
logs the original error, the only trace that upstream was broken."""
|
||||
mock_revert = MagicMock(return_value=True)
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
|
||||
mock_is_file.return_value = True
|
||||
mock_load_yaml.side_effect = [
|
||||
cv.Invalid("bad yaml"),
|
||||
OrderedDict(
|
||||
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
|
||||
),
|
||||
]
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"pkg": {
|
||||
CONF_URL: "https://github.com/esphome/repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [{CONF_PATH: "file.yaml"}],
|
||||
CONF_REFRESH: "1d",
|
||||
}
|
||||
}
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual[CONF_SENSOR] == [
|
||||
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
|
||||
]
|
||||
assert mock_revert.call_count == 1
|
||||
assert mock_load_yaml.call_count == 2
|
||||
assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
|
||||
"""Test that CORE.raw_config contains esphome section from merged package.
|
||||
|
||||
@@ -1625,3 +1695,34 @@ def test_resolve_packages_does_not_apply_extend_remove() -> None:
|
||||
# over the package value during merge), and the marker is not
|
||||
# resolved by this wrapper.
|
||||
assert isinstance(result[CONF_WIFI], Remove)
|
||||
|
||||
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_registers_checkout_for_secret_scan(
|
||||
mock_clone_or_update, tmp_path: Path
|
||||
) -> None:
|
||||
"""Loading a remote package registers its path-narrowed checkout dir
|
||||
as a bundle secret-scan dir (issue 18023)."""
|
||||
repo_root = tmp_path / "repo"
|
||||
package_dir = repo_root / "packages"
|
||||
package_dir.mkdir(parents=True)
|
||||
(package_dir / "base.yml").write_text(
|
||||
f"sensor:\n - platform: {TEST_SENSOR_PLATFORM_1}\n name: {TEST_SENSOR_NAME_1}\n"
|
||||
)
|
||||
mock_clone_or_update.return_value = (repo_root, None)
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"package1": {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_REF: "main",
|
||||
CONF_PATH: "packages",
|
||||
CONF_FILES: ["base.yml"],
|
||||
CONF_REFRESH: "1d",
|
||||
}
|
||||
}
|
||||
}
|
||||
packages_pass(config)
|
||||
|
||||
assert package_dir in bundle._get_data().secret_scan_dirs
|
||||
assert repo_root not in bundle._get_data().secret_scan_dirs
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
@@ -0,0 +1,4 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
host:
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: preftest
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from
|
||||
codegen (key-lookup backends) or must not (slot-based backends, whose managers
|
||||
have no load_from_key()). Run each platform's real codegen and assert the
|
||||
emission, mirroring the split the deny-list in esphome/core/defines.h assumes
|
||||
for static analysis.
|
||||
|
||||
The fixtures cover every distinct preferences backend today: ln882x and
|
||||
rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is
|
||||
an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new
|
||||
fixture here."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "emits"),
|
||||
[
|
||||
("esp32.yaml", True),
|
||||
("bk72xx.yaml", True), # libretiny
|
||||
("host.yaml", True),
|
||||
("nrf52.yaml", True), # zephyr
|
||||
("esp8266.yaml", False),
|
||||
("rp2.yaml", False),
|
||||
],
|
||||
)
|
||||
def test_key_lookup_define_matches_the_platform_backend(
|
||||
fixture: str,
|
||||
emits: bool,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path(fixture))
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-default
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-single
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
connection_slots: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-two
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
connection_slots: 2
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and
|
||||
final validation rejects over-subscription with the consumer list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_proxy_claims_its_slots_through_the_shared_accounting(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# A default (3-slot) proxy build records one claim per slot, attributed
|
||||
# to the consumer, and passes final validation.
|
||||
generate_main(component_config_path("rp2_proxy_default.yaml"))
|
||||
used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
|
||||
assert used == ["bluetooth_proxy"] * 3
|
||||
|
||||
|
||||
def test_oversubscription_is_rejected_with_the_consumer_list() -> None:
|
||||
# No YAML shape reaches this today (the proxy schema caps at the same
|
||||
# limit); the guard exists for a second consumer such as ble_client.
|
||||
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
|
||||
rp2040_ble.consume_connection_slots(1, "ble_client")({})
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client",
|
||||
):
|
||||
rp2040_ble.validate_connection_slots()
|
||||
|
||||
|
||||
def test_at_cap_passes() -> None:
|
||||
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
|
||||
rp2040_ble.validate_connection_slots()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags
|
||||
that swap the prebuilt single-client pools for the codegen-sized ones;
|
||||
single-slot builds emit none and stay byte-identical to previous releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..helpers import get_define_value
|
||||
|
||||
# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo
|
||||
# in the component's list fails here instead of mirroring into the test.
|
||||
WRAP_FLAGS = (
|
||||
"-Wl,--wrap=btstack_memory_gatt_client_get",
|
||||
"-Wl,--wrap=btstack_memory_gatt_client_free",
|
||||
"-Wl,--wrap=btstack_memory_hci_connection_get",
|
||||
"-Wl,--wrap=btstack_memory_hci_connection_free",
|
||||
)
|
||||
|
||||
|
||||
def test_default_slots_emit_the_pool_wrap(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("rp2_proxy_default.yaml"))
|
||||
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3"
|
||||
|
||||
|
||||
def test_two_slots_emit_the_pool_wrap(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# Two slots: the wrap pools are smaller than the cap, sized from the count.
|
||||
generate_main(component_config_path("rp2_proxy_two_slots.yaml"))
|
||||
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2"
|
||||
|
||||
|
||||
def test_single_slot_keeps_the_prebuilt_pools(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("rp2_proxy_single_slot.yaml"))
|
||||
assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Validation tests for the sendspin image platform.
|
||||
|
||||
These cover the rejection branches, which a compile test cannot reach: a
|
||||
`test*.yaml` can only assert that a configuration is accepted.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data
|
||||
from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _slot_config(**overrides: Any) -> ConfigType:
|
||||
"""Build a minimal valid artwork slot config, allowing field overrides."""
|
||||
config: ConfigType = {
|
||||
"id": "album_slot",
|
||||
"format": "JPEG",
|
||||
"type": "RGB565",
|
||||
"resize": "240x240",
|
||||
"current_image": {"id": "album_art"},
|
||||
}
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""The baseline the rejection tests vary is itself valid."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
config = CONFIG_SCHEMA(_slot_config())
|
||||
|
||||
assert config["slot"] == 0
|
||||
assert config["source"] == "ALBUM"
|
||||
assert config["display_offset"].total_milliseconds == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_format", ["JPEG", "JPG"])
|
||||
def test_jpeg_alias_maps_to_one_enum(
|
||||
set_core_config: SetCoreConfigCallable, image_format: str
|
||||
) -> None:
|
||||
"""runtime_image takes JPG as an alias for JPEG, so both spellings must reach the
|
||||
library's single JPEG enum."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
CONFIG_SCHEMA(_slot_config(format=image_format))
|
||||
|
||||
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
|
||||
|
||||
|
||||
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
for slot in range(MAX_ARTWORK_SLOTS):
|
||||
assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot
|
||||
|
||||
with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"):
|
||||
CONFIG_SCHEMA(_slot_config(id="one_too_many"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resize",
|
||||
[f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"],
|
||||
)
|
||||
def test_oversized_resize_rejected(
|
||||
set_core_config: SetCoreConfigCallable, resize: str
|
||||
) -> None:
|
||||
"""Either dimension past the decoder's limit is refused."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"):
|
||||
CONFIG_SCHEMA(_slot_config(resize=resize))
|
||||
|
||||
|
||||
def test_sub_millisecond_display_offset_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The library field is whole milliseconds, so finer values are refused
|
||||
rather than silently rounded down to zero."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"):
|
||||
CONFIG_SCHEMA(_slot_config(display_offset="500us"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("display_offset", ["61s", "-61s"])
|
||||
def test_out_of_range_display_offset_rejected(
|
||||
set_core_config: SetCoreConfigCallable, display_offset: str
|
||||
) -> None:
|
||||
"""Offsets more than a minute either side of the boundary are refused."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="value must be at (most|least)"):
|
||||
CONFIG_SCHEMA(_slot_config(display_offset=display_offset))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)]
|
||||
)
|
||||
def test_display_offset_accepted(
|
||||
set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int
|
||||
) -> None:
|
||||
"""Whole-millisecond offsets pass through in both directions."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset))
|
||||
|
||||
assert config["display_offset"].total_milliseconds == expected_ms
|
||||
@@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning(
|
||||
generate_main: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Auth type basic builds Basic and does not warn."""
|
||||
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
|
||||
"""Auth type basic on ESP32 uses plaintext credentials and does not warn."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_basic.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_username("admin");' in main_cpp
|
||||
assert '->set_auth_password("password");' in main_cpp
|
||||
assert "set_auth_basic_hash" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
assert _DEFAULT_CHANGE_WARNING not in caplog.text
|
||||
|
||||
|
||||
def test_web_server_auth_basic_esp8266_uses_precomputed_hash(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp
|
||||
assert "set_auth_username" not in main_cpp
|
||||
assert "set_auth_password" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
|
||||
|
||||
def test_web_server_auth_digest_esp8266_uses_plaintext_credentials(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Auth type digest on ESP8266 uses plaintext credentials, not the basic hash."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml"
|
||||
)
|
||||
|
||||
assert '->set_auth_username("admin");' in main_cpp
|
||||
assert '->set_auth_password("password");' in main_cpp
|
||||
assert "set_auth_basic_hash" not in main_cpp
|
||||
assert _has_define("USE_WEBSERVER_AUTH")
|
||||
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
|
||||
|
||||
def test_web_server_auth_explicit_digest(
|
||||
generate_main: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: password
|
||||
type: basic
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
web_server:
|
||||
auth:
|
||||
username: admin
|
||||
password: password
|
||||
type: digest
|
||||
@@ -0,0 +1,7 @@
|
||||
# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform.
|
||||
# Remove before 2027.2.0
|
||||
sensor:
|
||||
- id: adc_temperature_sensor
|
||||
platform: adc
|
||||
pin: TEMPERATURE
|
||||
name: ADC Test temperature
|
||||
@@ -0,0 +1 @@
|
||||
airthings_ble:
|
||||
@@ -0,0 +1,6 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
airthings_ble:
|
||||
ble_hub_id: ble_tracker_hub
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
|
||||
airthings_ble: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
airthings_ble: !include common-ln.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
|
||||
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
|
||||
# on a non-esp32 platform; codegen and compilation are not exercised here.
|
||||
bk72xx_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
airthings_ble:
|
||||
ble_hub_id: ble_tracker_hub
|
||||
@@ -0,0 +1,7 @@
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Temperature
|
||||
humidity:
|
||||
name: ATC Humidity
|
||||
@@ -1,7 +1,10 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: atc_mithermometer
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Temperature
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
atc_mithermometer: !include common-ln.yaml
|
||||
@@ -0,0 +1,10 @@
|
||||
# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates.
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
esp32_ble_id: ble_tracker_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: ATC Legacy Key Temperature
|
||||
@@ -0,0 +1,17 @@
|
||||
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
|
||||
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
|
||||
# on a non-esp32 platform; codegen and compilation are not exercised here.
|
||||
bk72xx_ble_tracker:
|
||||
id: ble_hub
|
||||
|
||||
sensor:
|
||||
- platform: atc_mithermometer
|
||||
ble_hub_id: ble_hub
|
||||
mac_address: A4:C1:38:4E:16:78
|
||||
temperature:
|
||||
name: BK ATC Temperature
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: atc_mithermometer
|
||||
mac_address: A4:C1:38:4E:16:79
|
||||
temperature:
|
||||
name: BK ATC Implicit Temperature
|
||||
@@ -0,0 +1,7 @@
|
||||
sensor:
|
||||
- platform: b_parasite
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
temperature:
|
||||
name: b-parasite Air Temperature
|
||||
@@ -1,7 +1,10 @@
|
||||
esp32_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: b_parasite
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
|
||||
b_parasite: !include common-ln.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
|
||||
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
|
||||
# on a non-esp32 platform; codegen and compilation are not exercised here.
|
||||
bk72xx_ble_tracker:
|
||||
id: ble_tracker_hub
|
||||
|
||||
sensor:
|
||||
# Explicit ble_hub_id: pins the neutral binding as a declared key.
|
||||
- platform: b_parasite
|
||||
ble_hub_id: ble_tracker_hub
|
||||
mac_address: F0:CA:F0:CA:01:01
|
||||
humidity:
|
||||
name: b-parasite Air Humidity
|
||||
temperature:
|
||||
name: b-parasite Air Temperature
|
||||
moisture:
|
||||
name: b-parasite Soil Moisture
|
||||
battery_voltage:
|
||||
name: b-parasite Battery Voltage
|
||||
illuminance:
|
||||
name: b-parasite Illuminance
|
||||
# No ble_hub_id: exercises the generated binding real configs use.
|
||||
- platform: b_parasite
|
||||
mac_address: F0:CA:F0:CA:01:02
|
||||
temperature:
|
||||
name: BK b-parasite Implicit Temperature
|
||||
@@ -0,0 +1,54 @@
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- bk72xx_ble_tracker.start_scan
|
||||
- bk72xx_ble_tracker.start_scan:
|
||||
continuous: true
|
||||
- bk72xx_ble_tracker.stop_scan
|
||||
- bk72xx_ble_tracker.stop_scan: ble_tracker
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
on_ble_advertise:
|
||||
- mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: |-
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
- AC:37:43:77:5F:4D
|
||||
then:
|
||||
- lambda: |-
|
||||
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
|
||||
on_ble_service_data_advertise:
|
||||
- service_uuid: ABCD
|
||||
# mac_address exercises the UUID triggers' set_address() codegen branch.
|
||||
mac_address: AC:37:43:77:5F:4C
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of service data is %zu", x.size());
|
||||
- service_uuid: ABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "32-bit service data is %zu", x.size());
|
||||
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "128-bit service data is %zu", x.size());
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- manufacturer_id: ABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
|
||||
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size());
|
||||
on_scan_end:
|
||||
- then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "Scan ended");
|
||||
@@ -0,0 +1,8 @@
|
||||
# Passive scanning variant: the package merge keeps the shared parameters from
|
||||
# common.yaml and overrides only the mode.
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
@@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# resolve_irk() is compiled only when a sensor configures irk:
|
||||
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
|
||||
# no sensors, so emit the define here to put the real IRK path under test.
|
||||
# Likewise the scan-response merger (emitted by the split-report trackers)
|
||||
# and the listener vector it dispatches into (codegen-sized by consumers).
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace esphome::ble_device_base::testing {
|
||||
|
||||
// from_scan_result() ingests BLE controller order (LSB-first); the public
|
||||
// accessors must expose the historical esp32 semantics: address() in printable
|
||||
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str()
|
||||
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str_to()
|
||||
// printed MSB-first.
|
||||
namespace {
|
||||
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
|
||||
@@ -25,7 +25,14 @@ TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) {
|
||||
|
||||
EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL);
|
||||
|
||||
char buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
EXPECT_STREQ(device.address_str_to(buf), "AA:BB:CC:DD:EE:FF");
|
||||
|
||||
// The deprecated wrapper must keep returning the same string until its 2027.2.0 removal.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF");
|
||||
#pragma GCC diagnostic pop
|
||||
}
|
||||
|
||||
// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement
|
||||
@@ -43,4 +50,18 @@ TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) {
|
||||
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64());
|
||||
}
|
||||
|
||||
// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields
|
||||
// printable (MSB-first) order, and round-tripping through the LSB-first
|
||||
// packer restores the original value.
|
||||
TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) {
|
||||
uint8_t msb_first[6];
|
||||
uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first);
|
||||
EXPECT_EQ(msb_first[0], 0xaa);
|
||||
EXPECT_EQ(msb_first[5], 0xff);
|
||||
uint8_t lsb_first[6];
|
||||
for (int i = 0; i < 6; i++)
|
||||
lsb_first[i] = msb_first[5 - i];
|
||||
EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
namespace {
|
||||
|
||||
// AD types under test
|
||||
constexpr uint8_t AD_SHORT_NAME = 0x08;
|
||||
constexpr uint8_t AD_COMPLETE_NAME = 0x09;
|
||||
|
||||
void append_name(std::vector<uint8_t> &adv, uint8_t ad_type, const char *name) {
|
||||
size_t len = strlen(name);
|
||||
adv.push_back(static_cast<uint8_t>(len + 1));
|
||||
adv.push_back(ad_type);
|
||||
adv.insert(adv.end(), name, name + len);
|
||||
}
|
||||
|
||||
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
|
||||
return device;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BleAdvName, ParsesACompleteName) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, "TP96");
|
||||
ESPBTDevice device = device_from(adv);
|
||||
EXPECT_EQ(device.get_name(), "TP96");
|
||||
// The backing buffer is NUL-terminated so c_str() is usable directly.
|
||||
EXPECT_STREQ(device.get_name().c_str(), "TP96");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, LongestNameWinsShortenedThenComplete) {
|
||||
// A merged adv + scan-response frame can carry both forms; the shortened
|
||||
// one must never replace the complete one.
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_SHORT_NAME, "Radon");
|
||||
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
|
||||
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, LongestNameWinsCompleteThenShortened) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
|
||||
append_name(adv, AD_SHORT_NAME, "Radon");
|
||||
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, MaxLengthNameFitsAndTerminates) {
|
||||
// 29 bytes is the largest name a legacy AD element can carry and exactly
|
||||
// fills the fixed buffer.
|
||||
std::string max_name(29, 'a');
|
||||
std::vector<uint8_t> adv;
|
||||
append_name(adv, AD_COMPLETE_NAME, max_name.c_str());
|
||||
ESPBTDevice device = device_from(adv);
|
||||
EXPECT_EQ(device.get_name().size(), 29u);
|
||||
EXPECT_EQ(device.get_name(), max_name);
|
||||
EXPECT_STREQ(device.get_name().c_str(), max_name.c_str());
|
||||
}
|
||||
|
||||
TEST(BleAdvName, ReparseResetsThePreviousName) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
std::vector<uint8_t> first;
|
||||
append_name(first, AD_COMPLETE_NAME, "RadonEye");
|
||||
std::vector<uint8_t> second;
|
||||
append_name(second, AD_COMPLETE_NAME, "TP96");
|
||||
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, first.data(), static_cast<uint16_t>(first.size()));
|
||||
ASSERT_EQ(device.get_name(), "RadonEye");
|
||||
// A shorter name from a fresh report must fully replace the longer one:
|
||||
// the longest-name rule applies within one report, not across reports.
|
||||
device.from_scan_result(mac, -59, 0, second.data(), static_cast<uint16_t>(second.size()));
|
||||
EXPECT_EQ(device.get_name(), "TP96");
|
||||
EXPECT_STREQ(device.get_name().c_str(), "TP96");
|
||||
}
|
||||
|
||||
TEST(BleAdvName, NoNamePresentIsEmpty) {
|
||||
std::vector<uint8_t> adv = {0x02, 0x0A, 0x00}; // TX power only
|
||||
EXPECT_TRUE(device_from(adv).get_name().empty());
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -19,6 +19,33 @@ const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56};
|
||||
const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01};
|
||||
} // namespace
|
||||
|
||||
// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD
|
||||
// (0x11). Both the AAD block and the l = 3 length encoding are only reachable
|
||||
// through this shape, so they need their own vector. Generated the same way.
|
||||
namespace {
|
||||
const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b};
|
||||
const uint8_t AAD_XIAOMI[1] = {0x11};
|
||||
const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23};
|
||||
const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6};
|
||||
const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00};
|
||||
} // namespace
|
||||
|
||||
TEST(BleAesCcm, DecryptsXiaomiShapedVector) {
|
||||
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
|
||||
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI),
|
||||
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
|
||||
EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI)));
|
||||
}
|
||||
|
||||
TEST(BleAesCcm, RejectsWrongAssociatedData) {
|
||||
uint8_t bad_aad[sizeof(AAD_XIAOMI)];
|
||||
memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI));
|
||||
bad_aad[0] ^= 0x01;
|
||||
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
|
||||
EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad),
|
||||
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
|
||||
}
|
||||
|
||||
TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) {
|
||||
uint8_t out[sizeof(PLAINTEXT)] = {};
|
||||
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
|
||||
|
||||
@@ -27,6 +27,60 @@ TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) {
|
||||
EXPECT_TRUE(u32 == u128);
|
||||
}
|
||||
|
||||
// A default-constructed UUID is UNSET, the historical "not configured" sentinel
|
||||
// (len 0 through the esp32 get_uuid() adapter).
|
||||
TEST(BleDeviceUuid, DefaultConstructedIsUnset) {
|
||||
const ESPBTUUID unset;
|
||||
EXPECT_EQ(unset.type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_TRUE(unset == ESPBTUUID());
|
||||
EXPECT_FALSE(unset == ESPBTUUID::from_uint16(0x1234));
|
||||
EXPECT_FALSE(unset.contains(0x00, 0x00));
|
||||
}
|
||||
|
||||
// Every factory yields a non-UNSET UUID, even for 0x0000: only default construction and a
|
||||
// failed text parse are unset, keeping type() != UNSET equivalent to the old len > 0 check.
|
||||
TEST(BleDeviceUuid, AllFactoriesProduceSetUuids) {
|
||||
const uint8_t raw[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
|
||||
EXPECT_NE(ESPBTUUID::from_uint16(0x0000).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_uint32(0).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw(raw).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw_reversed(raw).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("180F", 4).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("0000180F", 8).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw(reinterpret_cast<const char *>(raw), 16).type(), ESPBTUUID::Type::UNSET);
|
||||
EXPECT_NE(ESPBTUUID::from_raw("6E400001-B5A3-F393-E0A9-E50E24DCCA9E").type(), ESPBTUUID::Type::UNSET);
|
||||
}
|
||||
|
||||
// 0x0000 is a valid short UUID on real devices (esphome/aioesphomeapi#1742); an unset
|
||||
// UUID must never compare equal to it. Unset equals only unset.
|
||||
TEST(BleDeviceUuid, UnsetIsNotEqualToZeroUuid) {
|
||||
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_uint16(0x0000));
|
||||
EXPECT_FALSE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID());
|
||||
const uint8_t base[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_raw(base));
|
||||
EXPECT_TRUE(ESPBTUUID() == ESPBTUUID());
|
||||
EXPECT_FALSE(ESPBTUUID().as_128bit().is_set()); // widening preserves the unset state
|
||||
// A configured 0x0000 still matches its own 128-bit base UUID expansion.
|
||||
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID::from_raw(base));
|
||||
}
|
||||
|
||||
// is_set() is the sentinel check; an unset UUID prints as "None" instead of a
|
||||
// valid-looking all-zero 128-bit UUID.
|
||||
TEST(BleDeviceUuid, IsSetAndUnsetToStr) {
|
||||
char buf[UUID_STR_LEN];
|
||||
EXPECT_FALSE(ESPBTUUID().is_set());
|
||||
EXPECT_STREQ(ESPBTUUID().to_str(buf), "None");
|
||||
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000).is_set());
|
||||
EXPECT_STREQ(ESPBTUUID::from_uint16(0x0000).to_str(buf), "0x0000");
|
||||
}
|
||||
|
||||
// Text parsing of an invalid length historically produced a len-0 (unset) UUID.
|
||||
TEST(BleDeviceUuid, InvalidTextFormParsesToUnset) {
|
||||
EXPECT_EQ(ESPBTUUID::from_raw("nope", 3).type(), ESPBTUUID::Type::UNSET);
|
||||
}
|
||||
|
||||
TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) {
|
||||
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235));
|
||||
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// The GATT client contract compiles in no real build until a hub backend is
|
||||
// configured; this TU pins it on the host so the header cannot rot unseen.
|
||||
// The contract is a concept (BLEGattConnection is a per-platform alias), so
|
||||
// the minimal backend here proves the concept stays satisfiable and routes
|
||||
// events through the GattClientListener interface the way a real backend does.
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// Overrides only what it records; the interface's defaults cover the rest.
|
||||
class RecordingListener : public GattClientListener {
|
||||
public:
|
||||
void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
|
||||
void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
|
||||
void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
|
||||
|
||||
bool connected_{false};
|
||||
int discovery_error_{0};
|
||||
uint16_t write_handle_{0};
|
||||
};
|
||||
|
||||
class MinimalConnection {
|
||||
public:
|
||||
void set_listener(GattClientListener *listener) { this->listener_ = listener; }
|
||||
|
||||
int connect(uint64_t address, uint8_t addr_type) {
|
||||
this->listener_->on_connection_state(true, 517, 0);
|
||||
return 0;
|
||||
}
|
||||
bool cancel_gatt_disconnect() { return false; }
|
||||
int gatt_disconnect() { return 0; }
|
||||
int discover_services() {
|
||||
this->listener_->on_service_discovery_done(0);
|
||||
return 0;
|
||||
}
|
||||
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; }
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
this->listener_->on_write_result(handle, 0);
|
||||
return 0;
|
||||
}
|
||||
int read_descriptor(uint16_t handle) { return 0; }
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; }
|
||||
int notify_characteristic(uint16_t handle, bool enable) { return 0; }
|
||||
int pair() { return GATT_ERR_NOT_CONNECTED; }
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
|
||||
return 0;
|
||||
}
|
||||
GattServiceTable get_service_table() { return {}; }
|
||||
void release_services() {}
|
||||
void set_connection_type(ConnectionType ct) {}
|
||||
|
||||
protected:
|
||||
GattClientListener *listener_{nullptr};
|
||||
};
|
||||
|
||||
static_assert(BLEGattConnectionContract<MinimalConnection>,
|
||||
"a minimal backend must satisfy the contract the alias asserts");
|
||||
|
||||
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
|
||||
MinimalConnection connection;
|
||||
RecordingListener listener;
|
||||
connection.set_listener(&listener);
|
||||
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
|
||||
EXPECT_TRUE(listener.connected_);
|
||||
EXPECT_EQ(connection.discover_services(), 0);
|
||||
EXPECT_EQ(listener.discovery_error_, 0);
|
||||
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
|
||||
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
|
||||
EXPECT_EQ(listener.write_handle_, 7);
|
||||
|
||||
// A default table is empty and safe to walk.
|
||||
GattServiceTable table = connection.get_service_table();
|
||||
EXPECT_EQ(table.service_count, 0);
|
||||
EXPECT_EQ(table.characteristic_count, 0);
|
||||
EXPECT_EQ(table.descriptor_count, 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -0,0 +1,158 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// from_manufacturer_data() accepts exactly the iBeacon frame: Apple company ID,
|
||||
// 23 payload bytes, and the 0x02/0x15 sub-type/length prefix. The prefix check
|
||||
// is stricter than the legacy esp32 parser (which surfaced any 23-byte Apple
|
||||
// payload as a beacon) — a declared behavior change; these tests pin the
|
||||
// accept/reject boundary.
|
||||
namespace {
|
||||
|
||||
ServiceData make_apple_payload(uint8_t sub_type, uint8_t length, size_t size = 23) {
|
||||
ServiceData data;
|
||||
data.uuid = ESPBTUUID::from_uint16(0x004C); // Apple company ID
|
||||
data.data.assign(size, 0);
|
||||
if (size >= 2) {
|
||||
data.data[0] = sub_type;
|
||||
data.data[1] = length;
|
||||
}
|
||||
// BeaconData layout: sub_type[0], length[1], proximity_uuid[2..17],
|
||||
// major[18..19], minor[20..21], signal_power[22] — all wire values big-endian.
|
||||
if (size >= 23) {
|
||||
data.data[18] = 0x12; // major 0x1234
|
||||
data.data[19] = 0x34;
|
||||
data.data[20] = 0x56; // minor 0x5678
|
||||
data.data[21] = 0x78;
|
||||
data.data[22] = 0xC5; // signal power -59 dBm
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BleIBeacon, AcceptsWellFormedFrame) {
|
||||
auto beacon = ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15));
|
||||
ASSERT_TRUE(beacon.has_value());
|
||||
// Explicit guard: clang-tidy's unchecked-optional-access models neither
|
||||
// gtest's ASSERT_TRUE nor value() as a check.
|
||||
if (beacon.has_value()) {
|
||||
// Pins every scalar accessor's offset and the on-wire big-endian order.
|
||||
EXPECT_EQ(beacon->get_major(), 0x1234);
|
||||
EXPECT_EQ(beacon->get_minor(), 0x5678);
|
||||
EXPECT_EQ(beacon->get_signal_power(), -59);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongSubType) {
|
||||
// Apple "nearby" and other frames of coincidental length must not parse.
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongLengthByte) {
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x14)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsWrongPayloadSize) {
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 22)).has_value());
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 24)).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, RejectsNonAppleCompany) {
|
||||
auto data = make_apple_payload(0x02, 0x15);
|
||||
data.uuid = ESPBTUUID::from_uint16(0x0059); // Nordic
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(data).has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, PrefixRejectedFlagsOnlyTheSubTypeCase) {
|
||||
// The out-param drives the get_ibeacon() diagnostic for frames the legacy
|
||||
// parser accepted: exactly the 23-byte Apple payload with a wrong prefix.
|
||||
// Wrong size and non-Apple frames were never accepted and must stay silent.
|
||||
bool flagged = false;
|
||||
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15), &flagged).has_value());
|
||||
EXPECT_TRUE(flagged);
|
||||
|
||||
flagged = false;
|
||||
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15), &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
|
||||
flagged = false;
|
||||
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15, 22), &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
|
||||
flagged = false;
|
||||
auto nordic = make_apple_payload(0x10, 0x15);
|
||||
nordic.uuid = ESPBTUUID::from_uint16(0x0059);
|
||||
ESPBLEiBeacon::from_manufacturer_data(nordic, &flagged);
|
||||
EXPECT_FALSE(flagged);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One AD manufacturer-data record: [len][0xFF][company LE][payload...].
|
||||
void append_mfr_record(std::vector<uint8_t> &adv, uint16_t company, const std::vector<uint8_t> &payload) {
|
||||
adv.push_back(static_cast<uint8_t>(1 + 2 + payload.size()));
|
||||
adv.push_back(0xFF);
|
||||
adv.push_back(static_cast<uint8_t>(company & 0xFF));
|
||||
adv.push_back(static_cast<uint8_t>(company >> 8));
|
||||
adv.insert(adv.end(), payload.begin(), payload.end());
|
||||
}
|
||||
|
||||
std::vector<uint8_t> beacon_payload(uint8_t sub_type, uint8_t length) {
|
||||
std::vector<uint8_t> p(23, 0);
|
||||
p[0] = sub_type;
|
||||
p[1] = length;
|
||||
p[18] = 0x12;
|
||||
p[19] = 0x34;
|
||||
p[20] = 0x56;
|
||||
p[21] = 0x78;
|
||||
p[22] = 0xC5;
|
||||
return p;
|
||||
}
|
||||
|
||||
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
|
||||
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
|
||||
return device;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// get_ibeacon() wraps the parser with first-rejection capture and the log
|
||||
// gate; pin its short circuits so a regression there needs a code change, not
|
||||
// a review, to surface.
|
||||
TEST(BleIBeacon, GetIbeaconReturnsBeaconDespitePrecedingRejectedFrame) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); // rejected prefix
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x02, 0x15)); // real iBeacon
|
||||
auto device = device_from(adv);
|
||||
auto beacon = device.get_ibeacon();
|
||||
ASSERT_TRUE(beacon.has_value());
|
||||
if (beacon.has_value()) {
|
||||
EXPECT_EQ(beacon->get_major(), 0x1234);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, GetIbeaconEmptyWhenOnlyRejectedFrames) {
|
||||
std::vector<uint8_t> adv;
|
||||
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15));
|
||||
auto device = device_from(adv);
|
||||
EXPECT_FALSE(device.get_ibeacon().has_value());
|
||||
}
|
||||
|
||||
TEST(BleIBeacon, GetIbeaconEmptyWithoutManufacturerData) {
|
||||
std::vector<uint8_t> adv;
|
||||
adv.push_back(0x02); // flags record only
|
||||
adv.push_back(0x01);
|
||||
adv.push_back(0x06);
|
||||
auto device = device_from(adv);
|
||||
EXPECT_FALSE(device.get_ibeacon().has_value());
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing {
|
||||
//
|
||||
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
|
||||
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
|
||||
// pinned here through a minimal host BLEHub implementation instead.
|
||||
// pinned here through a minimal host hub carrying only the slot under test.
|
||||
namespace {
|
||||
|
||||
class FakeHub : public BLEHub {
|
||||
class FakeHub {
|
||||
public:
|
||||
void register_listener(ESPBTDeviceListener *listener) override {}
|
||||
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
|
||||
HubCapabilities get_capabilities() const override { return {false, false, false}; }
|
||||
void get_adapter_mac(uint8_t out[6]) override {}
|
||||
bool scan_running() override { return false; }
|
||||
bool scan_active() override { return false; }
|
||||
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; }
|
||||
|
||||
/// The emit path every tracker implements: fire only when a subscriber is set.
|
||||
void emit(const RawAdvertisement &adv) {
|
||||
@@ -46,13 +41,13 @@ struct CapturingSubscriber {
|
||||
}
|
||||
};
|
||||
|
||||
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
|
||||
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
|
||||
// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it.
|
||||
constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL;
|
||||
const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00};
|
||||
|
||||
RawAdvertisement make_test_adv() {
|
||||
return RawAdvertisement{
|
||||
.mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
|
||||
.address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -70,7 +65,7 @@ TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) {
|
||||
hub.emit(make_test_adv());
|
||||
|
||||
ASSERT_EQ(subscriber.calls, 1);
|
||||
EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST);
|
||||
EXPECT_EQ(subscriber.last.address, TEST_ADDRESS);
|
||||
EXPECT_EQ(subscriber.last.data, ADV_DATA);
|
||||
EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA));
|
||||
EXPECT_EQ(subscriber.last.rssi, -63);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// The host test build gets this from the manifest override; clang-tidy does not.
|
||||
#ifndef USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#define USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#endif
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ble_device_base/scan_response_merger.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
namespace {
|
||||
|
||||
// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot
|
||||
// bookkeeping, the same-device reuse path, the table-full fallback, the
|
||||
// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate.
|
||||
// Delivery is observed through a real AdvDispatcher: the raw callback sees
|
||||
// every frame (including raw_only), a listener only the parsed ones.
|
||||
|
||||
struct DeliveredFrame {
|
||||
uint64_t address;
|
||||
std::vector<uint8_t> data;
|
||||
int8_t rssi;
|
||||
};
|
||||
|
||||
struct RawCapture {
|
||||
std::vector<DeliveredFrame> frames;
|
||||
|
||||
static void trampoline(void *self, const RawAdvertisement &adv) {
|
||||
auto *capture = static_cast<RawCapture *>(self);
|
||||
capture->frames.push_back({adv.address, std::vector<uint8_t>(adv.data, adv.data + adv.data_len), adv.rssi});
|
||||
}
|
||||
};
|
||||
|
||||
class CountingListener : public ESPBTDeviceListener {
|
||||
public:
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
this->parsed++;
|
||||
return true; // claimed: keeps the discovered log quiet
|
||||
}
|
||||
int parsed{0};
|
||||
};
|
||||
|
||||
class ScanResponseMergerTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline});
|
||||
this->dispatcher_.register_listener(&this->listener_);
|
||||
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test");
|
||||
}
|
||||
|
||||
void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) {
|
||||
std::vector<uint8_t> data(data_len, fill);
|
||||
this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now);
|
||||
}
|
||||
|
||||
void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) {
|
||||
std::vector<uint8_t> data(data_len, fill);
|
||||
this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len);
|
||||
}
|
||||
|
||||
ScanResponseMerger merger_;
|
||||
AdvDispatcher dispatcher_;
|
||||
RawCapture raw_;
|
||||
CountingListener listener_;
|
||||
bool scan_continuous_{true};
|
||||
};
|
||||
|
||||
constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
|
||||
constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16};
|
||||
|
||||
TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA);
|
||||
EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered
|
||||
|
||||
this->scan_rsp_(MAC_A, -70, 10, 0xBB);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
const auto &frame = this->raw_.frames[0];
|
||||
ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame
|
||||
EXPECT_EQ(frame.data[0], 0xAA);
|
||||
EXPECT_EQ(frame.data[19], 0xAA);
|
||||
EXPECT_EQ(frame.data[20], 0xBB);
|
||||
// The advertisement's RSSI, never the scan response's.
|
||||
EXPECT_EQ(frame.rssi, -40);
|
||||
EXPECT_EQ(this->listener_.parsed, 1);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA);
|
||||
this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 20u);
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
|
||||
EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot
|
||||
|
||||
this->scan_rsp_(MAC_A, -70, 5, 0xBB);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 2u);
|
||||
EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot
|
||||
EXPECT_EQ(this->raw_.frames[1].rssi, -45);
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) {
|
||||
uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
for (uint8_t i = 0; i < 8; i++) {
|
||||
mac[5] = i;
|
||||
this->stash_(mac, -50, 10, i);
|
||||
}
|
||||
EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held
|
||||
|
||||
mac[5] = 8;
|
||||
this->stash_(mac, -50, 10, 8); // 9th device: no slot left
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
|
||||
|
||||
this->merger_.flush(); // the 8 held frames are all still intact
|
||||
EXPECT_EQ(this->raw_.frames.size(), 9u);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) {
|
||||
this->stash_(MAC_A, -40, 31, 0xAA);
|
||||
this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 62u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB);
|
||||
EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB);
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) {
|
||||
this->scan_rsp_(MAC_B, -60, 12, 0xDD);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -60);
|
||||
EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) {
|
||||
std::vector<uint8_t> adv(20, 0xAA);
|
||||
this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0);
|
||||
std::vector<uint8_t> rsp(10, 0xBB);
|
||||
this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size());
|
||||
// Same MAC, different addr_type: no merge — the response goes out raw_only.
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
|
||||
EXPECT_EQ(this->listener_.parsed, 0);
|
||||
EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
|
||||
this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout
|
||||
EXPECT_TRUE(this->raw_.frames.empty());
|
||||
this->merger_.sweep(1301);
|
||||
ASSERT_EQ(this->raw_.frames.size(), 1u);
|
||||
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
|
||||
EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) {
|
||||
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
|
||||
this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000);
|
||||
this->merger_.flush();
|
||||
EXPECT_EQ(this->raw_.frames.size(), 2u);
|
||||
EXPECT_EQ(this->listener_.parsed, 2);
|
||||
EXPECT_TRUE(this->merger_.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) {
|
||||
ScanResponseMerger unbound;
|
||||
std::vector<uint8_t> data(20, 0xAA);
|
||||
unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
|
||||
unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
|
||||
unbound.sweep(1000);
|
||||
unbound.flush(); // no null jump anywhere
|
||||
EXPECT_TRUE(unbound.empty());
|
||||
}
|
||||
|
||||
TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) {
|
||||
ScanResponseMerger partial;
|
||||
partial.bind(&this->dispatcher_, nullptr, "test");
|
||||
std::vector<uint8_t> data(20, 0xAA);
|
||||
partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
|
||||
partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
|
||||
partial.flush(); // dropped, not dispatched through half a binding
|
||||
EXPECT_TRUE(this->raw_.frames.empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
@@ -0,0 +1,52 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
|
||||
namespace esphome::ble_device_base::testing {
|
||||
|
||||
// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback:
|
||||
// a default-constructed slot is "no subscriber", a set slot delivers the
|
||||
// state, and a new registration replaces the old.
|
||||
namespace {
|
||||
|
||||
struct CapturingSubscriber {
|
||||
ScannerState last{ScannerState::IDLE};
|
||||
int calls{0};
|
||||
|
||||
static void trampoline(void *self, ScannerState state) {
|
||||
auto *sub = static_cast<CapturingSubscriber *>(self);
|
||||
sub->last = state;
|
||||
sub->calls++;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) {
|
||||
const ScannerStateCallback callback{};
|
||||
EXPECT_FALSE(callback.is_set());
|
||||
}
|
||||
|
||||
TEST(ScannerStateCallback, SubscriberSeesState) {
|
||||
CapturingSubscriber subscriber;
|
||||
ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline};
|
||||
ASSERT_TRUE(callback.is_set());
|
||||
callback.invoke(ScannerState::RUNNING);
|
||||
EXPECT_EQ(subscriber.calls, 1);
|
||||
EXPECT_EQ(subscriber.last, ScannerState::RUNNING);
|
||||
}
|
||||
|
||||
TEST(ScannerStateCallback, NewSubscriberReplacesOld) {
|
||||
CapturingSubscriber first;
|
||||
CapturingSubscriber second;
|
||||
ScannerStateCallback callback{&first, CapturingSubscriber::trampoline};
|
||||
callback = {&second, CapturingSubscriber::trampoline};
|
||||
callback.invoke(ScannerState::STOPPED);
|
||||
EXPECT_EQ(first.calls, 0);
|
||||
EXPECT_EQ(second.calls, 1);
|
||||
EXPECT_EQ(second.last, ScannerState::STOPPED);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base::testing
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user