mirror of
https://github.com/esphome/esphome.git
synced 2026-09-20 19:48:39 +00:00
Merge remote-tracking branch 'upstream/esp8266-delay-os-suspend' into integration
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <coredecls.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
|
||||
// call to the original millis() that --wrap can't intercept, so calling ::delay()
|
||||
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
|
||||
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
|
||||
// WiFi run correctly. Theoretically less power-efficient than Arduino's
|
||||
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
|
||||
// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is
|
||||
// negligible.
|
||||
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
|
||||
// suspend the cont task for `ms` milliseconds without polling millis(). This
|
||||
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
|
||||
// and lets the SDK run freely while we wait, which timing-sensitive
|
||||
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
|
||||
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
|
||||
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
|
||||
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
|
||||
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
|
||||
// by this path (the --wrap=millis goal of #15662 is preserved).
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
esp_delay(ms);
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
|
||||
@@ -86,10 +86,22 @@ class EffectRef:
|
||||
component_path: list[str | int] # path_context when the action was validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCycleRef:
|
||||
"""A pending light.effect.next/previous action to validate.
|
||||
|
||||
Records that the referenced light needs at least one effect configured.
|
||||
"""
|
||||
|
||||
light_id: ID
|
||||
component_path: list[str | int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightData:
|
||||
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
|
||||
effect_refs: list[EffectRef] = field(default_factory=list)
|
||||
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> LightData:
|
||||
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
this never runs — but the ID validator will catch the missing light ID separately.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.effect_refs:
|
||||
if not data.effect_refs and not data.effect_cycle_refs:
|
||||
return config
|
||||
|
||||
# Drain the list so we only validate once even though
|
||||
# Drain the lists so we only validate once even though
|
||||
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
|
||||
refs = data.effect_refs
|
||||
data.effect_refs = []
|
||||
cycle_refs = data.effect_cycle_refs
|
||||
data.effect_cycle_refs = []
|
||||
|
||||
fconf = fv.full_config.get()
|
||||
|
||||
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
for ref in cycle_refs:
|
||||
try:
|
||||
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
|
||||
light_config = fconf.get_config_for_path(light_path)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if not light_config.get(CONF_EFFECTS):
|
||||
raise cv.FinalExternalInvalid(
|
||||
f"Light '{ref.light_id}' has no effects configured, but a "
|
||||
f"'light.effect.next' or 'light.effect.previous' action "
|
||||
f"references it. Add at least one effect to the light.",
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
|
||||
transition_length_{};
|
||||
};
|
||||
|
||||
// Cycle through the light's configured effects. `Forward` selects direction
|
||||
// at compile time so the chosen branch is the only one that gets instantiated
|
||||
// per action site. `include_none` is runtime so a single set of templates
|
||||
// covers both the "wrap through None" and "skip None" variants.
|
||||
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
|
||||
|
||||
void set_include_none(bool include_none) { this->include_none_ = include_none; }
|
||||
|
||||
void play(const Ts &...) override {
|
||||
size_t count = this->parent_->get_effect_count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
uint32_t current = this->parent_->get_current_effect_index();
|
||||
uint32_t next;
|
||||
if (this->include_none_) {
|
||||
uint32_t total = static_cast<uint32_t>(count) + 1;
|
||||
if constexpr (Forward) {
|
||||
next = (current + 1) % total;
|
||||
} else {
|
||||
next = (current + total - 1) % total;
|
||||
}
|
||||
} else {
|
||||
if constexpr (Forward) {
|
||||
next = (current % static_cast<uint32_t>(count)) + 1;
|
||||
} else {
|
||||
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
|
||||
}
|
||||
}
|
||||
auto call = this->parent_->turn_on();
|
||||
call.set_effect(next);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
LightState *parent_;
|
||||
bool include_none_{false};
|
||||
};
|
||||
|
||||
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit LightIsOnCondition(LightState *state) : state_(state) {}
|
||||
|
||||
@@ -26,8 +26,8 @@ from esphome.const import (
|
||||
CONF_WARM_WHITE,
|
||||
CONF_WHITE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .types import (
|
||||
@@ -39,12 +39,15 @@ from .types import (
|
||||
DimRelativeAction,
|
||||
LightCall,
|
||||
LightControlAction,
|
||||
LightEffectCycleAction,
|
||||
LightIsOffCondition,
|
||||
LightIsOnCondition,
|
||||
LightState,
|
||||
ToggleAction,
|
||||
)
|
||||
|
||||
CONF_INCLUDE_NONE = "include_none"
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.toggle",
|
||||
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
|
||||
|
||||
|
||||
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
|
||||
"""Record a cycle-action reference for later validation against the target light."""
|
||||
from . import EffectCycleRef, _get_data
|
||||
|
||||
_get_data().effect_cycle_refs.append(
|
||||
EffectCycleRef(
|
||||
light_id=config[CONF_ID],
|
||||
component_path=path_context.get(),
|
||||
)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(LightState),
|
||||
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.next",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_next_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.previous",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_previous_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
|
||||
|
||||
|
||||
async def _light_effect_cycle_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
forward: bool,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
|
||||
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
|
||||
return var
|
||||
|
||||
|
||||
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
|
||||
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ LIMIT_MODES = {
|
||||
# Actions
|
||||
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
|
||||
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
|
||||
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
|
||||
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
|
||||
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
|
||||
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
|
||||
|
||||
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_COMMENT,
|
||||
@@ -605,6 +605,12 @@ class EsphomeCore:
|
||||
self.build_path: Path | None = None
|
||||
# The validated configuration, this is None until the config has been validated
|
||||
self.config: ConfigType | None = None
|
||||
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
|
||||
# YAML document separated by `---` from the actual configuration. It is
|
||||
# ignored by config validation and code generation, but kept here so it
|
||||
# can be inspected by callers (tooling, future features). Keyed by the
|
||||
# resolved Path of the source file.
|
||||
self.frontmatter: dict[Path, Any] = {}
|
||||
# The pending tasks in the task queue (mostly for C++ generation)
|
||||
# This is a priority queue (with heapq)
|
||||
# Each item is a tuple of form: (-priority, unique number, task)
|
||||
@@ -670,6 +676,7 @@ class EsphomeCore:
|
||||
self.config_path = None
|
||||
self.build_path = None
|
||||
self.config = None
|
||||
self.frontmatter = {}
|
||||
self.event_loop = _FakeEventLoop()
|
||||
self.task_counter = 0
|
||||
self.variables = {}
|
||||
|
||||
@@ -100,6 +100,6 @@ dependencies:
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.6.0
|
||||
version: 0.6.1
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
|
||||
+27
-2
@@ -768,10 +768,35 @@ def _load_yaml_internal_with_type(
|
||||
content: TextIOWrapper,
|
||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||
) -> Any:
|
||||
"""Load a YAML file."""
|
||||
"""Load a YAML file.
|
||||
|
||||
Supports an optional leading YAML frontmatter document: when the file
|
||||
contains two YAML documents separated by ``---``, the first document is
|
||||
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
|
||||
resolved file path, while the second document is returned as the actual
|
||||
configuration. Frontmatter is ignored by config validation and code
|
||||
generation.
|
||||
"""
|
||||
loader = loader_type(content, fname, yaml_loader)
|
||||
try:
|
||||
return loader.get_single_data() or OrderedDict()
|
||||
documents: list[Any] = []
|
||||
while loader.check_data():
|
||||
documents.append(loader.get_data())
|
||||
if len(documents) > 2:
|
||||
raise EsphomeError(
|
||||
f"YAML file '{fname}' contains {len(documents)} documents but "
|
||||
f"at most two are supported (an optional frontmatter document "
|
||||
f"followed by the configuration)."
|
||||
)
|
||||
if len(documents) == 2:
|
||||
frontmatter = documents[0]
|
||||
config = documents[1]
|
||||
if frontmatter is not None:
|
||||
CORE.frontmatter[Path(fname).resolve()] = frontmatter
|
||||
return config if config is not None else OrderedDict()
|
||||
if len(documents) == 1:
|
||||
return documents[0] or OrderedDict()
|
||||
return OrderedDict()
|
||||
except yaml.YAMLError as exc:
|
||||
raise EsphomeError(exc) from exc
|
||||
finally:
|
||||
|
||||
@@ -9,13 +9,17 @@ import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.light import (
|
||||
EffectCycleRef,
|
||||
EffectRef,
|
||||
_final_validate,
|
||||
_get_data,
|
||||
available_effects_str,
|
||||
find_effect_index,
|
||||
)
|
||||
from esphome.components.light.automation import _record_effect_ref
|
||||
from esphome.components.light.automation import (
|
||||
_record_effect_cycle_ref,
|
||||
_record_effect_ref,
|
||||
)
|
||||
from esphome.config import Config, path_context
|
||||
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
|
||||
from esphome.core import ID, Lambda
|
||||
@@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
# --- _final_validate: EffectCycleRef ---
|
||||
|
||||
|
||||
def _setup_cycle_final_validate(
|
||||
cycle_refs: list[EffectCycleRef],
|
||||
light_configs: list[ConfigType],
|
||||
declare_ids: list[tuple[ID, list[str | int]]],
|
||||
) -> Token:
|
||||
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
|
||||
data = _get_data()
|
||||
data.effect_cycle_refs = cycle_refs
|
||||
|
||||
full_conf = Config()
|
||||
full_conf["light"] = light_configs
|
||||
for id_, path in declare_ids:
|
||||
full_conf.declare_ids.append((id_, path))
|
||||
|
||||
return fv.full_config.set(full_conf)
|
||||
|
||||
|
||||
def test_final_validate_cycle_accepts_light_with_effects() -> None:
|
||||
"""Cycle ref against a light with effects should not raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
|
||||
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
|
||||
"""Cycle ref against a light with empty effects list should raise."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
|
||||
"""Cycle refs to unknown light IDs should be silently skipped."""
|
||||
data = _get_data()
|
||||
data.effect_cycle_refs = [
|
||||
EffectCycleRef(
|
||||
light_id=ID("nonexistent", is_declaration=True),
|
||||
component_path=["esphome"],
|
||||
)
|
||||
]
|
||||
|
||||
full_conf = Config()
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_final_validate_drains_cycle_refs() -> None:
|
||||
"""Cycle refs should be drained after validation to avoid redundant runs."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
token = _setup_cycle_final_validate(
|
||||
cycle_refs=[
|
||||
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
|
||||
],
|
||||
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
|
||||
declare_ids=[(light_id, ["light", 0, CONF_ID])],
|
||||
)
|
||||
try:
|
||||
_final_validate({})
|
||||
assert _get_data().effect_cycle_refs == []
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
# --- _record_effect_ref ---
|
||||
|
||||
|
||||
@@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
|
||||
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
|
||||
_record_effect_ref(config)
|
||||
assert _get_data().effect_refs == []
|
||||
|
||||
|
||||
# --- _record_effect_cycle_ref ---
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_path_ctx")
|
||||
def test_record_effect_cycle_ref() -> None:
|
||||
"""Cycle-action config should be recorded with light_id and path."""
|
||||
light_id = ID("led1", is_declaration=True)
|
||||
config: ConfigType = {CONF_ID: light_id}
|
||||
result = _record_effect_cycle_ref(config)
|
||||
assert result is config
|
||||
data = _get_data()
|
||||
assert len(data.effect_cycle_refs) == 1
|
||||
assert data.effect_cycle_refs[0].light_id is light_id
|
||||
assert data.effect_cycle_refs[0].component_path == ["esphome"]
|
||||
|
||||
@@ -103,6 +103,16 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
|
||||
# Cycle through configured effects (skip "None")
|
||||
- light.effect.next: test_monochromatic_light
|
||||
- light.effect.previous: test_monochromatic_light
|
||||
# Cycle through effects including "None"
|
||||
- light.effect.next:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.effect.previous:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.dim_relative:
|
||||
id: test_monochromatic_light
|
||||
relative_brightness: 5%
|
||||
|
||||
@@ -34,6 +34,14 @@ def clear_secrets_cache() -> None:
|
||||
yaml_util._SECRET_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_core_frontmatter() -> None:
|
||||
"""Reset CORE.frontmatter between tests."""
|
||||
core.CORE.frontmatter = {}
|
||||
yield
|
||||
core.CORE.frontmatter = {}
|
||||
|
||||
|
||||
def test_include_with_vars(fixture_path: Path) -> None:
|
||||
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
|
||||
|
||||
@@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
|
||||
with track_yaml_loads() as loaded:
|
||||
yaml_util.load_yaml(link)
|
||||
assert target.resolve() in loaded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None:
|
||||
"""A leading `---`-separated YAML document is stored as frontmatter and
|
||||
stripped from the returned config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n"
|
||||
)
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
|
||||
# Config does not contain frontmatter keys
|
||||
assert "author" not in config
|
||||
assert "labels" not in config
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
|
||||
# Frontmatter is stored on CORE keyed by resolved path
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["author"] == "Jesse"
|
||||
assert frontmatter["labels"] == ["office", "climate"]
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None:
|
||||
"""A YAML file with a single document does not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("esphome:\n name: my_node\n")
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None:
|
||||
"""A leading `---` with no content above it is just a document start marker,
|
||||
not frontmatter, and must not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\nesphome:\n name: my_node\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None:
|
||||
"""Frontmatter keys are not validated — any structure is accepted."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"any_key: any_value\n"
|
||||
"nested:\n"
|
||||
" count: 42\n"
|
||||
" items:\n"
|
||||
" - a\n"
|
||||
" - b\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["any_key"] == "any_value"
|
||||
assert frontmatter["nested"]["count"] == 42
|
||||
assert frontmatter["nested"]["items"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None:
|
||||
"""Frontmatter preserves deeply nested dict/list structures intact."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"device:\n"
|
||||
" metadata:\n"
|
||||
" location:\n"
|
||||
" building: HQ\n"
|
||||
" floor: 3\n"
|
||||
" room:\n"
|
||||
" number: 302\n"
|
||||
" occupants:\n"
|
||||
" - name: Jesse\n"
|
||||
" role:\n"
|
||||
" title: maintainer\n"
|
||||
" since: 2021\n"
|
||||
" - name: Alice\n"
|
||||
" role:\n"
|
||||
" title: contributor\n"
|
||||
" since: 2024\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
fm = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
room = fm["device"]["metadata"]["location"]["room"]
|
||||
assert room["number"] == 302
|
||||
assert room["occupants"][0]["name"] == "Jesse"
|
||||
assert room["occupants"][0]["role"]["title"] == "maintainer"
|
||||
assert room["occupants"][0]["role"]["since"] == 2021
|
||||
assert room["occupants"][1]["role"]["title"] == "contributor"
|
||||
|
||||
|
||||
def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None:
|
||||
"""Three or more YAML documents is unsupported and must raise."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n")
|
||||
|
||||
with pytest.raises(EsphomeError, match="at most two are supported"):
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
|
||||
|
||||
def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None:
|
||||
"""An empty (null) frontmatter document is treated as no frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\n---\nesphome:\n name: t\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "t"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_empty_config_doc(tmp_path: Path) -> None:
|
||||
"""An empty config document after a frontmatter document yields an empty config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("only: frontmatter\n---\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config == {}
|
||||
assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter"
|
||||
|
||||
|
||||
def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
|
||||
"""Frontmatter on an !include'd file is also captured on CORE, keyed by
|
||||
that file's resolved path."""
|
||||
inc = tmp_path / "child.yaml"
|
||||
inc.write_text("child_meta: hello\n---\nchild_key: value\n")
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text("esphome:\n name: t\nchild: !include child.yaml\n")
|
||||
|
||||
config = yaml_util.load_yaml(main)
|
||||
# !include is deferred; force resolution so the child file actually loads
|
||||
force_load_include_files(config)
|
||||
assert config["child"].load()["child_key"] == "value"
|
||||
# Main file has no frontmatter
|
||||
assert main.resolve() not in core.CORE.frontmatter
|
||||
# Included file's frontmatter is captured
|
||||
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
|
||||
|
||||
Reference in New Issue
Block a user