Skip to content

Mark in the listing an automation whose name more than one item carries - #2858

Closed
bdraco wants to merge 1 commit into
mainfrom
flag-shared-automation-name
Closed

bdraco wants to merge 1 commit into
mainfrom
flag-shared-automation-name

Conversation

@bdraco

@bdraco bdraco commented Sep 27, 2026

Copy link
Copy Markdown
Member

What does this implement/fix?

Since #2854 a write aimed at a name more than one item carries is refused. The listing gave no sign of it, so a caller found out only from the refusal. The dashboard's editor saves as the user types, so it showed that refusal again on every pause.

The listing now says so up front:

  • automations/parse: a row carries shared_name: true when more than one item carries the name of its location. The field is absent otherwise, so a listing without such a pair reads as before.
  • automations/get_available: a component instance and a script carry shared_name: true the same way.
  • automations/get_available also returns unnamed_script_ids, the ids the scripts without an id: are listed under. They are not free for a new script, and scripts keeps listing declared scripts only, since it feeds the pickers of script.execute.

The count behind the flags is the one the refusal uses, so the two cannot disagree. A test checks for every automation that it is marked exactly when a write to it is refused.

The MCP tool list_automations forwards the listing, so a model sees shared_name on a row it cannot write before it tries. Nothing a caller sends changes.

Checked against 57 hand written configs, 302 automations: no row is marked, and what parse and get_available return for them is the same as on main, apart from the new unnamed_script_ids list.

#2853, a new form for the made up ids, is closed as not planned. This and the frontend change below replace it.

