mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-n
|
||||
|
||||
bk72xx:
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-q
|
||||
|
||||
bk72xx:
|
||||
board: wa2
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-t
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231t-qfn32-tuya
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: bk-family-gate-7252
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The non-5.x family rejection lives in to_code (config validation must stay
|
||||
family-agnostic for the validate-only CI fixtures), so codegen is the only
|
||||
place it can be pinned."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "match"),
|
||||
[
|
||||
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
|
||||
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
|
||||
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
|
||||
],
|
||||
)
|
||||
def test_unsupported_family_rejected(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
match: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with pytest.raises(EsphomeError, match=match):
|
||||
generate_main(component_config_path(config_file))
|
||||
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
|
||||
# board), but it warns before codegen raises.
|
||||
assert "cannot compile" in caplog.text
|
||||
|
||||
|
||||
def test_ble5_family_generates(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
|
||||
assert "bk72xx_ble::BK72xxBLE" in main_cpp
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: slotcount-controller
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble:
|
||||
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: slotcount-tracker
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7252
|
||||
board: cb2s
|
||||
|
||||
bk72xx_ble_tracker:
|
||||
|
||||
@@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None:
|
||||
|
||||
|
||||
def test_esp32_defaults_are_valid() -> None:
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
|
||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
|
||||
|
||||
Without wifi loaded, the conditional window default falls back to the
|
||||
historical 30 ms; the wifi-aware resolution is covered by the
|
||||
esp32_ble_tracker component tests.
|
||||
"""
|
||||
config = ESP32_SCHEMA({})
|
||||
assert to_ble_units(config["interval"]) == 512
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for the esp32_ble_tracker conditional scan window default.
|
||||
|
||||
The scan window default depends on wifi coexistence and the IDF version:
|
||||
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
|
||||
configured window (espressif/esp-idf#18931), so on fixed versions the
|
||||
historical 30 ms default would only listen 9.4 % of the time and miss most
|
||||
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
|
||||
window instead defaults to the interval, as Espressif recommends; without the
|
||||
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_device_base import to_ble_units
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_ble_tracker import (
|
||||
CONF_SOFTWARE_COEXISTENCE,
|
||||
CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_INTERVAL, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_esp32(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> Callable[..., None]:
|
||||
"""Stage an esp32 build with a given IDF version and wifi presence."""
|
||||
|
||||
def stage(idf: str, *, wifi: bool) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
if wifi:
|
||||
# Makes cv.OnlyWith default software_coexistence to True, exactly
|
||||
# as a real config with wifi: does.
|
||||
CORE.loaded_integrations.add("wifi")
|
||||
|
||||
return stage
|
||||
|
||||
|
||||
def _scan_params(config: ConfigType) -> ConfigType:
|
||||
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("idf", "config", "expected_units"),
|
||||
[
|
||||
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
|
||||
("6.0.1", {}, 512), # any newer version behaves the same
|
||||
# Follows a user-set interval.
|
||||
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
|
||||
],
|
||||
)
|
||||
def test_wifi_on_fixed_idf_defaults_window_to_interval(
|
||||
stage_esp32: Callable[..., None],
|
||||
idf: str,
|
||||
config: ConfigType,
|
||||
expected_units: int,
|
||||
) -> None:
|
||||
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
|
||||
stage_esp32(idf, wifi=True)
|
||||
params = _scan_params(config)
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_WINDOW]) == expected_units
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("idf", "wifi", "config"),
|
||||
[
|
||||
# Buggy IDF over-scans anyway; keep the 30 ms default.
|
||||
("5.5.4", True, {}),
|
||||
# No wifi (e.g. ethernet) means no radio contention.
|
||||
("5.5.5", False, {}),
|
||||
# Coexistence disabled: no arbiter, so a full-duty scan would starve
|
||||
# wifi outright.
|
||||
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
|
||||
],
|
||||
)
|
||||
def test_30ms_default_kept(
|
||||
stage_esp32: Callable[..., None],
|
||||
idf: str,
|
||||
wifi: bool,
|
||||
config: ConfigType,
|
||||
) -> None:
|
||||
stage_esp32(idf, wifi=wifi)
|
||||
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
|
||||
|
||||
|
||||
@pytest.mark.parametrize("window", ["60ms", "30ms"])
|
||||
def test_explicit_window_is_never_touched(
|
||||
stage_esp32: Callable[..., None], window: str
|
||||
) -> None:
|
||||
"""A user-set window wins over the conditional default.
|
||||
|
||||
The explicit 30 ms case matters: it is indistinguishable from the
|
||||
defaulted value by inspection, so the defaulted flag must separate them.
|
||||
"""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({"scan_parameters": {"window": window}})
|
||||
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
|
||||
cv.positive_time_period(window)
|
||||
)
|
||||
|
||||
|
||||
def test_short_interval_without_window_still_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""The provisional 30 ms default validates against the interval as before."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_scan_params({"scan_parameters": {"interval": "20ms"}})
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the esp32_hosted ESP-IDF version gate."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_hosted import _final_validate
|
||||
from esphome.const import PlatformFramework
|
||||
|
||||
from ..types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
|
||||
def test_final_validate_accepts_supported_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF 5.3 and newer passes validation unchanged."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
|
||||
def test_final_validate_rejects_old_idf(
|
||||
set_core_config: SetCoreConfigCallable, idf: str
|
||||
) -> None:
|
||||
"""ESP-IDF older than 5.3 is rejected with a clear error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
|
||||
_final_validate({})
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the shared io expander interrupt_pin validator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.gpio_expander import validate_interrupt_pin
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_esp32(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
|
||||
def test_plain_pin_accepted(stage_esp32: None) -> None:
|
||||
value = validate_interrupt_pin(
|
||||
{"number": 16, "mode": {"input": True, "pullup": True}}
|
||||
)
|
||||
assert value["number"] == 16
|
||||
|
||||
|
||||
def test_inverted_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "inverted": True})
|
||||
|
||||
|
||||
def test_allow_other_uses_rejected(stage_esp32: None) -> None:
|
||||
with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"):
|
||||
validate_interrupt_pin({"number": 16, "allow_other_uses": True})
|
||||
|
||||
|
||||
# mcp23017 covers the shared mcp23xxx_base schema
|
||||
@pytest.mark.parametrize(
|
||||
"component",
|
||||
[
|
||||
"pcf8574",
|
||||
"pca9554",
|
||||
"tca9555",
|
||||
"pca6416a",
|
||||
"pi4ioe5v6408",
|
||||
"mcp23016",
|
||||
"mcp23017",
|
||||
],
|
||||
)
|
||||
def test_component_schemas_route_through_validator(
|
||||
stage_esp32: None, component: str
|
||||
) -> None:
|
||||
module = importlib.import_module(f"esphome.components.{component}")
|
||||
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
|
||||
module.CONFIG_SCHEMA(
|
||||
{"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}}
|
||||
)
|
||||
@@ -21,16 +21,20 @@ from esphome.components.image import (
|
||||
CONF_OPAQUE,
|
||||
CONF_TRANSPARENCY,
|
||||
PLATFORM_FILE,
|
||||
_expand_platform_entry,
|
||||
_flatten_legacy_image_config,
|
||||
_is_legacy_image_format,
|
||||
_is_new_image_format,
|
||||
_migrate_legacy_image_config,
|
||||
expand_platform_config,
|
||||
get_all_image_metadata,
|
||||
get_image_metadata,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_DEFAULTS,
|
||||
CONF_DITHER,
|
||||
CONF_FILE,
|
||||
CONF_FILES,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_RAW_DATA_ID,
|
||||
@@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None:
|
||||
assert out[0][CONF_BYTE_ORDER] == "little_endian"
|
||||
|
||||
|
||||
def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None:
|
||||
"""The legacy flattener drops an incompatible byte_order even when written directly on the entry."""
|
||||
out = _flatten_legacy_image_config(
|
||||
{"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]}
|
||||
)
|
||||
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
|
||||
assert CONF_BYTE_ORDER not in out[0]
|
||||
|
||||
|
||||
def test_flatten_skips_meta_and_unknown_keys() -> None:
|
||||
out = _flatten_legacy_image_config(
|
||||
{
|
||||
@@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform(
|
||||
),
|
||||
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
|
||||
pytest.param("a string", False, id="scalar"),
|
||||
# A `platform:`-tagged dict is the new format written without list brackets.
|
||||
pytest.param(
|
||||
{CONF_PLATFORM: "file", "id": "a", "file": "x.png"},
|
||||
False,
|
||||
id="platform_tagged_flat_dict",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
},
|
||||
False,
|
||||
id="platform_tagged_defaults_files_dict",
|
||||
),
|
||||
# `files:` without `platform:` is not legacy either -- the flattener has no branch for it.
|
||||
pytest.param(
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
},
|
||||
False,
|
||||
id="defaults_files_dict_without_platform",
|
||||
),
|
||||
# Same as above in a list -- without this exclusion it would be silently
|
||||
# migrated to a hard-coded `platform: file` instead of raising the error.
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
],
|
||||
False,
|
||||
id="defaults_files_list_entry_without_platform",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_legacy_image_format(config: object, expected: bool) -> None:
|
||||
@@ -359,39 +408,313 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None:
|
||||
def test_migrate_returns_none_for_invalid_legacy_shapes(
|
||||
config: object, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Unrecognised shapes are not migrated (and emit no warning) so normal
|
||||
platform validation surfaces a proper error instead of silently dropping
|
||||
the offending input."""
|
||||
"""Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
assert "deprecated" not in caplog.text
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_mapping_form_defaults_files() -> None:
|
||||
"""A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator."""
|
||||
config = {
|
||||
CONF_PLATFORM: "file",
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None:
|
||||
"""`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has
|
||||
no `files:` branch and would silently return `[]`."""
|
||||
config = {
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None:
|
||||
"""Same, in a list -- previously the list branch migrated it to a hard-coded
|
||||
`platform: file` instead of raising a missing-platform error."""
|
||||
config = [
|
||||
{
|
||||
"defaults": {"type": "rgb565"},
|
||||
"files": [{"id": "a", "file": "a.png"}],
|
||||
}
|
||||
]
|
||||
assert _migrate_legacy_image_config(config) is None
|
||||
|
||||
|
||||
# --------------------------- end legacy migration --------------------------
|
||||
|
||||
|
||||
def test_expand_platform_entry_passes_through_plain_entry() -> None:
|
||||
entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}
|
||||
assert _expand_platform_entry(0, entry) == [entry]
|
||||
|
||||
|
||||
def test_expand_platform_entry_expands_files_with_defaults() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"},
|
||||
CONF_FILES: [
|
||||
{"id": "img1", "file": "foo.png"},
|
||||
{"id": "img2", "file": "bar.png", "type": "GRAYSCALE"},
|
||||
],
|
||||
}
|
||||
assert _expand_platform_entry(0, entry) == [
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"id": "img1",
|
||||
"file": "foo.png",
|
||||
"type": "RGB565",
|
||||
"transparency": "opaque",
|
||||
},
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
"id": "img2",
|
||||
"file": "bar.png",
|
||||
"type": "GRAYSCALE",
|
||||
"transparency": "opaque",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_without_defaults() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
|
||||
}
|
||||
assert _expand_platform_entry(0, entry) == [
|
||||
{CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
|
||||
]
|
||||
|
||||
|
||||
def test_expand_platform_entry_preserves_source_range() -> None:
|
||||
"""A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there."""
|
||||
from esphome import yaml_util
|
||||
|
||||
file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"})
|
||||
file_entry._esp_range = "sentinel-range"
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [file_entry],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert isinstance(out, yaml_util.ESPHomeDataBase)
|
||||
assert out.esp_range == "sentinel-range"
|
||||
|
||||
|
||||
def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None:
|
||||
"""Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
|
||||
|
||||
|
||||
def test_expand_platform_entry_per_file_overrides_win() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["type"] == "BINARY"
|
||||
|
||||
|
||||
def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None:
|
||||
"""A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"},
|
||||
CONF_FILES: [
|
||||
{"id": "a", "file": "x.png"},
|
||||
{"id": "b", "file": "y.png", "type": "binary"},
|
||||
],
|
||||
}
|
||||
out = _expand_platform_entry(0, entry)
|
||||
assert out[0]["byte_order"] == "little_endian"
|
||||
assert "byte_order" not in out[1]
|
||||
|
||||
|
||||
def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None:
|
||||
"""A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="did you mean") as excinfo:
|
||||
_expand_platform_entry(0, entry)
|
||||
assert excinfo.value.path == [0]
|
||||
|
||||
|
||||
def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["byte_order"] == "big_endian"
|
||||
|
||||
|
||||
def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None:
|
||||
"""A `byte_order` written directly on the entry is kept so validate_settings raises the normal error."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "rgb565"},
|
||||
CONF_FILES: [
|
||||
{
|
||||
"id": "a",
|
||||
"file": "x.png",
|
||||
"type": "binary",
|
||||
"byte_order": "little_endian",
|
||||
}
|
||||
],
|
||||
}
|
||||
[out] = _expand_platform_entry(0, entry)
|
||||
assert out["byte_order"] == "little_endian"
|
||||
|
||||
|
||||
def test_expand_platform_entry_defaults_without_files_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}}
|
||||
with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo:
|
||||
_expand_platform_entry(0, entry)
|
||||
assert excinfo.value.path == [0]
|
||||
|
||||
|
||||
def test_expand_platform_entry_null_files_raises_not_empty() -> None:
|
||||
"""A `files:` key with no value parses to `None` and must be reported clearly."""
|
||||
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None}
|
||||
with pytest.raises(cv.Invalid, match="must not be empty"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None:
|
||||
"""An explicit `files: []` must not silently drop the whole platform entry."""
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: []}
|
||||
with pytest.raises(cv.Invalid, match="must not be empty"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_with_stray_key_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
"extra": 1,
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be combined with"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_id_in_defaults_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {CONF_ID: "a"},
|
||||
CONF_FILES: [{"file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_platform_in_defaults_raises() -> None:
|
||||
"""`platform:` inside `defaults:` would silently reassign every file's platform."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {CONF_PLATFORM: "animation"},
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_platform_in_file_entry_raises() -> None:
|
||||
"""`platform:` on a `files:` item must not silently override the entry's platform."""
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="not allowed inside"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_files_not_list_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"}
|
||||
with pytest.raises(cv.Invalid, match="must be a list"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_defaults_not_mapping_raises() -> None:
|
||||
entry = {
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: "not-a-mapping",
|
||||
CONF_FILES: [{"id": "a", "file": "x.png"}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="must be a mapping"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_entry_file_item_not_mapping_raises() -> None:
|
||||
entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]}
|
||||
with pytest.raises(cv.Invalid, match="must be a mapping"):
|
||||
_expand_platform_entry(0, entry)
|
||||
|
||||
|
||||
def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None:
|
||||
config = [
|
||||
{
|
||||
CONF_PLATFORM: "file",
|
||||
CONF_DEFAULTS: {"type": "RGB565"},
|
||||
CONF_FILES: [
|
||||
{"id": "img1", "file": "foo.png"},
|
||||
{"id": "img2", "file": "bar.png"},
|
||||
],
|
||||
},
|
||||
{CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"},
|
||||
]
|
||||
out = expand_platform_config(config)
|
||||
assert [entry["id"] for entry in out] == ["img1", "img2", "plain"]
|
||||
|
||||
|
||||
def test_expand_platform_config_ignores_non_platform_entries() -> None:
|
||||
# Not expanded here -- legacy_config_migrate runs before this hook and is
|
||||
# responsible for tagging/flattening pre-platform shapes.
|
||||
config = ["not-a-platform-entry"]
|
||||
assert expand_platform_config(config) == config
|
||||
|
||||
|
||||
# --------------------- end defaults/files expansion -------------------------
|
||||
|
||||
|
||||
def test_validate_image_final_defaults_to_little_endian() -> None:
|
||||
out = validate_image_final({CONF_FILE: "x.png"})
|
||||
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
|
||||
config = {CONF_FILE: "x.png"}
|
||||
validate_image_final(config)
|
||||
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
|
||||
|
||||
|
||||
def test_validate_image_final_keeps_little_endian(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
out = validate_image_final(
|
||||
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
|
||||
)
|
||||
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
|
||||
validate_image_final(config)
|
||||
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
|
||||
assert "big-endian" not in caplog.text
|
||||
|
||||
|
||||
def test_validate_image_final_warns_on_big_endian(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
|
||||
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
|
||||
validate_image_final(config)
|
||||
assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN"
|
||||
assert "big-endian" in caplog.text
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source(
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
# Should not raise.
|
||||
assert FINAL_VALIDATE_SCHEMA({}) == {}
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
|
||||
|
||||
def test_provisioning_warns_on_hardcoded_credentials(
|
||||
@@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials(
|
||||
register_source("network")
|
||||
report_hardcoded_credentials("wifi")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert FINAL_VALIDATE_SCHEMA({}) == {}
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "wifi" in caplog.text
|
||||
assert "credentials" in caplog.text
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert FINAL_VALIDATE_SCHEMA({}) == {}
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "credentials" not in caplog.text
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user