Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ Parsing and writing live on the backend: the frontend exchanges structured `Auto
| `automations/get_light_effects` | `{platform?, board_id?}` | `[LightEffect]` | Full light-effects catalog. |
| `automations/get_filters` | `{}` | `[Filter]` | Full sensor / binary_sensor / text_sensor filter catalog. Each entry's `applies_to` lists the component domains the filter is valid on; the REGISTRY_LIST renderer uses it to scope the per-row picker. |
| `automations/get_available` | `{configuration, yaml?}` | `{triggers, actions, conditions, scripts, devices}` | Scoped catalog for a single device. Optional `yaml` scopes off the editor's unsaved draft instead of disk, so a component just added through the wizard exposes its triggers before the global save. `triggers` / `actions` / `conditions` filtered to the components present in the YAML, matched by the catalog's canonical `<domain>.<platform>` form (e.g. an action with `domain == "switch.template"` only surfaces when a switch with `platform: template` is configured); `core` items (control flow, lambda, combinators) and device-level triggers are always included. `scripts` lists declared `script: id`s with their `parameters:` map. `devices` lists every configured component instance with its `id` / `name` for id-picker dropdowns, plus `title` — the component's catalog display name (e.g. `wifi` → "WiFi Component"), shown when `name` is unset so a nameless singleton reads as a human name instead of the raw key. Each instance carries `has_explicit_id`: false means `id` is the parser/writer round-trip identity synthesized for an id-less block (the domain for a singleton, `<domain>_<idx>` positional, `<parent_instance_id>_<sub_key>` for sub-entities), not a YAML-referenceable id — the frontend must never write it into an action's reference param (#2208). A multi-entity platform component (e.g. `sensor: - platform: aht10`) also lists each configured nested sub-entity (`temperature` / `humidity`) as its own instance keyed on the bare sub-domain (`component_id: "sensor"`) with `parent_id` set to the container — on its declared `id:`, or on the synthetic `<parent_instance_id>_<sub_key>` id when the sub-block has none — and always marks the platform item `is_entity_container: true` (even with no sub-blocks configured) so the frontend offers entity triggers on the sub-entities and never on the container. |
| `automations/parse` | `{configuration, yaml?}` | `[ParsedAutomation]` | Walk the device YAML and return every recognised automation (top-level `script:` / `interval:`, a mapping-form block counting as its one entry at index 0 or the script's id, `api.actions:`, device-level `esphome.on_*`, inline component `on_*:`, light `effects:` entries). An unknown *condition* id (or a misrouted / malformed body) flags only its own automation (`error` set, empty tree); siblings still parse. An uncatalogued single-key **action** (from an `external_components` source, or a typo) instead decomposes to an opaque passthrough `ActionNode` — `unknown: true` with the body on `raw_body` (tags carried via the passthrough tag sentinel above) — so its catalogued siblings stay editable; the frontend renders that one node read-only. A *known* action with no structured form (an oversized LVGL `*.update`) additionally sets `unsupported: true`, so the editor shows the neutral "edit in YAML" hint instead of an error alert. A YAML that won't load at all raises `INVALID_ARGS`. Optional `yaml` parses the unsaved draft instead of disk. |
| `automations/parse` | `{configuration, yaml?}` | `[ParsedAutomation]` | Walk the device YAML and return every recognised automation (top-level `script:` / `interval:`, a mapping-form block counting as its one entry at index 0 or the script's id, `api.actions:`, device-level `esphome.on_*`, inline component `on_*:`, light `effects:` entries). An unknown *condition* id (or a misrouted / malformed body) flags only its own automation (`error` set, empty tree); siblings still parse. An uncatalogued single-key **action** (from an `external_components` source, or a typo) instead decomposes to an opaque passthrough `ActionNode` — `unknown: true` with the body on `raw_body` (tags carried via the passthrough tag sentinel above) — so its catalogued siblings stay editable; the frontend renders that one node read-only. A body the tree cannot represent (an oversized LVGL `*.update`, a tagged condition gate such as `condition: !include`) additionally sets `unsupported: true`, so the editor shows the neutral "edit in YAML" hint instead of an error alert. A YAML that won't load at all raises `INVALID_ARGS`. Optional `yaml` parses the unsaved draft instead of disk. |
| `automations/upsert` | `{configuration, automation, location, yaml?, save?, expected?}` | `{yaml_diff: YamlDiff}` | Insert or replace one automation at `location`; returns the splice the frontend applies in place. An id-less `script:` item is addressed by the `script_<index>` id `automations/parse` lists it under; replacing it writes that id onto the item. Optional `yaml` splices into the unsaved draft instead of disk. `save` and `expected` behave as for `automations/delete`; with either, the write is guarded: a replace needs `expected` to still match, and an insert must leave every existing automation intact and put the new one at `location` (an occupied index, or a location with no index aimed at a list-shaped handler, is refused; an append at the end is not, even when it turns a bare action list into `then:` entries); otherwise `precondition_failed` with nothing written. An index past the end, or one holding a list entry the parser never lists (an `!include`d item, a multi-key `effects:` entry), is `invalid_args`; the latter names the index to append at. A top-level `interval:` or `script:` written as a single block mapping, or as a one-line flow mapping or list, is rewritten as a block list by any write (upsert or delete) before the write lands; a value the line splicers cannot see is `invalid_args` naming its shape: a flow value spanning several lines, an inline scalar, a tagged value such as `script: !include scripts.yaml` (edit the included file instead), an alias as the whole value or inside a flow value, or a block whose anchor is aliased (listifying it drops or reshapes the anchored value; a flow block is refused for any aliased anchor under it, since re-dumping drops them all). |
| `automations/delete` | `{configuration, location, yaml?, save?, expected?}` | `{yaml_diff: YamlDiff}` | Remove the automation at `location`. `expected` is the `raw_yaml` `automations/parse` returned for it (trailing newlines are not compared): the delete then happens only while the automation at that location still reads that way, and answers `precondition_failed` with nothing written if it changed or moved (a location is positional) or the file no longer loads; a changed automation's message carries the first lines of a diff from `expected` to its current text. Optional `yaml` splices into the unsaved draft instead of disk. `save: true` also writes the result to the on-disk config, for callers with no draft buffer: the read, the splice and the atomic write run as one job under the file's write lock, followed by the same history commit and rescan as `devices/update_config`. It is refused with `INVALID_ARGS` alongside `yaml`, and when the result would be an empty file, as `devices/update_config` refuses one. |

Expand Down
55 changes: 29 additions & 26 deletions esphome_device_builder/controllers/automations/_decompose.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from ruamel.yaml.scalarstring import LiteralScalarString

from ...helpers.api import CommandError
from ...helpers.automation_keys import CONDITION_GATE_KEYS
from ...helpers.automation_keys import CONDITION_GATE_KEYS, scalar_param_key
from ...helpers.yaml.scalar import is_custom_yaml_tag
from ...models.api import ErrorCode
from ...models.automations import (
Expand All @@ -32,12 +32,7 @@


class UnsupportedActionError(CommandError):
"""A known action with no structured form (oversized LVGL ``*.update``)."""


# Fallback shorthand key when a catalog entry has no ``scalar_shorthand_key``
# (id-reference actions / conditions). Shared with the emitter's collapse check.
DEFAULT_SHORTHAND_KEY = "id"
"""A body the tree cannot represent (formless LVGL ``*.update``, a tagged condition gate)."""


def _safe_tree(
Expand All @@ -54,7 +49,7 @@ def _safe_tree(
opaque passthrough node.) A document that won't load at all is the
separate whole-file failure raised by :func:`parse_device_yaml`
upstream. The third tuple field flags an
:class:`UnsupportedActionError` — a known action with no form.
:class:`UnsupportedActionError` — a body the tree cannot represent.
"""
try:
return build(), None, False
Expand Down Expand Up @@ -197,17 +192,20 @@ def _decompose_action(action_id: str, raw_params: Any, *, multi_key: bool = Fals
conditions = _decompose_condition_list(value)
continue
params[key] = _render_value(value)
elif (
isinstance(raw_params, str)
and action.scalar_shorthand_key in CONDITION_GATE_KEYS
and catalog.is_known_condition(raw_params)
):
# ``wait_until: api.connected``: esphome reads a string in a condition
# position as that condition id with no config.
conditions = _decompose_condition_list(raw_params)
params = {}
else:
# Bare-scalar shorthand (``logger.log: "hi"`` / ``light.turn_on: id``):
# surface the scalar under the action's own ``maybe_simple_value`` key
# so the writer reconstructs the short form on round-trip.
key = action.scalar_shorthand_key or DEFAULT_SHORTHAND_KEY
# ``core.wait_until`` has ``maybe == "condition"``; a shorthand that
# names a gate / sub-list key must never land in ``params`` — fall
# back to ``id`` so it round-trips harmlessly.
if key in CONDITION_GATE_KEYS or key in action.accepts_action_list:
key = DEFAULT_SHORTHAND_KEY
params = {key: _render_value(raw_params)}
# store the scalar under the entry's collapse key so the writer
# reconstructs the short form on round-trip.
params = {scalar_param_key(action): _render_value(raw_params)}

return ActionNode(
action_id=action_id,
Expand All @@ -233,15 +231,21 @@ def _decompose_condition_list(body: Any) -> list[ConditionNode]:
if body is None:
return []
if isinstance(body, list):
return [_decompose_condition(item) for item in body if isinstance(item, dict)]
if isinstance(body, dict):
return [_decompose_condition(body)]
return []
return [_decompose_condition(item) for item in body]
return [_decompose_condition(body)]


def _decompose_condition(raw: dict) -> ConditionNode:
"""Build one :class:`ConditionNode` from a registry-shaped entry."""
if not raw or not isinstance(raw, dict):
def _decompose_condition(raw: Any) -> ConditionNode:
"""Build one :class:`ConditionNode` from a registry-shaped entry or a bare condition id."""
if isinstance(raw, str):
raw = {raw: None}
if isinstance(raw, TaggedScalar):
msg = "Condition uses a YAML tag the editor cannot represent"
raise UnsupportedActionError(ErrorCode.INVALID_ARGS, msg)
if not isinstance(raw, dict):
msg = "Condition must be a mapping or a condition id"
raise CommandError(ErrorCode.INVALID_ARGS, msg)
if not raw:
msg = "Empty condition entry"
raise CommandError(ErrorCode.INVALID_ARGS, msg)
if len(raw) != 1:
Expand All @@ -259,8 +263,7 @@ def _decompose_condition(raw: dict) -> ConditionNode:
elif isinstance(value, dict):
params = {k: _render_value(v) for k, v in value.items()}
elif value is not None:
key = catalog_entry.scalar_shorthand_key or DEFAULT_SHORTHAND_KEY
params = {key: _render_value(value)}
params = {scalar_param_key(catalog_entry): _render_value(value)}
return ConditionNode(
condition_id=str(cond_id),
params=params,
Expand Down
5 changes: 5 additions & 0 deletions esphome_device_builder/controllers/automations/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ def is_known_action(action_id: str) -> bool:
return action_id in _ALL_ACTION_IDS


def is_known_condition(condition_id: str) -> bool:
"""Return True when *condition_id* is catalogued."""
return condition_id in _CONDITION_IDS


def condition_by_id(condition_id: str) -> AutomationCondition | None:
"""Look up one condition's full body by qualified id."""
return _CONDITION_STORE.get_sync(condition_id)
Expand Down
41 changes: 8 additions & 33 deletions esphome_device_builder/controllers/automations/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,10 @@
Two ergonomic shortcuts the emitter applies on fresh writes (the
parser accepts both shapes, so the choice is purely cosmetic):

- An action / condition with a single param whose key is the
catalog entry's ``scalar_shorthand_key`` (and, for actions, no
children / conditions) renders as ``- <id>: <value>`` (registry
shortcut) instead of the explicit ``{<key>: <value>}`` mapping.
A bare-scalar action with no named field (``delay: 1s``) collapses
the same way via its synthetic ``id`` param. An entry whose sole
field is a genuine ``id`` mapping (``time.has_time``) has no scalar
form and always renders as a mapping. See :func:`_shorthand_key`.
- An action / condition whose single param is its collapse key
(:func:`automation_keys.shorthand_key`) and, for actions, has no
children / conditions renders as ``- <id>: <value>`` instead of the
explicit ``{<key>: <value>}`` mapping.
- A condition list of length one collapses to the single condition
mapping.
"""
Expand All @@ -32,18 +28,17 @@
from ruamel.yaml.tag import Tag

from ...helpers.api import CommandError
from ...helpers.automation_keys import shorthand_key
from ...helpers.yaml.scalar import is_custom_yaml_tag, is_lambda_sentinel, is_tagged_sentinel
from ...models.api import ErrorCode
from ...models.automations import (
ActionNode,
AutomationAction,
AutomationCondition,
AutomationTree,
ConditionNode,
LightEffect,
)
from . import catalog
from .parsing import DEFAULT_SHORTHAND_KEY, make_yaml
from .parsing import make_yaml


def render_script_item(tree: AutomationTree, script_id: str) -> str:
Expand Down Expand Up @@ -119,26 +114,6 @@ def emit_action_seq(actions: list[ActionNode]) -> CommentedSeq:
return seq


def _shorthand_key(entry: AutomationAction | AutomationCondition | None) -> str | None:
"""Return the collapse key for a bare-scalar form, or ``None`` for mapping-only.

Most shorthands come straight from the catalog's ``scalar_shorthand_key``
(``logger.log`` → ``format``, ``switch.toggle`` → ``id``). The exception is
a bare-scalar action with no named field: ``delay: 1s`` has no shorthand key
in the schema, and the parser stores the scalar under a synthetic ``id``
(``{id: "1s"}``). Collapse that back only when ``id`` is *not* a real config
entry — an entry whose sole field is a genuine ``id`` mapping
(``time.has_time``) has no scalar form and must stay a mapping.
"""
if entry is None:
return None
if entry.scalar_shorthand_key:
return entry.scalar_shorthand_key
if not any(e.key == DEFAULT_SHORTHAND_KEY for e in entry.config_entries):
return DEFAULT_SHORTHAND_KEY
return None


def emit_action_node(node: ActionNode) -> CommentedMap:
"""Build one ``{<action_id>: <body>}`` mapping for an action node."""
if node.unknown:
Expand Down Expand Up @@ -169,7 +144,7 @@ def emit_action_node(node: ActionNode) -> CommentedMap:
for child_key in sorted(node.children.keys(), key=lambda k: (k != "then", k)):
body[child_key] = emit_action_seq(node.children[child_key])
out = CommentedMap()
shorthand = _shorthand_key(catalog.action_by_id(node.action_id))
shorthand = shorthand_key(catalog.action_by_id(node.action_id))
if (
not node.children
and not node.conditions
Expand Down Expand Up @@ -206,7 +181,7 @@ def emit_condition_node(node: ConditionNode) -> CommentedMap:
if not node.params:
out[node.condition_id] = None
return out
shorthand = _shorthand_key(catalog.condition_by_id(node.condition_id))
shorthand = shorthand_key(catalog.condition_by_id(node.condition_id))
if len(node.params) == 1 and shorthand is not None and shorthand in node.params:
out[node.condition_id] = encode_value(node.params[shorthand])
return out
Expand Down
2 changes: 0 additions & 2 deletions esphome_device_builder/controllers/automations/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
)
from . import catalog
from ._decompose import (
DEFAULT_SHORTHAND_KEY,
_block_tree,
_collect_api_action_params,
_collect_block_params,
Expand All @@ -72,7 +71,6 @@
from ._yaml import make_yaml

__all__ = [
"DEFAULT_SHORTHAND_KEY",
"_decompose_action",
"_decompose_action_list",
"_decompose_condition",
Expand Down
23 changes: 23 additions & 0 deletions esphome_device_builder/helpers/automation_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from ..models.automations import AutomationAction, AutomationCondition

# Key-name prefixes marking an inline automation *trigger* (``on_press``,
# ``on_value``, ``on_state_change``, ...). A ``type: trigger`` config-var
# whose key lacks this prefix is a component action-field (``set_action``,
Expand All @@ -12,6 +14,9 @@
# Action-body keys that introduce a condition gate rather than plain params.
CONDITION_GATE_KEYS: frozenset[str] = frozenset({"condition", "all", "any"})

# Fallback collapse key for an entry with no usable scalar shorthand.
DEFAULT_SHORTHAND_KEY = "id"


def is_trigger_key(key: str) -> bool:
"""Return True when *key* names an inline automation trigger (``on_*``)."""
Expand All @@ -21,3 +26,21 @@ def is_trigger_key(key: str) -> bool:
def bare_trigger_key(trigger_id: str) -> str:
"""Return the ``on_*`` YAML key a catalog trigger id ends in."""
return trigger_id.rsplit(".", 1)[-1]


def shorthand_key(entry: AutomationAction | AutomationCondition | None) -> str | None:
"""Return the key a bare scalar collapses to, ``None`` when the entry has no scalar form."""
if entry is None:
return None
action_lists = entry.accepts_action_list if isinstance(entry, AutomationAction) else ()
key = entry.scalar_shorthand_key
if key and key not in CONDITION_GATE_KEYS and key not in action_lists:
return key
if any(e.key == DEFAULT_SHORTHAND_KEY for e in entry.config_entries):
return None
return DEFAULT_SHORTHAND_KEY


def scalar_param_key(entry: AutomationAction | AutomationCondition) -> str:
"""Return the param key a parsed bare scalar is stored under."""
return shorthand_key(entry) or DEFAULT_SHORTHAND_KEY
7 changes: 4 additions & 3 deletions esphome_device_builder/models/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,10 @@ class ParsedAutomation(DashboardModel):
still parse, and the frontend renders it read-only rather than
editing an empty tree. An uncatalogued *action* no longer faults —
it decomposes to an opaque passthrough node (see ``ActionNode``).
``unsupported`` narrows that: the failure was a *known* action with
no structured form (an oversized LVGL ``*.update``), so the frontend
shows the neutral "edit in YAML" hint rather than an error alert.
``unsupported`` narrows that: the body is valid YAML the tree cannot
represent (an oversized LVGL ``*.update``, a tagged condition gate),
so the frontend shows the neutral "edit in YAML" hint rather than an
error alert.
"""

location: AutomationLocation
Expand Down
Loading
Loading