Related issue or feature (if applicable):

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.index.json / definitions/components/*.json have not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

@bdraco bdraco added the enhancement Improvement to an existing feature label Sep 27, 2026
@bdraco

bdraco commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

@esphbot review

@codspeed

codspeed Bot commented Sep 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 28 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing flag-shared-automation-name (c81e971) with main (594bdec)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports. ↩

@bdraco
bdraco marked this pull request as ready for review September 27, 2026 22:13
Copilot AI lite review requested due to automatic review settings September 27, 2026 22:13
@codecov

codecov Bot commented Sep 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.75%. Comparing base (594bdec) to head (c81e971).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #2858   +/-   ##
=======================================
  Coverage   99.75%   99.75%           
=======================================
  Files         296      296           
  Lines       25618    25650   +32     
=======================================
+ Hits        25556    25588   +32     
  Misses         62       62           
Flag Coverage Δ
py3.12 99.72% <100.00%> (+<0.01%) ⬆️
py3.14 99.60% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...vice_builder/controllers/automations/addressing.py 100.00% <100.00%> (ø)
...vice_builder/controllers/automations/controller.py 100.00% <100.00%> (ø)
esphome_device_builder/models/automations.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@esphbot

esphbot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Review — Mark in the listing an automation whose name more than one item carries

Solid, well-scoped change. The listing flags now come from the same count the refusal uses. No blocking issues.

The refactor of addressing.py into a Names counter is the right design. require_writable and the new listing flags share one code path through Names.named/shares, so the two cannot drift, and test_an_automation_is_marked_exactly_when_a_write_to_it_is_refused pins that equivalence directly.

I checked that the refactor keeps the old behaviour:

  • lights filters on domain == "light", as the old _instances_named(..., "light") did.
  • Component locations count across all domains, as before.
  • name in unnamed_scripts is equivalent to the old not all(map(declares_id, items)).

The get_available ids line up with the Names counters because both use instance_id and singleton_component_id. The MCP list_automations passes the new field through unchanged. The fields are omitted at their default through _CatalogConfig, so existing consumers see the same output.

  • The docs response-shape column for get_available doesn't list unnamed_script_ids.
  • The new MCP row in the docs is missing a period.
  • parse now loads the YAML twice per call; this is worth sharing the loaded root, since the editor calls it on every pause.

🟢 Suggestions

1. Response shape column omits `unnamed_script_ids`
docs/API.md:338

The prose at the end of this row describes the new unnamed_script_ids list, but the Response column still reads {triggers, actions, conditions, scripts, devices}. Anyone reading the table for the wire shape (the frontend PR for #1918, or MCP tool authors) will miss the new key.

Fix: change the column to {triggers, actions, conditions, scripts, devices, unnamed_script_ids}.

| `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. An instance, and a script, carries `shared_name: true` when more than one item is listed under its `id`. `unnamed_script_ids` lists the ids the scripts without an `id:` are listed under, which are not free for a new script. |
2. `automations/parse` now loads the YAML twice
esphome_device_builder/controllers/automations/controller.py:353-359

_parse_with_shared_names calls parsing.parse_device_yaml(yaml_text) and then Names.of(yaml_text), which loads the same text again with make_yaml(). The dashboard calls parse on every save the editor makes as the user types, so each pause now pays for two ruamel round-trip loads of the whole config. For large configs, such as LVGL-heavy ones, the second load is the most expensive part of this change.

This is not a correctness problem, since Names has to match the refusal in require_writable, which also loads the text itself. If parse_device_yaml can expose or accept the loaded root, Names(root) could reuse it, the way _scope_from_yaml already does with Names(data). That would halve the cost.

parsed = parsing.parse_device_yaml(yaml_text)
names = Names.of(yaml_text)

Checklist

  • Refusal and listing flag share one source of truth
  • Backward-compatible wire shape (new fields omitted at default)
  • Tests cover marked, unmarked and non-loading configs
  • Docs reflect the new API fields — suggestion #1
  • No needless repeated I/O / parsing on hot path — suggestion #2
  • No hardcoded secrets / injection risks

Silent Failure Analysis

🟡 **3. MEDIUM** — silent empty return
esphome_device_builder/controllers/automations/addressing.py:84-93

Risk: parsing.listed_block treats a script: written as a single mapping as one entry, which automations/parse lists under script_0 when it has no id:. _scripts returns [] for that shape, so this item never gets into Names.unnamed_scripts. The new unnamed_script_ids field then leaves out script_0 without any signal and tells the frontend the id is free. require_writable(..., declared_only=True) also stops refusing a new script given that id, which is the guard that keeps a new script from replacing the id-less one. (I did not trace whether a later writer step catches this some other way.)

scripts = root.get("script") if isinstance(root, dict) else None
if not isinstance(scripts, list):
    return []

Fix: Build the script list with parsing.listed_block(root, "script") so that Names counts scripts the same way the parser lists them. Add a test for an id-less script written as a single mapping.

🟡 **4. MEDIUM** — silently diverging name derivation
esphome_device_builder/controllers/automations/controller.py:400-405

Risk: Counter returns 0 for a key it does not have. If device.id from _scope_* differs from the comp_id that iter_instance_targets yields (for example, a singleton id from singleton_component_id, or a top-level key that iter_instance_targets skips as ignored), the lookup gets 0 and shared_name comes out False with no error. The flag would then disagree with what require_writable refuses.

names = Names(data)
for device in devices:
    device.shared_name = names.instances[device.id] > 1

Fix: Take the device ids and the shared flag from the same iter_instance_targets walk, or assert or test that every listed device.id appears in names.instances.


Automated review by Kōan (Claude) HEAD=c81e971 1 min 8s

@bdraco

bdraco commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Closing without merging. The change is not worth what it adds.

  • Since Refuse an automation write aimed at a name more than one item carries #2854 a write aimed at a name more than one item carries is refused, so nothing can be changed by mistake. What this adds is a notice before the refusal.
  • None of 57 hand written configs has such a name.
  • The fields do nothing until a frontend change reads them, and they stay in the API after that.
  • The script half only matters for a script without an id:, and ESPHome requires the id: of a script, so such a config does not validate.

The guard from #2854 stays as it is.

@esphbot esphbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@bdraco
bdraco deleted the flag-shared-automation-name branch September 27, 2026 22:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Address the mapping-valued script guard gap and document unnamed_script_ids in the API response shape.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Adds ambiguity metadata to automation listings so callers can identify names whose writes will be refused.

Changes:

  • Adds shared_name and unnamed_script_ids response fields.
  • Updates listing logic, write guards, tests, and API documentation.
File Summary
tests/​test_automations_ambiguous_location.py Tests shared-name markers and script IDs.
esphome_device_builder/​models/​automations.py Adds response fields.
esphome_device_builder/​controllers/​automations/​controller.py Applies listing metadata.
esphome_device_builder/​controllers/​automations/​addressing.py Counts names and guards writes; mapping-valued scripts need handling.
docs/​API.md Documents API responses; unnamed_script_ids needs inclusion in the response shape.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 87 to +90
if not isinstance(scripts, list):
return []
return [
item
(instance_id("script", item, idx, is_list=True), item)
Comment thread docs/API.md
| `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 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/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. An instance, and a script, carries `shared_name: true` when more than one item is listed under its `id`. `unnamed_script_ids` lists the ids the scripts without an `id:` are listed under, which are not free for a new script. |
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

enhancement Improvement to an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Say in the listing when two automations share a name

3 participants