Compare commits

..

62 Commits

Author SHA1 Message Date
Petar Petrov 19231b9e78 Demote the unused state/numeric_state condition editors to base classes
With the automation condition editors handling state / numeric_state in
the dashboard editor, the lovelace `ha-card-condition-state` and
`ha-card-condition-numeric_state` elements are no longer rendered. Their
only remaining role is as the base class for the entity-filter
`-no_entity` variants, so drop the now-dead custom-element registrations
(and the redundant side-effect imports) while keeping the shared logic.
2026-06-30 09:48:31 +03:00
Petar Petrov e7daf09a1a Fold the card entity into entity-less conditions on read
A card with a host entity can carry entity-less state / numeric_state
visibility conditions that implicitly target that entity (folded in at
runtime). The editor translated them with an empty entity_id, so the
reused automation editor showed an empty, invalid entity field.

Fold the current-mode context entity into the displayed condition, and
recompute it when the entity context arrives (it can follow the
condition). Opening still does not rewrite the stored config; only
editing converts it to explicit core format.
2026-06-30 09:33:41 +03:00
Petar Petrov fff1568898 Load config translations for the reused condition editors
The dashboard visibility editor reuses the automation condition editors
(state, numeric_state, template, sun, zone, device), which label their
form fields from the `config` translation fragment. The lovelace panel
never loads that fragment, so those labels rendered blank — e.g. the
numeric_state limit-type selectors and the above/below fields.

Load the `config` fragment when the conditions editor first renders, so
the embedded editors resolve their labels.
2026-06-30 09:22:54 +03:00
Petar Petrov db8bd28b07 Keep conditional cards mounted while hidden for server conditions
A conditional card gated on a server-evaluated condition (template, sun,
zone, device) never reappeared: hui-card removes a hidden child card from
the DOM, tearing down the evaluator, and the synchronous seed can revive a
client condition but not a server one, so the subscription was never
(re)opened and the server result that would show the card never arrived.

Set connectedWhileHidden so the conditional card/row stay mounted while
hidden and keep their subscriptions alive, like the other cards that must
keep working while hidden. The inner element is still unmounted when
hidden, so there is no extra render cost.
2026-06-29 18:04:44 +03:00
Petar Petrov e773ba4ded Evaluate conditional picture element visibility server-side
The picture-elements `conditional` element was the one visibility
consumer still evaluating fully client-side, so its stateful conditions
were not delegated to core (and server-class types it could not evaluate
fell through to a permanently-hidden result). Convert it to a
ReactiveElement driven by ConditionalListenerMixin, exactly like its
sibling hui-conditional-base, so the evaluator delegates stateful
conditions through subscribe_condition and evaluates the client-only
ones locally.
2026-06-29 17:36:14 +03:00
Petar Petrov a9a2d17741 Accept server-evaluated conditions in validateConditionalConfig
The conditional card/row/element gate their config on
validateConditionalConfig, which only knew the client-side condition
types and rejected the server-evaluated ones (template, sun, zone,
device, and integration-provided conditions). Now that those are
authorable and delegated to core, accept them — core validates them —
so configuring one no longer throws "Invalid configuration".
2026-06-29 17:36:05 +03:00
Petar Petrov 49a7814115 Remove the orphaned conditional-listener no-op methods
`addConditionalListener` / `clearConditionalListeners` became no-op
shims once the evaluator took over subscriptions and teardown; their only
callers were the listener-wiring removed in the migration. Drop both
methods, the mixin's now-redundant `disconnectedCallback` override, and
the stale `clearConditionalListeners()` call in hui-conditional-base.
2026-06-29 17:24:48 +03:00
Petar Petrov 002bf491bf Pin the condition editor live-test for hidden and invalid configs
The per-row live-test set `invalid` (or hidden) directly but left its
evaluator callback unguarded, so the evaluator's torn-down `unknown`
result — fired ~500ms after observing `undefined` — clobbered the pinned
state until the next hass tick re-pinned it, causing a transient flicker.

Add the same `_override` guard the sibling visibility-status banner
already uses: the hidden / client-invalid branches set the result
directly and pin it, and the evaluator callback ignores results while
pinned.
2026-06-29 16:57:38 +03:00
Petar Petrov 43fcd1b0a4 Remove the superseded client-only condition listeners
The dashboard now evaluates visibility through the reactive condition
evaluator (which uses `observeConditionChanges`), so the old synchronous
client-only listener path has no remaining callers.

- Delete `ConditionListenersController` and the `setupConditionListeners` /
  `setupMediaQueryListeners` / `setupTimeListeners` helpers.
- Keep `observeConditionChanges` and the shared time-boundary scheduler, and
  re-point their tests onto it so the scheduling edge cases stay covered.
2026-06-29 16:19:07 +03:00
Petar Petrov ab031ab139 Edit dashboard state conditions via the core condition editors
Route `state` / `numeric_state` visibility conditions to the core automation
condition editors (outside entity-filter mode), so they share one editing
surface with the server-class types and are authored in core format.

- Read both: existing lovelace-format conditions (`entity`, `state_not`, …)
  are translated to core for display; a `state_not` shows as `not(state)`.
- Write new with touch-to-convert: opening a condition leaves it untouched;
  editing or adding one persists it in core format (`entity_id`, `state` list).
- Entity-filter mode keeps the lovelace no-entity syntax and editors.
- Register the core `state` / `numeric_state` editors (dynamicElement only
  renders a tag, it does not define the element) and widen the editor chain's
  condition arrays to the mixed visibility union.
2026-06-29 16:18:51 +03:00
Petar Petrov 07030e6575 Evaluate the visibility status banner server-side
Drive the card-level visibility summary banner with the same
ConditionEvaluatorController used by the per-condition live-test, so a set
containing server-class conditions reports its real visible/hidden verdict
instead of being flagged as an invalid configuration.

- Add a distinct "unknown" banner state for while a server result is still
  pending, separate from "invalid" (a genuine configuration error).
- Keep a client-side validity check for purely client trees, and fold the
  card entity into the observed conditions, matching the per-condition editor.
- Extract isPureClientCondition (every leaf client-side, as opposed to
  isClientCondition's any-leaf) so both consumers share one classifier.
2026-06-29 15:37:15 +03:00
Petar Petrov c32ae22f63 Evaluate the visibility condition editor live-test through the reactive evaluator
Drive the per-condition live-test indicator with the same
ConditionEvaluatorController the dashboard uses at runtime, so server-class
conditions (template/sun/zone/device and core-format state/numeric_state) get
a real subscribe_condition-backed verdict instead of a neutral indicator.
Client-only conditions stay evaluated locally and mixed logical trees combine
both via three-valued logic.

- Fold the card entity into the observed condition exactly as the runtime
  mixin does, and memoize the folded array so the evaluator's signature memo
  keeps hitting on hass-only updates.
- Map the evaluator verdict to the indicator: visible -> pass, hidden -> fail,
  pending -> unknown, server error -> invalid (raw error shown as the tooltip
  detail, localized label kept as the aria-label).
- Keep a client-side validity check for purely client trees so a malformed
  client-only config still surfaces as invalid.
- Recurse the no-entity (filter-mode) suppression so nested entity-less
  conditions are handled, and report an as-yet-unknown manual test as no
  result rather than a failure.
- Drop the now-unused invalid-config alert and its orphaned translation keys.
2026-06-29 15:25:20 +03:00
Petar Petrov 585db17e86 Add server condition types to the dashboard visibility editor
Let the visibility editor add and edit the core-format server condition
types (template, sun, zone, device) by embedding the automation condition
editors, which already speak core format. ha-card-condition-editor
dispatches these types to ha-automation-condition-editor; the existing
lovelace editors and the and/or/not containers are unchanged, and because
the logical editors nest ha-card-condition-editor, mixed trees dispatch
each child correctly.

- extend the add-condition menu with the new types (icons + labels)
- suppress the client-side live-test for server-class conditions (and any
  logical tree containing one); checkConditionsMet can't evaluate them, so
  the indicator stays neutral instead of showing a misleading failure

Server-backed live-test and read-both/write-new conversion of lovelace
state/numeric_state conditions are follow-ups.
2026-06-29 14:46:28 +03:00
Petar Petrov 28739f7fd3 Evaluate dashboard visibility through the reactive condition evaluator
Rework ConditionalListenerMixin to derive visibility from
ConditionEvaluatorController instead of evaluating checkConditionsMet
synchronously. Stateful conditions (state, numeric_state, template, sun,
zone, device, integration) are delegated to core via subscribe_condition;
client-only conditions (screen, user, view_columns, location, time) stay
local. The mixin re-feeds the evaluator on connect and on hass/config/
column changes, and drives _updateVisibility from its tri-state verdict.

Consumers (hui-card, hui-badge, hui-section, hui-heading-badge,
hui-view-sidebar, hui-conditional-base) now read the mixin's
_conditionsVisible(), which prefers the server-aware verdict and falls back
to an optimistic synchronous seed while a server subtree is pending — exact
for legacy lovelace conditions (no flash for existing dashboards) and hidden
for core-only conditions until the server reports.

- fold the host entity_id context into the evaluator path via
  addEntityToCondition, and read core-format entity_id in
  checkStateCondition / checkStateNumericCondition so seed and server agree
- addEntityToCondition no longer grafts a context entity onto an
  already-core condition that carries its own entity_id
- the conditional card/row now evaluates legacy {entity, state} conditions
- cache the entity-folded array so the evaluator's signature memo holds
  across hass updates, and drop the cached verdict when the tree changes by
  value so the seed is used for the new tree
2026-06-29 13:34:55 +03:00
Petar Petrov 8db3f168a5 Fix condition evaluator controller lifecycle edge cases
Follow-up to the adversarial review of the controller (#52836):

- Key re-subscription on a structural signature of the condition tree
  rather than array reference identity, so a host re-deriving the array
  each render neither starves the debounce nor churns subscriptions.
- Reset the published result to `unknown` on host disconnect so a
  detached/reconnecting host never renders a stale, no-longer-live result.
- Read hass lazily in the time-boundary listeners so timezone changes are
  picked up on the next boundary instead of being pinned at subscribe time.
2026-06-29 12:44:26 +03:00
Petar Petrov aa2c8564ed Harden condition translation for incomplete and odd numeric inputs
Follow-up to the adversarial review of the translator (#52836):

- Incomplete/garbage state conditions (no entity, no value, or an empty
  object) now translate to an always-false core condition instead of a
  schema-invalid `state`, matching checkConditionsMet and avoiding a
  broken grouped subscription.
- numeric_state bounds: coerce only finite numeric strings (incl. "" -> 0)
  to numbers, pass genuine entity-id references through, and drop junk or
  non-finite strings (matching lovelace's "NaN -> ignored" and never
  emitting a non-JSON-serializable Infinity).
2026-06-29 12:44:19 +03:00
Petar Petrov aaf5986fd7 Add reactive condition evaluator controller
Phase B of delegating dashboard visibility conditions to core (#52836).

- ConditionEvaluatorController opens one subscribe_condition per server
  subtree, evaluates client leaves locally, observes screen/time
  boundaries, and exposes a tri-state visible/hidden/unknown result plus
  error. It recomputes on push/listener/hass/context change, debounces
  re-subscription when the tree changes, and tears down on disconnect.
- Add observeConditionChanges to listeners.ts (notify-only, decoupled
  from checkConditionsMet) and widen extract.ts to the VisibilityCondition
  tree, factoring time-boundary scheduling into a shared helper.
2026-06-29 12:26:35 +03:00
Petar Petrov 8c20a1041f Add dashboard visibility condition classifier, translator and splitter
Pure-logic foundation for delegating dashboard visibility conditions to
core (#52836).

- Add a VisibilityCondition type spanning the client-only lovelace
  conditions (screen, user, view_columns, location, time) and core
  automation conditions, alongside the existing lovelace Condition.
- translate.ts: classify conditions as client- or server-evaluated and
  translate server ones to core format (entity -> entity_id, state_not
  -> not-wrap, numeric bound coercion), preserving lovelace's
  not = not(AND) semantics.
- split.ts: split a tree into maximal server subtrees (one subscription
  each, sibling-grouped) plus a three-valued client combiner.
2026-06-29 12:15:19 +03:00
renovate[bot] 05afa19a76 Update dependency js-yaml to v5.1.0 (#52899) 2026-06-29 08:08:48 +00:00
Bram Kragten 66775f03dd Update dependency js-yaml to v5 (#52843)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:00:21 +02:00
renovate[bot] 53e47e58f1 Update dependency intl-messageformat to v11.2.9 (#52897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-29 08:53:58 +03:00
Paul Bottein 7b2569346f Show dedicated icons for Cloud and Cast in Actvity and add tooltip (#52896) 2026-06-29 08:37:30 +03:00
renovate[bot] 72fe6e1cbb Update dependency tar to v7.5.17 (#52895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-29 08:34:08 +03:00
Paulus Schoutsen 26f270720a Fix duplicate logbook entries in more info dialog after reconnect (#52880)
* Fix duplicate logbook entries after reconnect in more info dialog

When a more info dialog is left open while the app is backgrounded, the
WebSocket connection drops and reconnects on resume. The logbook stream
subscription relied on home-assistant-js-websocket's auto-resubscribe,
which replays the original subscription with its stale start_time. The
backend then resends the entire historical chunk, and ha-logbook appends
streamed events without deduplicating, so every entry was shown twice
(and a third time after another background/reconnect cycle).

Mirror the approach already used for the history stream: disable the
library's auto-resubscribe for the logbook event stream and have
ha-logbook listen for the connection "ready" event, resubscribing from a
clean state on reconnect instead of appending a replayed history chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYMwT7ZQJrjyzrNfGQyWaU

* Simplify logbook reconnect comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-28 21:34:07 +00:00
renovate[bot] e01bef53dc Update CodeMirror (#52894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 08:16:16 +00:00
dependabot[bot] 04226dda32 Bump actions/download-artifact from 4.1.7 to 8.0.1 (#52889)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.7 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/65a9edc5881444af0b9093a5e628f2fe47ea3b2e...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 10:08:55 +02:00
dependabot[bot] b8fc05d5c4 Bump actions/github-script from 7.0.1 to 9.0.0 (#52890)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.0.1 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7.0.1...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 10:08:24 +02:00
dependabot[bot] d602e77fc3 Bump actions/checkout from 6.0.2 to 7.0.0 (#52891)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.0.2...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 10:08:04 +02:00
dependabot[bot] a19842bd4d Bump home-assistant/actions/helpers/verify-version from e91ad1948e57189485b9c1ad608af0c303946f89 to f4ca6f671bd429efb108c0f2fa0ae8af0215986c (#52893)
Bump home-assistant/actions/helpers/verify-version

Bumps [home-assistant/actions/helpers/verify-version](https://github.com/home-assistant/actions) from e91ad1948e57189485b9c1ad608af0c303946f89 to f4ca6f671bd429efb108c0f2fa0ae8af0215986c.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/e91ad1948e57189485b9c1ad608af0c303946f89...f4ca6f671bd429efb108c0f2fa0ae8af0215986c)

---
updated-dependencies:
- dependency-name: home-assistant/actions/helpers/verify-version
  dependency-version: f4ca6f671bd429efb108c0f2fa0ae8af0215986c
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 10:07:40 +02:00
dependabot[bot] 13872baa8c Bump release-drafter/release-drafter from 7.3.1 to 7.4.0 (#52892)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.3.1 to 7.4.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/693d20e7c1ce1a81d3a41962f85914253b518449...ed4bc48ec97379be2258e7b7ac2624a3e26ab809)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 10:07:09 +02:00
karwosts a1aaf3fe33 Disconnect helpers table updates from hass states updates (#52878) 2026-06-27 13:49:18 +03:00
Jan-Philipp Benecke 84840dc922 Fix overflow issue in mobile automation target picker (#52883)
Fix overflow issue in mobile target picker
2026-06-27 10:20:02 +02:00
Petar Petrov 8e43688ed8 Add untracked consumption to intermediate devices in energy and water sankey cards (#52884) 2026-06-27 10:19:38 +02:00
Abílio Costa d9037b84c8 Add untracked power to intermediate upstream devices (#52882) 2026-06-27 10:11:01 +03:00
renovate[bot] c070765f54 Update dependency @rsdoctor/rspack-plugin to v1.5.16 (#52877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 07:08:41 +00:00
renovate[bot] 8e5d976f7b Update CodeMirror (#52879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 09:59:03 +03:00
renovate[bot] 2dbb052200 Update dependency @playwright/test to v1.61.1 (#52881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 09:58:35 +03:00
TheOtherAdam 3b04f29755 Handle disabled core log file (#52523)
* Handle disabled core log file

* Use typed logging config

* Address disabled log file UI review

* Align disabled log file metadata

---------

Co-authored-by: Adam Steen <8374368+adamsteen@users.noreply.github.com>
2026-06-26 18:49:15 +03:00
Aidan Timson 865b5b1b80 Localize hardcoded UI strings in lovelace, logs, cloud, and media browse (#52869)
* Localize hardcoded UI strings in lovelace, logs, cloud, and media browse

Wire existing translation keys where available and add scoped keys for lovelace error sections and cloud support package privacy text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep loading ellipsis outside translatable strings

Localize the loading and preview labels without dots, then append ellipsis in the template so translators are not asked to copy punctuation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix manual entry localize key path

Use ui.components.selectors.selector.types.manual so the key resolves in en.json and TypeScript.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use property bindings for localized dialog and badge labels

Bind headerTitle and label as properties so localized strings pass correctly to ha-dialog and ha-badge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Format

* Use better path

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 05:08:55 +02:00
Aidan Timson b44c69b1b0 Add more info view smoke tests to e2e app spec (#52862)
* Add more info views to e2e app spec

* Add registry for light more info test

* Improve tests
2026-06-26 05:06:44 +02:00
Aidan Timson 27787e51f8 Add test:e2e:app:dev to not need to build for every test run (#52865)
* Add test:e2e:app:dev to not need to build for every test run

* Stop browser open

* Add test:e2e:app:dev
2026-06-26 05:05:09 +02:00
renovate[bot] dc7daf3156 Update dependency typescript-eslint to v8.62.0 (#52876)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:59 +02:00
renovate[bot] b898468193 Update dependency globals to v17.7.0 (#52875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:42 +02:00
renovate[bot] 781aa116b8 Update dependency eslint-plugin-import-x to v4.17.0 (#52874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:24 +02:00
Simon Lamon 60c86899f3 Swap google-timezones-json to @vvo/tzdb (#52770) 2026-06-25 16:14:42 +02:00
Petar Petrov f8d870d6bb Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:12:52 +02:00
Copilot 4d82b352a9 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:02:29 +02:00
Paul Bottein 179b4cf77c Show dash for unavailable number entity in slider row (#52866) 2026-06-25 14:17:54 +02:00
Paul Bottein 542f07606a Fix logbook padding and margin (#52864) 2026-06-25 14:17:24 +02:00
Paul Bottein cf2c440e7b Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 14:16:01 +02:00
Franck Nijhof 27fbabb71b Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 12:57:45 +02:00
Paul Bottein 389af6e00c Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:31:58 +01:00
Bram Kragten 7ff4cf58e8 Split config sections from panel config, add CI for entrypoint size (#52830)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 07:11:53 +00:00
renovate[bot] f849302876 Update dependency @rspack/dev-server to v2.1.0 (#52856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 09:56:17 +03:00
Paulus Schoutsen 1db707937b Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


Claude-Session: https://claude.ai/code/session_01SYyMTtBdrt7EBrVEt869Uw

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 08:08:29 +03:00
Franck Nijhof 70f0d12e43 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 08:06:54 +03:00
Michael Hansen 12bb09dad2 Add demo voice assistants and exposed entities (#52855) 2026-06-24 18:23:52 -04:00
Aidan Timson f08ffefe28 Output combined e2e report on failure to markdown comment (#52844)
* Output combined e2e report on failure to markdown comment

* Move to file, parse json file (markdown output didnt exist)

* Add syntax highlighting
2026-06-24 20:14:36 +02:00
Aidan Timson 9de89278cd Move inline workflow mjs scripts to dedicated files, add to eslint config (#52846)
* Move inline workflow mjs scripts to dedicated files, add to eslint config

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-24 20:10:01 +02:00
renovate[bot] 207d997a3a Update playwright monorepo (#52839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 18:16:49 +02:00
Bram Kragten 0bb32aa1b4 Provide Lit contexts to gallery demos; stop ignoring init errors (#52845) 2026-06-24 17:09:15 +02:00
Bram Kragten ba0310ee58 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-24 16:00:23 +02:00
118 changed files with 5282 additions and 1830 deletions
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
@@ -0,0 +1,195 @@
#!/usr/bin/env node
// Checks that a pull request follows the contribution standards: it must use the
// PR template, tick exactly one "Type of change" option, and describe the change.
// Labels and comments the PR when it does not, and fails the check so it blocks
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await checkStandards({ github, context, core });
export default async function checkPullRequestStandards({
github,
context,
core,
}) {
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (_error) {
core.info(
`${pr.user.login} is not an organization member, checking standards`
);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
let body = pr.body || "";
let previous;
do {
previous = body;
body = body.replace(/<!--[\s\S]*?-->/g, "");
} while (body !== previous);
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push('Select exactly one option under "Type of change".');
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
}
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Restricts Task issues to organization members: closes and labels the issue with
// an explanatory comment when the author is not an org member. Invoked from the
// `check-authorization` job in .github/workflows/restrict-task-creation.yml via
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await checkTaskAuthorization({ github, context, core });
export default async function checkTaskAuthorization({
github,
context,
core,
}) {
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
core.info(`${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body:
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
}
+8 -23
View File
@@ -20,31 +20,16 @@ jobs:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
await checkBlockingLabels({ github, context, core });
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: dev
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: master
persist-credentials: false
+5 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
@@ -105,6 +105,8 @@ jobs:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Check entrypoint bundle size budget
run: yarn run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: dev
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+15 -19
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -129,7 +129,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -155,19 +155,19 @@ jobs:
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -195,7 +195,7 @@ jobs:
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: blob-report-local
@@ -229,16 +229,12 @@ jobs:
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
- name: Post report to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
const { default: postReportComment } = await import(
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+9 -161
View File
@@ -1,7 +1,7 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
types:
- opened
- edited
@@ -23,168 +23,16 @@ jobs:
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
);
await checkStandards({ github, context, core });
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
+10 -41
View File
@@ -36,52 +36,21 @@ jobs:
name: Check authorization
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await checkTaskAuthorization({ github, context, core });
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+15
View File
@@ -0,0 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
+155
View File
@@ -0,0 +1,155 @@
/* global require, process, __dirname */
// Enforce a strict size budget on the initial JS of the most critical
// entrypoints (`app` and `core`). These two are downloaded on every cold load
// before anything interactive can happen, so unintended growth here hurts
// first-load performance directly.
//
// In production rspack does not split initial chunks (splitChunks only operates
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
// the entrypoint's initial JS size against a committed budget.
//
// Usage:
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
const fs = require("fs");
const path = require("path");
const paths = require("./paths.cjs");
// Entrypoints whose initial JS we hold to a strict budget. These are all
// downloaded on a user-facing cold load before anything interactive can happen:
// `app`/`core` for the main app, plus the standalone `authorize` and
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
// when a custom panel is opened).
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
const BUILDS = ["frontend-modern", "frontend-legacy"];
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
const STATS_DIR = path.join(paths.build_dir, "stats");
const readStats = (build) => {
const file = path.join(STATS_DIR, `${build}.json`);
if (!fs.existsSync(file)) {
throw new Error(
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
);
}
return JSON.parse(fs.readFileSync(file, "utf8"));
};
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
const entrypointInitialJS = (stats, entrypoint) => {
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
let total = 0;
let found = false;
for (const chunk of stats.chunks) {
if (!chunk.entry || !chunk.initial) {
continue;
}
if (!(chunk.names || []).includes(entrypoint)) {
continue;
}
found = true;
for (const file of chunk.files || []) {
if (file.endsWith(".js") && assetSize.has(file)) {
total += assetSize.get(file);
}
}
}
if (!found) {
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
}
return total;
};
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
const main = () => {
const update = process.argv.includes("--update");
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
const current = {};
for (const build of BUILDS) {
const stats = readStats(build);
current[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
}
}
if (update) {
const budget = { _comment: BUDGET_COMMENT };
for (const build of BUILDS) {
budget[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
budget[build][entrypoint] = Math.ceil(
current[build][entrypoint] * (1 + headroom / 100)
);
}
}
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
console.log(
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
(headroom ? ` (+${headroom}% headroom).` : ".")
);
return;
}
if (!fs.existsSync(BUDGET_FILE)) {
throw new Error(
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
);
}
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
let failed = false;
console.log("Initial JS budget (entry chunks, raw bytes):\n");
for (const build of BUILDS) {
for (const entrypoint of TRACKED_ENTRYPOINTS) {
const actual = current[build][entrypoint];
const limit = budget[build] && budget[build][entrypoint];
if (typeof limit !== "number") {
failed = true;
console.log(
`${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
);
continue;
}
const ok = actual <= limit;
const delta = (((actual - limit) / limit) * 100).toFixed(1);
console.log(
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
);
if (!ok) {
failed = true;
}
}
}
if (failed) {
console.error(
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
"If the growth is intentional, re-seed the budget:\n" +
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
);
process.exit(1);
}
console.log("\nAll tracked entrypoints within budget.");
};
const BUDGET_COMMENT =
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
main();
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import yaml from "js-yaml";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
+1
View File
@@ -240,6 +240,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
})
);
+3 -1
View File
@@ -66,7 +66,9 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
const hass = provideHass(this, initial, true);
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+6
View File
@@ -240,6 +240,12 @@ export default tseslint.config(
globals: globals.node,
},
},
{
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+91
View File
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -19,6 +20,22 @@ import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../../src/data/context";
import { updateHassGroups } from "../../src/data/context/updateContext";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
@@ -113,6 +130,65 @@ class HaGallery extends LitElement {
@state() private _drawerOpen = !this._narrow;
// Fallback Lit context providers for the whole gallery. The real app's root
// element provides these via `contextMixin`; here we mirror that so demos
// which render context-consuming components without setting up their own hass
// (e.g. bare component demos) still resolve `localize`, formatters, config,
// etc. instead of throwing during init. Demos that call `provideHass`
// register their own providers closer in the tree, which take precedence.
private _contextProviders = {
registries: new ContextProvider(this, { context: registriesContext }),
internationalization: new ContextProvider(this, {
context: internationalizationContext,
}),
api: new ContextProvider(this, { context: apiContext }),
connection: new ContextProvider(this, { context: connectionContext }),
ui: new ContextProvider(this, { context: uiContext }),
config: new ContextProvider(this, { context: configContext }),
formatters: new ContextProvider(this, { context: formattersContext }),
};
// The individual (non-grouped) contexts contextMixin also provides. Components
// such as ha-area-picker / ha-entity-picker consume these directly, so the
// fallback must cover them too.
private _singleContextProviders = {
states: new ContextProvider(this, { context: statesContext }),
services: new ContextProvider(this, { context: servicesContext }),
entities: new ContextProvider(this, { context: entitiesContext }),
devices: new ContextProvider(this, { context: devicesContext }),
areas: new ContextProvider(this, { context: areasContext }),
floors: new ContextProvider(this, { context: floorsContext }),
};
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
// the gallery hass propagate to consuming components.
const hass = this._galleryHass;
(
Object.keys(
this._contextProviders
) as (keyof typeof this._contextProviders)[]
).forEach((group) => {
const provider = this._contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
hass,
provider.value
)
);
});
(
Object.keys(
this._singleContextProviders
) as (keyof typeof this._singleContextProviders)[]
).forEach((key) => {
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
hass[key]
);
});
}
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
@@ -576,6 +652,21 @@ class HaGallery extends LitElement {
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
formatEntityState: (stateObj, stateValue) =>
(stateValue != null ? stateValue : stateObj.state) ?? "",
formatEntityStateToParts: (stateObj, stateValue) => [
{
type: "value",
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
},
],
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
} as unknown as HomeAssistant;
}
+1 -28
View File
@@ -1,5 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -15,11 +14,6 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -528,17 +522,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -560,16 +543,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+17 -16
View File
@@ -23,10 +23,12 @@
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:dev": "test/e2e/app/script/develop_app",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -36,14 +38,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/commands": "6.10.4",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@codemirror/state": "6.7.0",
"@codemirror/view": "6.43.3",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
@@ -80,6 +82,7 @@
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
@@ -96,13 +99,12 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"js-yaml": "4.2.0",
"intl-messageformat": "11.2.9",
"js-yaml": "5.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -144,17 +146,16 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.16",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
@@ -171,7 +172,7 @@
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-import-x": "4.17.0",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -180,7 +181,7 @@
"fs-extra": "11.3.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"globals": "17.7.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -201,11 +202,11 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.16",
"tar": "7.5.17",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.1",
"typescript-eslint": "8.62.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -218,7 +219,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.6.0",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.1"
version = "20260624.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+15 -6
View File
@@ -1,17 +1,24 @@
import type {
Condition,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
export function extractMediaQueries(
conditions: VisibilityCondition[]
): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
if (
"condition" in c &&
c.condition === "screen" &&
"media_query" in c &&
c.media_query
) {
array.push(c.media_query);
}
return array;
@@ -22,14 +29,16 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
conditions: VisibilityCondition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
if (c.condition === "time") {
array.push(c);
if ("condition" in c && c.condition === "time") {
// Dashboard `time` is always the client-side lovelace shape; core `time`
// is intentionally excluded from VisibilityCondition.
array.push(c as TimeCondition);
}
return array;
}, []);
+50 -78
View File
@@ -1,10 +1,9 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
Condition,
ConditionContext,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -16,95 +15,68 @@ import { calculateNextTimeUpdate } from "./time-calculator";
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
* Schedule a callback to fire at the next boundary of a time condition,
* rescheduling itself afterwards. Delays beyond the setTimeout maximum are
* capped and re-scheduled without firing (so the boundary is only reported
* once it is actually reached). Registers a single cleanup function that
* clears the pending timeout.
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
function scheduleTimeBoundaryListener(
getHass: () => HomeAssistant,
timeCondition: Omit<TimeCondition, "condition">,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onBoundary: () => void
): void {
const mediaQueries = extractMediaQueries(conditions);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (mediaQueries.length === 0) return;
const scheduleUpdate = () => {
// Read hass lazily so timezone changes are picked up on the next boundary.
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
if (delay === undefined) return;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
onBoundary();
}
});
addListener(unsub);
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
}
/**
* Helper to setup time-based listeners for conditional visibility
* Observe the client-evaluated parts of a condition tree — `screen` media
* queries and `time` boundaries — and invoke `onChange` whenever one of them
* could have flipped.
*
* This does not evaluate the conditions itself: the caller recombines client
* and server results on notification. Used by `ConditionEvaluatorController`,
* which merges these client signals with the results of `subscribe_condition`
* subscriptions.
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
export function observeConditionChanges(
conditions: VisibilityCondition[],
getHass: () => HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onChange: () => void
): void {
const timeConditions = extractTimeConditions(conditions);
extractMediaQueries(conditions).forEach((mediaQuery) => {
addListener(listenMediaQuery(mediaQuery, () => onChange()));
});
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
extractTimeConditions(conditions).forEach((timeCondition) => {
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
});
}
/**
* Sets up all condition listeners (media query, time) for conditional visibility.
*/
export function setupConditionListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
}
+176
View File
@@ -0,0 +1,176 @@
import type { Condition as CoreCondition } from "../../data/automation";
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import {
isLogicalCondition,
isServerCondition,
translateToCoreCondition,
} from "./translate";
/** A maximal server subtree, to be opened as one `subscribe_condition`. */
export interface ServerSubtree {
id: string;
coreCondition: CoreCondition;
}
/**
* Evaluate a single client-only condition leaf (`screen`, `user`,
* `view_columns`, `location`, `time`). Returns `undefined` when the outcome is
* not yet determinable (e.g. context not available).
*/
export type ClientConditionEvaluator = (
condition: VisibilityCondition
) => boolean | undefined;
/** Server subtree results keyed by {@link ServerSubtree.id}; `undefined` = not yet reported. */
export type ServerConditionResults = Record<string, boolean | undefined>;
export interface SplitConditionTree {
/** Maximal server subtrees, each to be opened as one `subscribe_condition`. */
serverSubtrees: ServerSubtree[];
/**
* Combine client + server results into the overall visibility using
* three-valued (Kleene) logic. Returns `undefined` while the outcome still
* depends on a server subtree that has not reported yet.
*/
evaluate: (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
}
type EvalNode = (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
// Three-valued logic combinators (true / false / undefined = unknown). `false`
// dominates AND and `true` dominates OR regardless of any unknown sibling.
const andNode =
(children: EvalNode[]): EvalNode =>
(clientEvaluator, serverResults) => {
let unknown = false;
for (const child of children) {
const value = child(clientEvaluator, serverResults);
if (value === false) return false;
if (value === undefined) unknown = true;
}
return unknown ? undefined : true;
};
const orNode =
(children: EvalNode[]): EvalNode =>
(clientEvaluator, serverResults) => {
let unknown = false;
for (const child of children) {
const value = child(clientEvaluator, serverResults);
if (value === true) return true;
if (value === undefined) unknown = true;
}
return unknown ? undefined : false;
};
const notNode =
(child: EvalNode): EvalNode =>
(clientEvaluator, serverResults) => {
const value = child(clientEvaluator, serverResults);
return value === undefined ? undefined : !value;
};
const serverLeaf =
(id: string): EvalNode =>
(_clientEvaluator, serverResults) =>
serverResults[id];
const clientLeaf =
(condition: VisibilityCondition): EvalNode =>
(clientEvaluator) =>
clientEvaluator(condition);
/**
* Split a dashboard visibility condition tree into:
*
* - a flat list of **maximal server subtrees** (`serverSubtrees`), each
* translated to core format and meant to back one `subscribe_condition`; and
* - an **`evaluate`** function that recombines those subtree results with
* locally-evaluated client leaves into the overall visibility.
*
* The top-level array is treated as an implicit `AND`. Sibling server
* conditions sharing a logical parent (including that implicit top-level AND)
* are grouped into a *single* subscription using the parent's operator, to
* avoid subscription fan-out. A `not` combines its children with `AND` before
* negating, matching lovelace `not` semantics (¬(AND of children)).
*/
export const splitConditionTree = (
conditions: VisibilityCondition[]
): SplitConditionTree => {
const serverSubtrees: ServerSubtree[] = [];
let nextId = 0;
const addSubtree = (coreCondition: CoreCondition): EvalNode => {
const id = String(nextId);
nextId += 1;
serverSubtrees.push({ id, coreCondition });
return serverLeaf(id);
};
// Partition children into client/server, group the server siblings into one
// subscription, and recurse into the client ones. `groupOperator` is the
// operator used to combine the grouped server siblings.
const buildSiblings = (
children: VisibilityCondition[],
groupOperator: "and" | "or"
): EvalNode[] => {
const serverChildren: VisibilityCondition[] = [];
const clientChildren: VisibilityCondition[] = [];
for (const child of children) {
(isServerCondition(child) ? serverChildren : clientChildren).push(child);
}
const nodes: EvalNode[] = [];
if (serverChildren.length === 1) {
nodes.push(addSubtree(translateToCoreCondition(serverChildren[0])));
} else if (serverChildren.length > 1) {
nodes.push(
addSubtree({
condition: groupOperator,
conditions: serverChildren.map(translateToCoreCondition),
})
);
}
for (const child of clientChildren) {
nodes.push(build(child));
}
return nodes;
};
// Only ever reached for client-class nodes (server subtrees are grouped and
// translated whole by `buildSiblings`).
const build = (condition: VisibilityCondition): EvalNode => {
if (isLogicalCondition(condition)) {
const children = condition.conditions ?? [];
if (condition.condition === "or") {
return orNode(buildSiblings(children, "or"));
}
if (condition.condition === "not") {
return notNode(andNode(buildSiblings(children, "and")));
}
return andNode(buildSiblings(children, "and"));
}
// Defensive: a server leaf reaching here still becomes a subscription.
if (isServerCondition(condition)) {
return addSubtree(translateToCoreCondition(condition));
}
return clientLeaf(condition);
};
const root = andNode(buildSiblings(conditions, "and"));
return {
serverSubtrees,
evaluate: (clientEvaluator, serverResults) =>
root(clientEvaluator, serverResults),
};
};
+251
View File
@@ -0,0 +1,251 @@
import type {
Condition as CoreCondition,
NumericStateCondition as CoreNumericStateCondition,
StateCondition as CoreStateCondition,
} from "../../data/automation";
import type {
LegacyCondition,
NumericStateCondition as LovelaceNumericStateCondition,
StateCondition as LovelaceStateCondition,
VisibilityCondition,
VisibilityLogicalCondition,
} from "../../panels/lovelace/common/validate-condition";
import { isValidEntityId } from "../entity/valid_entity_id";
/**
* Lovelace condition types evaluated on the client; these have no usable core
* equivalent for dashboards and are never sent to `subscribe_condition`.
*/
const CLIENT_CONDITION_TYPES = new Set([
"screen",
"user",
"view_columns",
"location",
"time",
]);
const LOGICAL_CONDITION_TYPES = new Set(["and", "or", "not"]);
/** Type guard for the `and` / `or` / `not` combinators. */
export const isLogicalCondition = (
condition: VisibilityCondition
): condition is VisibilityLogicalCondition =>
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
/**
* Whether a condition must be evaluated server-side (via `subscribe_condition`).
*
* Leaves: everything except the client-only lovelace types is server-class,
* including legacy `{ entity, state }` conditions (treated as `state`) and any
* integration-provided condition.
*
* Compounds (`and` / `or` / `not`) are server-class only when *every*
* descendant is, so a single client leaf anywhere forces the whole compound
* client-side, where it becomes a combinator wrapping server subtrees (see
* `splitConditionTree`). An empty compound is vacuously server-class.
*/
export const isServerCondition = (condition: VisibilityCondition): boolean => {
if (isLogicalCondition(condition)) {
return (condition.conditions ?? []).every(isServerCondition);
}
// Legacy lovelace condition without a `condition` key → treated as `state`.
if (!("condition" in condition)) {
return true;
}
return !CLIENT_CONDITION_TYPES.has(condition.condition);
};
/** Inverse of {@link isServerCondition}. */
export const isClientCondition = (condition: VisibilityCondition): boolean =>
!isServerCondition(condition);
/**
* Whether *every* leaf in the tree is a client-only condition, so the whole
* tree can be evaluated and validated client-side without any
* `subscribe_condition` round-trip. Distinct from {@link isClientCondition},
* which is true when *any* leaf is client-side.
*/
export const isPureClientCondition = (
condition: VisibilityCondition
): boolean =>
isLogicalCondition(condition)
? (condition.conditions ?? []).every(isPureClientCondition)
: isClientCondition(condition);
/**
* Translate a server-class lovelace condition into its core automation
* equivalent. Core-format conditions (and condition types with no lovelace
* counterpart, like `template` / `sun` / `zone` / `device` / integration
* conditions) are passed through untouched.
*
* The caller is responsible for only translating server-class conditions
* ({@link isServerCondition}); passing a client-only condition just returns it
* unchanged.
*/
export const translateToCoreCondition = (
condition: VisibilityCondition
): CoreCondition => {
// Legacy lovelace condition: { entity, state, state_not } with no `condition`.
if (!("condition" in condition)) {
return translateStateCondition({ condition: "state", ...condition });
}
if (isLogicalCondition(condition)) {
return translateLogicalCondition(condition);
}
switch (condition.condition) {
case "state":
return translateStateCondition(condition as LovelaceStateCondition);
case "numeric_state":
return translateNumericStateCondition(
condition as LovelaceNumericStateCondition
);
default:
// Already core format (sun, zone, template, device, integration, or a
// core `state` / `numeric_state` carrying `entity_id`) → pass through.
return condition as CoreCondition;
}
};
// A core condition that always evaluates to false — ¬(AND of nothing) = ¬true.
// Used where checkConditionsMet short-circuits to false (an incomplete config),
// so we never emit a schema-invalid condition that would break a grouped
// subscription.
const alwaysFalseCondition = (): CoreCondition => ({
condition: "not",
conditions: [{ condition: "and", conditions: [] }],
});
const translateStateCondition = (
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
): CoreCondition => {
// Already core format — distinguished from lovelace by `entity_id`.
if ("entity_id" in condition) {
return condition as CoreStateCondition;
}
const lovelace = condition as LovelaceStateCondition;
// Incomplete config: no entity, or no comparison value. checkConditionsMet
// returns false for these (and a `state` condition with no `entity_id` /
// `state` is invalid for core), so resolve to a clean always-false.
if (
lovelace.entity === undefined ||
(lovelace.state === undefined && lovelace.state_not === undefined)
) {
return alwaysFalseCondition();
}
const base = {
condition: "state" as const,
entity_id: lovelace.entity,
...(lovelace.attribute !== undefined
? { attribute: lovelace.attribute }
: {}),
};
// KNOWN LIMITATION: when the compared value is itself an entity id, lovelace
// (checkStateCondition -> getValueFromEntityId) resolves *any* entity to its
// live state, but core's `state` condition only dereferences `input_*`
// entities and compares everything else literally. A value referencing a
// non-`input_*` entity therefore changes meaning after delegation. This is
// niche (the visibility editor does not offer entity-as-value) and left as a
// future enhancement — a faithful, reactive fix would emit a `template`
// condition. See https://github.com/home-assistant/frontend/issues/52836.
// `state` wins over `state_not` when both are present, mirroring
// checkConditionsMet (`state ?? state_not`, positive branch when `state`).
if (lovelace.state !== undefined) {
return { ...base, state: lovelace.state } as CoreStateCondition;
}
// Core has no `state_not`; wrap a positive `state` in `not`.
return {
condition: "not",
conditions: [{ ...base, state: lovelace.state_not } as CoreStateCondition],
};
};
const translateNumericStateCondition = (
condition: LovelaceNumericStateCondition | CoreNumericStateCondition
): CoreCondition => {
if ("entity_id" in condition) {
return condition as CoreNumericStateCondition;
}
const lovelace = condition as LovelaceNumericStateCondition;
const core: CoreNumericStateCondition = {
condition: "numeric_state",
entity_id: lovelace.entity as string,
};
if (lovelace.attribute !== undefined) {
core.attribute = lovelace.attribute;
}
const above = translateNumericBound(lovelace.above);
if (above !== undefined) {
core.above = above;
}
const below = translateNumericBound(lovelace.below);
if (below !== undefined) {
core.below = below;
}
return core;
};
/**
* Reconcile a lovelace numeric bound with core's interpretation. Lovelace
* resolves a string bound to an entity's state only when that entity exists,
* otherwise falling back to `Number(...)` (which yields `NaN` for junk, leaving
* the bound effectively ignored). Core instead treats *every* string bound as
* an entity id and errors when it is not one. To preserve lovelace behavior:
*
* - a finite numeric string (`"5"`, `"10.5"`, even `""` → 0) coerces to a
* number (the entity-id regex matches `"10.5"`, so test `Number()` first);
* - a genuine entity-id reference passes through for core to resolve;
* - anything else (junk like `"foo"`, or non-finite like `"1e400"`) is dropped,
* matching lovelace's "NaN ⇒ ignored" and never emitting a non-finite number
* (which is not JSON-serializable).
*/
const translateNumericBound = (
bound: string | number | undefined
): string | number | undefined => {
if (typeof bound !== "string") {
return bound;
}
const numeric = Number(bound);
if (!isNaN(numeric) && isFinite(numeric)) {
return numeric;
}
if (isValidEntityId(bound)) {
return bound;
}
return undefined;
};
const translateLogicalCondition = (
condition: VisibilityLogicalCondition
): CoreCondition => {
// Lovelace treats a logical condition with no `conditions` key as vacuously
// true (checkAnd/Or/NotCondition all early-return on a missing list).
if (condition.conditions === undefined) {
return { condition: "and", conditions: [] };
}
const conditions = condition.conditions.map(translateToCoreCondition);
if (condition.condition === "not") {
// Lovelace `not` means ¬(AND of children); core `not` means ¬(OR of
// children). Wrapping the children in an `and` preserves the lovelace
// meaning for any arity — including an empty `not`, which becomes ¬(AND of
// nothing) = ¬true = false, matching checkConditionsMet. A single child is
// unambiguous (¬(OR of one) = ¬(AND of one)) and left unwrapped for a
// tidier persisted form.
if (conditions.length === 1) {
return { condition: "not", conditions };
}
return { condition: "not", conditions: [{ condition: "and", conditions }] };
}
// Empty `and` (true) / `or` (false) already agree between lovelace and core.
return { condition: condition.condition, conditions };
};
@@ -0,0 +1,329 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { subscribeCondition } from "../../data/automation";
import type {
Condition,
ConditionContext,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { observeConditionChanges } from "../condition/listeners";
import type {
ClientConditionEvaluator,
ServerConditionResults,
SplitConditionTree,
} from "../condition/split";
import { splitConditionTree } from "../condition/split";
/** Tri-state visibility outcome. `unknown` = a server subtree has not reported yet. */
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
export interface ConditionEvaluatorOptions {
/** Called whenever the combined result or error changes. */
onResult: (result: ConditionEvaluation, error?: string) => void;
/** Debounce (ms) before (re)opening subscriptions when the tree changes. */
resubscribeDelay?: number;
}
const DEFAULT_RESUBSCRIBE_DELAY = 50;
/**
* Reactive controller that keeps a dashboard visibility condition tree
* evaluated live by combining:
*
* - `subscribe_condition` subscriptions, one per maximal server subtree
* (`state`, `numeric_state`, `template`, `sun`, `zone`, `device`,
* integration conditions), and
* - locally-evaluated client leaves (`screen`, `user`, `view_columns`,
* `location`, `time`), reacting to media-query / time-boundary / hass /
* context changes.
*
* The host calls {@link observe} whenever its inputs change; the controller
* only (re)subscribes when the *condition tree* changes (debounced) and merely
* recomputes for hass/context changes. Subscriptions are torn down on host
* disconnect and re-opened on reconnect. The combined result uses three-valued
* logic so the host can render an explicit `unknown` state without flashing
* while server results are still pending.
*/
export class ConditionEvaluatorController implements ReactiveController {
private _host: ReactiveControllerHost;
private readonly _onResult: ConditionEvaluatorOptions["onResult"];
private readonly _resubscribeDelay: number;
private _conditions?: VisibilityCondition[];
private _hass?: HomeAssistant;
private _getContext?: () => ConditionContext;
private _connected = false;
// Structural signature of the tree the live subscriptions/listeners are for,
// and of the tree a pending (debounced) re-subscribe will switch to. Compared
// by value (not array reference) so a host re-deriving the array each render
// does not starve the debounce or needlessly drop subscriptions.
private _subscribedSignature?: string;
private _pendingSignature?: string;
// Memoize the signature for a stable array reference to avoid re-stringifying
// on every host update.
private _lastConditionsRef?: VisibilityCondition[];
private _lastSignature?: string;
private _split?: SplitConditionTree;
private _serverResults: ServerConditionResults = {};
private _subtreeErrors: Record<string, string | undefined> = {};
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
private _listeners: (() => void)[] = [];
// Bumped on every teardown so late-arriving async results are ignored.
private _generation = 0;
private _resubscribeTimeout?: ReturnType<typeof setTimeout>;
private _result: ConditionEvaluation = "unknown";
private _error?: string;
private _notifiedResult?: ConditionEvaluation;
private _notifiedError?: string;
constructor(
host: ReactiveControllerHost,
options: ConditionEvaluatorOptions
) {
this._host = host;
this._onResult = options.onResult;
this._resubscribeDelay =
options.resubscribeDelay ?? DEFAULT_RESUBSCRIBE_DELAY;
host.addController(this);
}
public get result(): ConditionEvaluation {
return this._result;
}
public get error(): string | undefined {
return this._error;
}
/**
* Provide the latest inputs. Cheap to call on every host update: it only
* (re)subscribes when the condition tree reference changes, otherwise it just
* recomputes the client-dependent parts.
*/
public observe(
conditions: VisibilityCondition[] | undefined,
hass: HomeAssistant | undefined,
getContext?: () => ConditionContext
): void {
this._conditions = conditions;
this._hass = hass;
this._getContext = getContext;
this._sync();
}
public hostConnected(): void {
this._connected = true;
this._sync();
}
public hostDisconnected(): void {
this._connected = false;
this._teardown();
// Nothing backs the last result once subscriptions are closed; report
// `unknown` (and force the notification through) so a detached/reconnecting
// host never renders a stale, no-longer-live visibility.
this._notifiedResult = undefined;
this._notifiedError = undefined;
this._setResult("unknown", undefined);
}
private _signatureOf(
conditions: VisibilityCondition[] | undefined
): string | undefined {
if (conditions === undefined) {
return undefined;
}
if (conditions === this._lastConditionsRef) {
return this._lastSignature;
}
this._lastConditionsRef = conditions;
this._lastSignature = JSON.stringify(conditions);
return this._lastSignature;
}
private _sync(): void {
if (!this._connected) {
return;
}
const signature = this._signatureOf(this._conditions);
// Re-subscribe only when the tree we are (or are about to be) subscribed to
// actually differs by value — not merely by array reference.
const targetSignature = this._pendingSignature ?? this._subscribedSignature;
if (signature !== targetSignature) {
this._pendingSignature = signature;
this._scheduleResubscribe();
}
// Always recompute so client leaves (and the current split) stay live, even
// while a re-subscribe is pending.
this._recompute();
}
private _scheduleResubscribe(): void {
if (this._resubscribeTimeout !== undefined) {
clearTimeout(this._resubscribeTimeout);
}
this._resubscribeTimeout = setTimeout(() => {
this._resubscribeTimeout = undefined;
this._subscribe();
}, this._resubscribeDelay);
}
private _subscribe(): void {
this._teardown();
const conditions = this._conditions;
const hass = this._hass;
this._subscribedSignature = this._signatureOf(conditions);
this._pendingSignature = undefined;
if (!conditions || !hass) {
this._setResult("unknown", undefined);
return;
}
const split = splitConditionTree(conditions);
this._split = split;
const generation = this._generation;
const connection: Connection = hass.connection;
for (const subtree of split.serverSubtrees) {
this._serverResults[subtree.id] = undefined;
const subscription = subscribeCondition(
connection,
(message) => {
if (generation !== this._generation) {
return;
}
if (message.error !== undefined) {
this._serverResults[subtree.id] = false;
this._subtreeErrors[subtree.id] =
typeof message.error === "string"
? message.error
: message.error.message;
} else {
this._serverResults[subtree.id] = message.result;
this._subtreeErrors[subtree.id] = undefined;
}
this._recompute();
},
subtree.coreCondition
);
subscription.catch((err: unknown) => {
if (generation !== this._generation) {
return;
}
this._serverResults[subtree.id] = false;
this._subtreeErrors[subtree.id] =
err instanceof Error ? err.message : String(err);
this._recompute();
});
this._subscriptions.push(subscription);
}
observeConditionChanges(
conditions,
() => this._hass ?? hass,
(unsub) => this._listeners.push(unsub),
() => this._recompute()
);
this._recompute();
}
private _recompute(): void {
if (!this._split || !this._hass) {
this._setResult("unknown", undefined);
return;
}
const hass = this._hass;
const context = this._getContext?.() ?? {};
const clientEvaluator: ClientConditionEvaluator = (condition) => {
try {
// Only client-class leaves reach here, and those are all lovelace
// Condition members.
return checkConditionsMet([condition as Condition], hass, context);
} catch (_err) {
return false;
}
};
const value = this._split.evaluate(clientEvaluator, this._serverResults);
const result: ConditionEvaluation =
value === undefined ? "unknown" : value ? "visible" : "hidden";
this._setResult(result, this._combinedError());
}
private _combinedError(): string | undefined {
for (const error of Object.values(this._subtreeErrors)) {
if (error) {
return error;
}
}
return undefined;
}
private _setResult(
result: ConditionEvaluation,
error: string | undefined
): void {
this._result = result;
this._error = error;
if (result === this._notifiedResult && error === this._notifiedError) {
return;
}
this._notifiedResult = result;
this._notifiedError = error;
this._onResult(result, error);
this._host.requestUpdate();
}
private _teardown(): void {
// Invalidate any in-flight subscription callbacks.
this._generation += 1;
if (this._resubscribeTimeout !== undefined) {
clearTimeout(this._resubscribeTimeout);
this._resubscribeTimeout = undefined;
}
for (const subscription of this._subscriptions) {
subscription.then((unsub) => unsub()).catch(() => undefined);
}
this._subscriptions = [];
for (const unsub of this._listeners) {
unsub();
}
this._listeners = [];
this._split = undefined;
this._serverResults = {};
this._subtreeErrors = {};
this._subscribedSignature = undefined;
this._pendingSignature = undefined;
}
}
@@ -1,59 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type {
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { setupConditionListeners } from "../condition/listeners";
/**
* Reactive controller that manages the media-query and time-based listeners
* needed to keep a set of lovelace visibility conditions evaluated live.
*
* The host is responsible for the actual evaluation (e.g. computing visible /
* hidden / invalid state); the controller only triggers it via the supplied
* `onUpdate` callback when something the conditions depend on changes. Call
* `setup()` whenever the conditions change; the controller clears previous
* listeners and re-subscribes. Listeners are automatically released when the
* host disconnects.
*/
export class ConditionListenersController implements ReactiveController {
private _unsubs: (() => void)[] = [];
constructor(host: ReactiveControllerHost) {
host.addController(this);
}
public hostDisconnected(): void {
this.clear();
}
public setup(
conditions: Condition[],
hass: HomeAssistant,
onUpdate: () => void,
getContext?: () => ConditionContext
): void {
this.clear();
if (!conditions.length) {
return;
}
setupConditionListeners(
conditions,
hass,
(unsub) => this._unsubs.push(unsub),
() => onUpdate(),
getContext
);
}
public clear(): void {
for (const unsub of this._unsubs) {
unsub();
}
this._unsubs = [];
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { timeZonesNames } from "@vvo/tzdb";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
timeZonesNames.includes(RESOLVED_RAW))
? RESOLVED_RAW
: undefined;
+16 -5
View File
@@ -17,11 +17,18 @@ import {
} from "../data/icons";
import "./ha-icon";
import "./ha-svg-icon";
import { consumeEntityState } from "../common/decorators/consume-context-entry";
@customElement("ha-state-icon")
export class HaStateIcon extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _consumeStateObj?: HassEntity;
@property({ attribute: false }) public entityId?: string;
@property({ attribute: false }) public stateValue?: string;
@property() public icon?: string;
@@ -38,11 +45,15 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
private get _stateObj(): HassEntity | undefined {
return this.stateObj ?? this._consumeStateObj;
}
private get _overrideIcon(): string | undefined {
return (
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon
(this._stateObj && this._entities?.[this._stateObj.entity_id]?.icon) ||
this._stateObj?.attributes.icon
);
}
@@ -72,7 +83,7 @@ export class HaStateIcon extends LitElement {
this._entities,
this._config,
this._connection,
this.stateObj,
this._stateObj,
this.stateValue,
] as const,
});
@@ -82,7 +93,7 @@ export class HaStateIcon extends LitElement {
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
if (!this.stateObj) {
if (!this._stateObj) {
return nothing;
}
if (!this._config || !this._connection || !this._entities) {
@@ -97,7 +108,7 @@ export class HaStateIcon extends LitElement {
}
private _renderFallback() {
const domain = computeStateDomain(this.stateObj!);
const domain = computeStateDomain(this._stateObj!);
return html`
<ha-svg-icon
+25 -23
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -13,38 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
// @vvo/tzdb is missing the bare "UTC" zone, even though it is a valid IANA
// identifier and a common server default. Add UTC back so a
// "UTC" configuration can be selected.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
const options: PickerComboBoxItem[] = Array.from(
new Map(
getTimeZones({ includeUtc: true })
.flatMap((timezone) => {
const groupArray = Array.isArray(timezone.group)
? timezone.group
: [timezone.group];
const filteredGroup = groupArray.filter((gName) =>
timeZonesNames.includes(gName)
);
return [timezone.name, ...filteredGroup].map((nameString) => ({
id: nameString,
primary: timezone.rawFormat,
secondary: nameString,
}));
})
.map((item) => [item.id, item])
).values()
);
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
+2 -3
View File
@@ -1,5 +1,5 @@
import type { Schema } from "js-yaml";
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import { dump, load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
export class HaYamlEditor extends LitElement {
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
@property({ attribute: false }) public defaultValue?: any;
@@ -70,7 +70,6 @@ export class HaYamlEditor extends LitElement {
this._yaml = !isEmpty(value)
? dump(value, {
schema: this.yamlSchema,
quotingType: '"',
noRefs: true,
})
: "";
@@ -74,7 +74,7 @@ export interface MediaPlayerItemId {
media_content_type?: string | undefined;
}
const MANUAL_ITEM: MediaPlayerItem = {
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_expand: true,
can_play: false,
can_search: false,
@@ -83,7 +83,6 @@ const MANUAL_ITEM: MediaPlayerItem = {
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
iconPath: mdiKeyboard,
title: "Manual entry",
};
@customElement("ha-media-player-browse")
@@ -240,7 +239,7 @@ export class HaMediaPlayerBrowse extends LitElement {
currentId.media_content_id &&
isManualMediaSourceContentId(currentId.media_content_id)
) {
this._currentItem = MANUAL_ITEM;
this._currentItem = this._manualItem();
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: this._currentItem,
@@ -801,12 +800,21 @@ export class HaMediaPlayerBrowse extends LitElement {
return prom.then((item) => {
if (!mediaContentId && this.action === "pick") {
item.children = item.children || [];
item.children.push(MANUAL_ITEM);
item.children.push(this._manualItem());
}
return item;
});
}
private _manualItem(): MediaPlayerItem {
return {
...MANUAL_ITEM_BASE,
title: this.hass.localize(
"ui.components.selectors.selector.types.manual"
),
};
}
private _measureCard(): void {
this.narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450;
}
+16 -3
View File
@@ -1,6 +1,6 @@
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
export interface LogProvider {
@@ -9,11 +9,24 @@ export interface LogProvider {
addon?: HassioAddonInfo;
}
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
isComponentLoaded(hass.config, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10);
export const fetchErrorLog = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "error_log");
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
isComponentLoaded(hass.config, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10)
hasSupervisorCoreLogDownload(hass)
? "/api/hassio/core/logs/latest"
: "/api/error_log";
export const getCoreLogFileDownloadUnavailableReason = (
hass: HomeAssistant
): LogFileDisabledReason | undefined => {
if (hasSupervisorCoreLogDownload(hass)) {
return undefined;
}
return hass.config.logging?.log_file_disabled_reason ?? undefined;
};
+4 -1
View File
@@ -144,7 +144,10 @@ export const subscribeLogbook = (
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message, subscriptionId),
params
params,
// Don't auto-resubscribe: the replay uses a stale start_time and ha-logbook
// appends events without deduping, so it resubscribes on `ready` instead.
{ resubscribe: false }
);
};
+1 -1
View File
@@ -13,7 +13,7 @@ import {
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/ha-panel-config";
import { configSections } from "../panels/config/config-sections";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
+53 -18
View File
@@ -9,11 +9,17 @@ import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
@@ -97,11 +103,15 @@ export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false,
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
// that the real app's root element provides via `contextMixin`. Needed for
// gallery demos that render context-consuming components (e.g. the climate
// temperature control) without the full app shell.
provideContexts = false
// Provide the grouped Lit contexts (registries, internationalization, api,
// connection, ui, config, formatters) that the real app's root element
// provides via `contextMixin`. On by default so that any standalone hass root
// (e.g. a gallery demo) automatically feeds context-consuming components the
// same way the real app does, instead of each demo wiring up a partial set by
// hand. Pass `false` for hosts that already provide these contexts themselves
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
// registering duplicate providers on the same element.
provideContexts = true
): MockHomeAssistant => {
elements = ensureArray(elements);
// Can happen because we store sidebar, more info etc on hass.
@@ -128,21 +138,46 @@ export const provideHass = (
}
: undefined;
// The individual (non-grouped) contexts that contextMixin also provides.
// Components such as ha-area-picker / ha-entity-picker consume these directly
// (e.g. `Object.values(areas)`), so they must be provided alongside the
// grouped contexts or those components throw once they render.
const singleContextProviders = provideContexts
? {
states: new ContextProvider(baseEl(), { context: statesContext }),
services: new ContextProvider(baseEl(), { context: servicesContext }),
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
devices: new ContextProvider(baseEl(), { context: devicesContext }),
areas: new ContextProvider(baseEl(), { context: areasContext }),
floors: new ContextProvider(baseEl(), { context: floorsContext }),
}
: undefined;
const updateContextProviders = (newHass: HomeAssistant) => {
if (!contextProviders) {
return;
if (contextProviders) {
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
}
if (singleContextProviders) {
(
Object.keys(
singleContextProviders
) as (keyof typeof singleContextProviders)[]
).forEach((key) => {
(singleContextProviders[key] as ContextProvider<any>).setValue(
newHass[key]
);
});
}
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
};
const wsCommands = {};
+163 -73
View File
@@ -1,13 +1,19 @@
import { consume } from "@lit/context";
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { setupConditionListeners } from "../common/condition/listeners";
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
import { maxColumnsContext } from "../panels/lovelace/common/context";
import type {
Condition,
ConditionContext,
VisibilityCondition,
} from "../panels/lovelace/common/validate-condition";
import {
addEntityToCondition,
checkConditionsMet,
} from "../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../types";
type Constructor<T> = abstract new (...args: any[]) => T;
@@ -20,22 +26,30 @@ export interface ConditionalConfig {
}
/**
* Mixin to handle conditional listeners for visibility control
* Mixin to handle conditional visibility control.
*
* Provides lifecycle management for listeners that control conditional
* visibility of components.
* Visibility conditions are evaluated by a {@link ConditionEvaluatorController}:
* stateful conditions (`state`, `numeric_state`, `template`, `sun`, `zone`,
* `device`, integration conditions) are delegated to core via
* `subscribe_condition`, while client-only conditions (`screen`, `user`,
* `view_columns`, `location`, `time`) are evaluated locally. The host stays
* declarative — it never evaluates conditions itself.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
* 2. Ensure component has config.visibility or _config.visibility property with conditions
* 3. Ensure component has _updateVisibility() or _updateElement() method
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
* 1. Extend with `ConditionalListenerMixin<YourConfigType>(ReactiveElement)`.
* 2. Provide conditions via `config.visibility` / `_config.visibility`, or by
* overriding `setupConditionalListeners()` and calling
* `super.setupConditionalListeners(customConditions)`.
* 3. Implement `_updateVisibility()` (or `_updateElement()`) and have it derive
* visibility from {@link _conditionsVisible} rather than evaluating
* conditions directly.
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
* - Handles conditional visibility based on defined conditions
* - Consumes column count from the view via Lit Context
* - feeds the evaluator on connect and whenever `hass`, the config, or the
* column count change;
* - notifies the host (`_updateVisibility` / `_updateElement`) when the verdict
* changes; and
* - tears down subscriptions on disconnect (handled by the controller).
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
@@ -43,8 +57,6 @@ export const ConditionalListenerMixin = <
superClass: Constructor<ReactiveElement>
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
@@ -57,6 +69,51 @@ export const ConditionalListenerMixin = <
protected _conditionContext: ConditionContext = {};
// The conditions currently being evaluated (a card/badge/section/view
// `visibility`, or the conditional card/row `conditions`). Retained so the
// optimistic synchronous seed evaluates exactly what the evaluator
// subscribed to.
private __conditions?: VisibilityCondition[];
// Latest server-aware verdict from the evaluator. `unknown` until a server
// subtree first reports (or immediately for an all-client tree).
private __conditionResult: ConditionEvaluation = "unknown";
// Cache for the entity-folded array fed to the evaluator. Rebuilt only when
// the source tree reference or the entity context changes, so the
// evaluator's reference-based signature memo keeps hitting on hass-only
// updates instead of re-stringifying every tick.
private __observedSource?: VisibilityCondition[];
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
// Value signature of the source tree, used to drop the cached verdict when
// the tree changes by value so `_conditionsVisible` re-seeds for it.
private __conditionsSignature?: string;
private __conditionEvaluator = new ConditionEvaluatorController(this, {
// The synchronous seed in `_conditionsVisible` covers the initial frame,
// so there is no need to delay (re)subscribing.
resubscribeDelay: 0,
onResult: (result) => {
this.__conditionResult = result;
// The forced `unknown` on disconnect only matters to hosts that render
// the evaluator's result; we drive visibility imperatively, so ignore
// notifications once detached.
if (!this.isConnected) {
return;
}
const config = this._config || this.config;
if (this._updateVisibility) {
this._updateVisibility();
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
});
protected _updateElement?(config: TConfig): void;
protected _updateVisibility?(conditionsMet?: boolean): void;
@@ -66,11 +123,6 @@ export const ConditionalListenerMixin = <
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
@@ -83,67 +135,105 @@ export const ConditionalListenerMixin = <
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("_maxColumns")) {
this._updateVisibility?.();
// Re-feed the evaluator after the host has settled its inputs (e.g.
// `_conditionContext.entity_id`, which consumers set in `willUpdate`).
// The evaluator only re-subscribes when the *tree* changes; a
// hass/context change merely recomputes.
if (
changedProperties.has("hass") ||
changedProperties.has("config") ||
changedProperties.has("_config") ||
changedProperties.has("_maxColumns")
) {
this.setupConditionalListeners();
}
}
/**
* Clear conditional listeners
* Resolve the observed conditions to a visibility boolean.
*
* This method is called when the component is disconnected from the DOM.
* It clears all the listeners that were set up by the setupConditionalListeners() method.
* Prefers the evaluator's server-aware verdict; while a server subtree is
* still pending (`unknown`) it falls back to an optimistic synchronous
* client evaluation. That fallback is exact for the legacy lovelace
* condition types (so existing dashboards never flash) and resolves to
* hidden for core-only conditions (`template` / `sun` / …) until the server
* reports — erring toward hiding rather than leaking content.
*
* Consumers call this from `_updateVisibility` instead of evaluating
* `checkConditionsMet` themselves.
*/
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
/**
* Add a conditional listener to the list of listeners
*
* This method is called when a new listener is added.
* It adds the listener to the list of listeners.
*
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
* @returns void
*/
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
/**
* Setup conditional listeners for visibility control
*
* Default implementation:
* - Checks config.visibility or _config.visibility for conditions (if not provided)
* - Sets up appropriate listeners based on condition types
* - Calls _updateVisibility() or _updateElement() when conditions change
*
* Override this method to customize behavior (e.g., filter conditions first)
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
*
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
*/
protected setupConditionalListeners(conditions?: Condition[]): void {
const config = this.config || this._config;
const finalConditions = conditions || config?.visibility;
if (!finalConditions || !this.hass) {
return;
protected _conditionsVisible(): boolean {
const conditions = this.__conditions;
if (!conditions || conditions.length === 0) {
return true;
}
setupConditionListeners(
finalConditions,
if (this.__conditionResult !== "unknown") {
return this.__conditionResult === "visible";
}
if (!this.hass) {
return true;
}
return checkConditionsMet(
conditions as Condition[],
this.hass,
this._conditionContext
);
}
/**
* Feed the current conditions to the evaluator.
*
* Override to supply a custom condition set (e.g. the conditional card's
* `conditions`) and call `super.setupConditionalListeners(customConditions)`.
*
* @param conditions - Optional conditions. Defaults to
* `config.visibility` / `_config.visibility`.
*/
protected setupConditionalListeners(
conditions?: VisibilityCondition[]
): void {
// Prefer the resolved `_config` (e.g. a strategy-generated section config)
// over the raw `config`, matching the pre-refactor evaluation source.
const config = this._config || this.config;
const finalConditions =
conditions ?? (config?.visibility as VisibilityCondition[] | undefined);
const entityId = this._conditionContext.entity_id;
this.__conditions = finalConditions;
// Re-derive the entity-folded array only when the source tree reference or
// the entity context actually changes — not on every hass tick — so the
// evaluator keeps seeing a stable array reference and its signature memo
// keeps hitting. The evaluator translates to core format with no notion of
// the host's `entity_id` context, so fold it in here (mirroring
// `checkConditionsMet`, which reads `entity_id || entity || context`).
if (
finalConditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
// When the tree changes by *value*, drop the cached verdict so
// `_conditionsVisible` re-seeds for the new tree instead of reusing the
// previous tree's result for a frame.
const signature = finalConditions
? JSON.stringify(finalConditions)
: undefined;
if (signature !== this.__conditionsSignature) {
this.__conditionsSignature = signature;
this.__conditionResult = "unknown";
}
this.__observedSource = finalConditions;
this.__observedEntityId = entityId;
this.__observed =
finalConditions && entityId
? ((finalConditions as Condition[]).map((c) =>
addEntityToCondition(c, entityId)
) as VisibilityCondition[])
: finalConditions;
}
this.__conditionEvaluator.observe(
this.__observed,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
() => this._conditionContext
);
}
@@ -28,7 +28,7 @@ import {
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
@@ -1,5 +1,5 @@
import { mdiDotsVertical } from "@mdi/js";
import { DEFAULT_SCHEMA, Type } from "js-yaml";
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -47,12 +47,11 @@ const SUPPORTED_UI_TYPES = [
"schema",
];
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
new Type("!secret", {
kind: "scalar",
construct: (data) => `!secret ${data}`,
}),
]);
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -54,7 +54,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -214,6 +214,7 @@ export class HaAutomationAddItems extends LitElement {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.items.blank {
border-radius: var(--ha-border-radius-xl);
@@ -120,7 +120,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { load } from "js-yaml";
import { load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
let loaded: any;
try {
loaded = load(paste);
loaded = load(paste, { schema: YAML11_SCHEMA });
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
@@ -52,7 +52,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
type BlueprintMetaDataPath = BlueprintMetaData & {
@@ -41,7 +41,9 @@ export class DialogSupportPackage extends LitElement {
<ha-dialog
.open=${this._open}
width="full"
header-title="Download support package"
.headerTitle=${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
@closed=${this._dialogClosed}
>
${this._supportPackage
@@ -52,13 +54,16 @@ export class DialogSupportPackage extends LitElement {
: html`
<div class="progress-container">
<ha-spinner></ha-spinner>
Generating preview...
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_generating_preview"
)}...
</div>
`}
<div slot="footer" class="footer">
<ha-alert>
This file may contain personal data about your home. Avoid sharing
them with unverified or untrusted parties.
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_privacy_warning"
)}
</ha-alert>
<hr />
<ha-dialog-footer>
@@ -67,10 +72,10 @@ export class DialogSupportPackage extends LitElement {
appearance="plain"
@click=${this.closeDialog}
>
Close
${this.hass.localize("ui.common.close")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._download}>
Download
${this.hass.localize("ui.common.download")}
</ha-button>
</ha-dialog-footer>
</div>
+555
View File
@@ -0,0 +1,555 @@
import {
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
mdiCellphoneCog,
mdiCog,
mdiDatabase,
mdiDevices,
mdiFlask,
mdiHammer,
mdiInformationOutline,
mdiLabel,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMemory,
mdiMicrophone,
mdiNetwork,
mdiNfcVariant,
mdiPalette,
mdiPaletteSwatch,
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
mdiShape,
mdiSofa,
mdiStarFourPoints,
mdiTextBoxOutline,
mdiTools,
mdiUpdate,
mdiViewDashboard,
mdiZigbee,
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
);
return (hass: HomeAssistant) => checkRegistry(hass.entities);
};
export const configSections: Record<string, PageNavigation[]> = {
dashboard: [
{
path: "/config/integrations",
translationKey: "devices",
iconPath: mdiDevices,
iconColor: "#0D47A1",
core: true,
adminOnly: true,
},
{
path: "/config/automation",
translationKey: "automations",
iconPath: mdiRobot,
iconColor: "#518C43",
core: true,
adminOnly: true,
},
{
path: "/config/areas",
translationKey: "areas",
iconPath: mdiSofa,
iconColor: "#E48629",
component: "zone",
adminOnly: true,
},
{
path: "/config/apps",
translationKey: "apps",
iconPath: mdiPuzzle,
iconColor: "#F1C447",
core: true,
adminOnly: true,
},
{
path: "/config/lovelace/dashboards",
translationKey: "dashboards",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
component: "lovelace",
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
dashboard_external_settings: [
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
],
dashboard_2: [
{
path: "/config/matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconViewBox: "0 1 24 24",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",
adminOnly: true,
},
{
path: "/config/zha",
iconPath: mdiZigbee,
iconColor: "#E74011",
component: "zha",
translationKey: "zha",
adminOnly: true,
},
{
path: "/config/zwave_js",
iconPath: mdiZWave,
iconColor: "#153163",
component: "zwave_js",
translationKey: "zwave_js",
adminOnly: true,
},
{
path: "/knx",
iconPath:
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
iconColor: "#4EAA66",
component: "knx",
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
iconColor: "#ED7744",
component: "thread",
translationKey: "thread",
adminOnly: true,
},
{
path: "/config/bluetooth",
iconPath: mdiBluetooth,
iconColor: "#0082FC",
component: "bluetooth",
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
iconColor: "#9C27B0",
translationKey: "infrared",
adminOnly: true,
filter: getHasDomainCheck("infrared"),
},
{
path: "/config/radio-frequency",
iconPath: mdiRadioTower,
iconColor: "#E74011",
component: "radio_frequency",
translationKey: "radio_frequency",
adminOnly: true,
filter: getHasDomainCheck("radio_frequency"),
},
{
path: "/insteon",
iconPath:
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
iconColor: "#E4002C",
component: "insteon",
translationKey: "insteon",
adminOnly: true,
},
{
path: "/config/tags",
translationKey: "tags",
iconPath: mdiNfcVariant,
iconColor: "#616161",
component: "tag",
adminOnly: true,
},
],
dashboard_3: [
{
path: "/config/person",
translationKey: "people",
iconPath: mdiAccount,
iconColor: "#5A87FA",
component: ["person", "users"],
adminOnly: true,
},
{
path: "/config/system",
translationKey: "system",
iconPath: mdiCog,
iconColor: "#301ABE",
core: true,
adminOnly: true,
},
{
path: "/config/developer-tools",
translationKey: "developer_tools",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
{
path: "/config/info",
translationKey: "about",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
backup: [
{
path: "/config/backup",
translationKey: "ui.panel.config.backup.caption",
iconPath: mdiBackupRestore,
iconColor: "#4084CD",
component: "backup",
adminOnly: true,
},
],
devices: [
{
component: "integrations",
path: "/config/integrations",
translationKey: "ui.panel.config.integrations.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "devices",
path: "/config/devices",
translationKey: "ui.panel.config.devices.caption",
iconPath: mdiDevices,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "entities",
path: "/config/entities",
translationKey: "ui.panel.config.entities.caption",
iconPath: mdiShape,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
iconPath: mdiTools,
iconColor: "#4D2EA4",
core: true,
adminOnly: true,
},
],
automations: [
{
component: "automation",
path: "/config/automation",
translationKey: "ui.panel.config.automation.caption",
iconPath: mdiRobot,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "scene",
path: "/config/scene",
translationKey: "ui.panel.config.scene.caption",
iconPath: mdiPalette,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "script",
path: "/config/script",
translationKey: "ui.panel.config.script.caption",
iconPath: mdiScriptText,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "blueprint",
path: "/config/blueprint",
translationKey: "ui.panel.config.blueprint.caption",
iconPath: mdiPaletteSwatch,
iconColor: "#518C43",
adminOnly: true,
},
],
tags: [
{
component: "tag",
path: "/config/tags",
translationKey: "ui.panel.config.tag.caption",
iconPath: mdiNfcVariant,
iconColor: "#616161",
adminOnly: true,
},
],
voice_assistants: [
{
path: "/config/voice-assistants",
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
developer_tools: [
{
path: "/config/developer-tools",
translationKey: "ui.panel.config.dashboard.developer_tools.main",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
energy: [
{
component: "energy",
path: "/config/energy",
translationKey: "ui.panel.config.energy.caption",
iconPath: mdiLightningBolt,
iconColor: "#F1C447",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
network_discovery: [
{
component: "dhcp",
path: "/config/dhcp",
translationKey: "ui.panel.config.network.discovery.dhcp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "ssdp",
path: "/config/ssdp",
translationKey: "ui.panel.config.network.discovery.ssdp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "zeroconf",
path: "/config/zeroconf",
translationKey: "ui.panel.config.network.discovery.zeroconf",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_credentials: [
{
path: "/config/application_credentials",
translationKey: "ui.panel.config.application_credentials.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_mqtt: [
{
component: "mqtt",
path: "/config/mqtt",
translationKey: "ui.panel.config.mqtt.title",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
lovelace: [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.caption",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
adminOnly: true,
},
],
persons: [
{
component: "person",
path: "/config/person",
translationKey: "ui.panel.config.person.caption",
iconPath: mdiAccount,
iconColor: "#5A87FA",
adminOnly: true,
},
{
component: "users",
path: "/config/users",
translationKey: "ui.panel.config.users.caption",
iconPath: mdiBadgeAccountHorizontal,
iconColor: "#5A87FA",
core: true,
adminOnly: true,
},
],
areas: [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
],
general: [
{
path: "/config/general",
translationKey: "core",
iconPath: mdiCog,
iconColor: "#653249",
core: true,
adminOnly: true,
},
{
path: "/config/updates",
translationKey: "updates",
iconPath: mdiUpdate,
iconColor: "#3B808E",
adminOnly: true,
},
{
path: "/config/repairs",
translationKey: "repairs",
iconPath: mdiScrewdriver,
iconColor: "#5c995c",
adminOnly: true,
},
{
component: "logs",
path: "/config/logs",
translationKey: "logs",
iconPath: mdiTextBoxOutline,
iconColor: "#C65326",
core: true,
adminOnly: true,
},
{
path: "/config/backup",
translationKey: "backup",
iconPath: mdiBackupRestore,
iconColor: "#0D47A1",
component: "backup",
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
iconPath: mdiShape,
iconColor: "#f1c447",
adminOnly: true,
},
{
path: "/config/ai-tasks",
translationKey: "ai_tasks",
iconPath: mdiStarFourPoints,
iconColor: "#8B69E3",
core: true,
adminOnly: true,
},
{
path: "/config/labs",
translationKey: "labs",
iconPath: mdiFlask,
iconColor: "#b1b134",
core: true,
adminOnly: true,
},
{
path: "/config/network",
translationKey: "network",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
path: "/config/storage",
translationKey: "storage",
iconPath: mdiDatabase,
iconColor: "#518C43",
component: "hassio",
adminOnly: true,
},
{
path: "/config/hardware",
translationKey: "hardware",
iconPath: mdiMemory,
iconColor: "#301A8E",
component: ["hassio", "hardware"],
adminOnly: true,
},
],
about: [
{
component: "info",
path: "/config/info",
translationKey: "ui.panel.config.info.caption",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
};
@@ -30,7 +30,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
@customElement("ha-config-system-navigation")
class HaConfigSystemNavigation extends LitElement {
@@ -43,7 +43,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { isMac } from "../../../util/is_mac";
import { isMobileClient } from "../../../util/is_mobile";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import "../repairs/ha-config-repairs";
import "./ha-config-navigation";
import "./ha-config-updates";
@@ -93,7 +93,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -118,7 +118,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import type { Helper } from "../helpers/const";
import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
-554
View File
@@ -1,43 +1,5 @@
import {
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
mdiCellphoneCog,
mdiCog,
mdiDatabase,
mdiDevices,
mdiFlask,
mdiHammer,
mdiInformationOutline,
mdiLabel,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMemory,
mdiMicrophone,
mdiNetwork,
mdiNfcVariant,
mdiPalette,
mdiPaletteSwatch,
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
mdiShape,
mdiSofa,
mdiStarFourPoints,
mdiTextBoxOutline,
mdiTools,
mdiUpdate,
mdiViewDashboard,
mdiZigbee,
mdiZWave,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
@@ -48,7 +10,6 @@ import {
} from "../../data/entity/entity_registry";
import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../types";
declare global {
@@ -58,521 +19,6 @@ declare global {
}
}
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
);
return (hass: HomeAssistant) => checkRegistry(hass.entities);
};
export const configSections: Record<string, PageNavigation[]> = {
dashboard: [
{
path: "/config/integrations",
translationKey: "devices",
iconPath: mdiDevices,
iconColor: "#0D47A1",
core: true,
adminOnly: true,
},
{
path: "/config/automation",
translationKey: "automations",
iconPath: mdiRobot,
iconColor: "#518C43",
core: true,
adminOnly: true,
},
{
path: "/config/areas",
translationKey: "areas",
iconPath: mdiSofa,
iconColor: "#E48629",
component: "zone",
adminOnly: true,
},
{
path: "/config/apps",
translationKey: "apps",
iconPath: mdiPuzzle,
iconColor: "#F1C447",
core: true,
adminOnly: true,
},
{
path: "/config/lovelace/dashboards",
translationKey: "dashboards",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
component: "lovelace",
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
dashboard_external_settings: [
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
],
dashboard_2: [
{
path: "/config/matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconViewBox: "0 1 24 24",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",
adminOnly: true,
},
{
path: "/config/zha",
iconPath: mdiZigbee,
iconColor: "#E74011",
component: "zha",
translationKey: "zha",
adminOnly: true,
},
{
path: "/config/zwave_js",
iconPath: mdiZWave,
iconColor: "#153163",
component: "zwave_js",
translationKey: "zwave_js",
adminOnly: true,
},
{
path: "/knx",
iconPath:
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
iconColor: "#4EAA66",
component: "knx",
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
iconColor: "#ED7744",
component: "thread",
translationKey: "thread",
adminOnly: true,
},
{
path: "/config/bluetooth",
iconPath: mdiBluetooth,
iconColor: "#0082FC",
component: "bluetooth",
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
iconColor: "#9C27B0",
translationKey: "infrared",
adminOnly: true,
filter: getHasDomainCheck("infrared"),
},
{
path: "/config/radio-frequency",
iconPath: mdiRadioTower,
iconColor: "#E74011",
component: "radio_frequency",
translationKey: "radio_frequency",
adminOnly: true,
filter: getHasDomainCheck("radio_frequency"),
},
{
path: "/insteon",
iconPath:
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
iconColor: "#E4002C",
component: "insteon",
translationKey: "insteon",
adminOnly: true,
},
{
path: "/config/tags",
translationKey: "tags",
iconPath: mdiNfcVariant,
iconColor: "#616161",
component: "tag",
adminOnly: true,
},
],
dashboard_3: [
{
path: "/config/person",
translationKey: "people",
iconPath: mdiAccount,
iconColor: "#5A87FA",
component: ["person", "users"],
adminOnly: true,
},
{
path: "/config/system",
translationKey: "system",
iconPath: mdiCog,
iconColor: "#301ABE",
core: true,
adminOnly: true,
},
{
path: "/config/developer-tools",
translationKey: "developer_tools",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
{
path: "/config/info",
translationKey: "about",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
backup: [
{
path: "/config/backup",
translationKey: "ui.panel.config.backup.caption",
iconPath: mdiBackupRestore,
iconColor: "#4084CD",
component: "backup",
adminOnly: true,
},
],
devices: [
{
component: "integrations",
path: "/config/integrations",
translationKey: "ui.panel.config.integrations.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "devices",
path: "/config/devices",
translationKey: "ui.panel.config.devices.caption",
iconPath: mdiDevices,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "entities",
path: "/config/entities",
translationKey: "ui.panel.config.entities.caption",
iconPath: mdiShape,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
iconPath: mdiTools,
iconColor: "#4D2EA4",
core: true,
adminOnly: true,
},
],
automations: [
{
component: "automation",
path: "/config/automation",
translationKey: "ui.panel.config.automation.caption",
iconPath: mdiRobot,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "scene",
path: "/config/scene",
translationKey: "ui.panel.config.scene.caption",
iconPath: mdiPalette,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "script",
path: "/config/script",
translationKey: "ui.panel.config.script.caption",
iconPath: mdiScriptText,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "blueprint",
path: "/config/blueprint",
translationKey: "ui.panel.config.blueprint.caption",
iconPath: mdiPaletteSwatch,
iconColor: "#518C43",
adminOnly: true,
},
],
tags: [
{
component: "tag",
path: "/config/tags",
translationKey: "ui.panel.config.tag.caption",
iconPath: mdiNfcVariant,
iconColor: "#616161",
adminOnly: true,
},
],
voice_assistants: [
{
path: "/config/voice-assistants",
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
developer_tools: [
{
path: "/config/developer-tools",
translationKey: "ui.panel.config.dashboard.developer_tools.main",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
energy: [
{
component: "energy",
path: "/config/energy",
translationKey: "ui.panel.config.energy.caption",
iconPath: mdiLightningBolt,
iconColor: "#F1C447",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
network_discovery: [
{
component: "dhcp",
path: "/config/dhcp",
translationKey: "ui.panel.config.network.discovery.dhcp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "ssdp",
path: "/config/ssdp",
translationKey: "ui.panel.config.network.discovery.ssdp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "zeroconf",
path: "/config/zeroconf",
translationKey: "ui.panel.config.network.discovery.zeroconf",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_credentials: [
{
path: "/config/application_credentials",
translationKey: "ui.panel.config.application_credentials.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_mqtt: [
{
component: "mqtt",
path: "/config/mqtt",
translationKey: "ui.panel.config.mqtt.title",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
lovelace: [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.caption",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
adminOnly: true,
},
],
persons: [
{
component: "person",
path: "/config/person",
translationKey: "ui.panel.config.person.caption",
iconPath: mdiAccount,
iconColor: "#5A87FA",
adminOnly: true,
},
{
component: "users",
path: "/config/users",
translationKey: "ui.panel.config.users.caption",
iconPath: mdiBadgeAccountHorizontal,
iconColor: "#5A87FA",
core: true,
adminOnly: true,
},
],
areas: [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
],
general: [
{
path: "/config/general",
translationKey: "core",
iconPath: mdiCog,
iconColor: "#653249",
core: true,
adminOnly: true,
},
{
path: "/config/updates",
translationKey: "updates",
iconPath: mdiUpdate,
iconColor: "#3B808E",
adminOnly: true,
},
{
path: "/config/repairs",
translationKey: "repairs",
iconPath: mdiScrewdriver,
iconColor: "#5c995c",
adminOnly: true,
},
{
component: "logs",
path: "/config/logs",
translationKey: "logs",
iconPath: mdiTextBoxOutline,
iconColor: "#C65326",
core: true,
adminOnly: true,
},
{
path: "/config/backup",
translationKey: "backup",
iconPath: mdiBackupRestore,
iconColor: "#0D47A1",
component: "backup",
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
iconPath: mdiShape,
iconColor: "#f1c447",
adminOnly: true,
},
{
path: "/config/ai-tasks",
translationKey: "ai_tasks",
iconPath: mdiStarFourPoints,
iconColor: "#8B69E3",
core: true,
adminOnly: true,
},
{
path: "/config/labs",
translationKey: "labs",
iconPath: mdiFlask,
iconColor: "#b1b134",
core: true,
adminOnly: true,
},
{
path: "/config/network",
translationKey: "network",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
path: "/config/storage",
translationKey: "storage",
iconPath: mdiDatabase,
iconColor: "#518C43",
component: "hassio",
adminOnly: true,
},
{
path: "/config/hardware",
translationKey: "hardware",
iconPath: mdiMemory,
iconColor: "#301A8E",
component: ["hassio", "hardware"],
adminOnly: true,
},
],
about: [
{
component: "info",
path: "/config/info",
translationKey: "ui.panel.config.info.caption",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
};
@customElement("ha-panel-config")
class HaPanelConfig extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
+28 -7
View File
@@ -122,7 +122,7 @@ import {
getEntityIdTableColumn,
getLabelsTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -133,6 +133,23 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { isHelperDomain, type HelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
import { computeDomain } from "../../../common/entity/compute_domain";
interface LimitedEntity {
entity_id: HassEntity["entity_id"];
attributes: {
friendly_name?: HassEntity["attributes"]["friendly_name"];
editable?: HassEntity["attributes"]["editable"];
};
}
function equalLimitedEntity(a: LimitedEntity, b: LimitedEntity): boolean {
return (
a === b ||
(a.entity_id === b.entity_id &&
a.attributes?.friendly_name === b.attributes?.friendly_name &&
a.attributes?.editable === b.attributes?.editable)
);
}
interface HelperItem {
id: string;
@@ -142,7 +159,7 @@ interface HelperItem {
editable?: boolean;
type: string;
configEntry?: ConfigEntry;
entity?: HassEntity;
entity?: LimitedEntity;
category: string | undefined;
area?: string;
label_entries: LabelRegistryEntry[];
@@ -221,7 +238,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _helperEntities?: HassEntity[];
@state() private _helperEntities?: LimitedEntity[];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -338,7 +355,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
moveable: false,
template: (helper) =>
helper.entity
? html`<ha-state-icon .stateObj=${helper.entity}></ha-state-icon>`
? html`<ha-state-icon
.entityId=${helper.entity_id}
></ha-state-icon>`
: html`<ha-svg-icon
.path=${helper.icon}
style="color: var(--error-color)"
@@ -465,7 +484,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
private _getItems = memoizeOne(
(
localize: LocalizeFunc,
stateItems: HassEntity[],
stateItems: LimitedEntity[],
disabledEntries: EntityRegistryEntry[],
entityEntries: Record<string, EntityRegistryEntry>,
configEntries: Record<string, ConfigEntry>,
@@ -500,7 +519,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
type: configEntry
? configEntry.domain
: this._entitySource![entityState.entity_id] ||
computeStateDomain(entityState),
computeDomain(entityState.entity_id),
configEntry,
entity: entityState,
};
@@ -1269,7 +1288,9 @@ ${rejected
if (
!this._helperEntities ||
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
!this._helperEntities.every((val, idx) =>
equalLimitedEntity(newHelpers[idx], val)
)
) {
this._helperEntities = newHelpers;
if (Object.keys(this._filters).length > 0) {
@@ -47,7 +47,10 @@ import { fetchDiagnosticHandler } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getErrorLogDownloadUrl } from "../../../data/error_log";
import {
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import type {
IntegrationLogInfo,
IntegrationManifest,
@@ -1179,6 +1182,20 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
LogSeverity[LogSeverity.NOTSET],
"once"
);
const logFileDownloadUnavailableReason =
getCoreLogFileDownloadUnavailableReason(this.hass);
if (logFileDownloadUnavailableReason) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.logs.log_file_disabled_title"
),
text: this.hass.localize(
`ui.panel.config.logs.log_file_disabled_debug_download.${logFileDownloadUnavailableReason}`
),
});
return;
}
const timeString = new Date().toISOString().replace(/:/g, "-");
const logFileName = `home-assistant_${integration}_${timeString}.log`;
const signedUrl = await getSignedPath(
@@ -58,7 +58,7 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { isHelperDomain } from "../helpers/const";
import "./ha-config-flow-card";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
+1 -1
View File
@@ -57,7 +57,7 @@ import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
type ConfigTranslationKey = FlattenObjectKeys<
+47 -11
View File
@@ -45,7 +45,11 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type { ConnectionStatus } from "../../../data/connection-status";
import { fetchErrorLog, getErrorLogDownloadUrl } from "../../../data/error_log";
import {
fetchErrorLog,
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioBoots,
@@ -131,6 +135,11 @@ class ErrorLogCard extends LitElement {
const hasBoots = this._streamSupported && Array.isArray(this._boots);
const localize = this.localizeFunc || this.hass.localize;
const logFileDownloadUnavailableReason =
!this.provider || this.provider === "core"
? getCoreLogFileDownloadUnavailableReason(this.hass)
: undefined;
return html`
<div class="error-log-intro">
${this._error
@@ -187,11 +196,13 @@ class ErrorLogCard extends LitElement {
</ha-dropdown>
`
: nothing}
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${localize("ui.panel.config.logs.download_logs")}
></ha-icon-button>
${logFileDownloadUnavailableReason
? nothing
: html`<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${localize("ui.panel.config.logs.download_logs")}
></ha-icon-button>`}
<ha-icon-button
.path=${this._wrapLines ? mdiWrapDisabled : mdiWrap}
@click=${this._toggleLineWrap}
@@ -248,11 +259,21 @@ class ErrorLogCard extends LitElement {
<ha-spinner></ha-spinner>
</div>`
: nothing}
${this._loadingState === "loading"
? html`<div>${localize("ui.panel.config.logs.loading_log")}</div>`
: this._loadingState === "empty"
? html`<div>${localize("ui.panel.config.logs.no_errors")}</div>`
: nothing}
${logFileDownloadUnavailableReason
? html`<ha-alert alert-type="warning">
${localize(
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
)}
</ha-alert>`
: this._loadingState === "loading"
? html`<div>
${localize("ui.panel.config.logs.loading_log")}
</div>`
: this._loadingState === "empty"
? html`<div>
${localize("ui.panel.config.logs.no_errors")}
</div>`
: nothing}
${this._loadingState === "loaded" &&
this.filter &&
this._noSearchResults
@@ -367,6 +388,13 @@ class ErrorLogCard extends LitElement {
}
private async _downloadLogs(): Promise<void> {
if (
(!this.provider || this.provider === "core") &&
getCoreLogFileDownloadUnavailableReason(this.hass)
) {
return;
}
if (this._streamSupported && this.provider) {
showDownloadLogsDialog(this, {
header: this.header,
@@ -400,6 +428,14 @@ class ErrorLogCard extends LitElement {
this._ansiToHtmlElement?.clear();
}
if (
(!this.provider || this.provider === "core") &&
getCoreLogFileDownloadUnavailableReason(this.hass)
) {
this._loadingState = "loaded";
return;
}
const streamLogs =
this._streamSupported &&
isComponentLoaded(this.hass.config, "hassio") &&
+51 -22
View File
@@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -14,7 +15,10 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-spinner";
import { getSignedPath } from "../../../data/auth";
import { getErrorLogDownloadUrl } from "../../../data/error_log";
import {
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import { domainToName } from "../../../data/integration";
import type { LoggedError } from "../../../data/system_log";
import {
@@ -88,6 +92,8 @@ export class SystemLogCard extends LitElement {
);
protected render() {
const logFileDownloadUnavailableReason =
getCoreLogFileDownloadUnavailableReason(this.hass);
const filteredItems = this._items
? this._getFilteredItems(
this.hass.localize,
@@ -109,36 +115,55 @@ export class SystemLogCard extends LitElement {
`
: html`
<div class="header">
<h1 class="card-header">${this.header || "Logs"}</h1>
<h1 class="card-header">
${this.header ||
this.hass.localize("ui.panel.config.logs.caption")}
</h1>
<div class="header-buttons">
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${this.hass.localize(
"ui.panel.config.logs.download_logs"
)}
></ha-icon-button>
${logFileDownloadUnavailableReason
? nothing
: html`<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${this.hass.localize(
"ui.panel.config.logs.download_logs"
)}
></ha-icon-button>`}
<ha-icon-button
.path=${mdiRefresh}
@click=${this.fetchData}
.label=${this.hass.localize("ui.common.refresh")}
></ha-icon-button>
<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
.label=${this.hass.localize("ui.common.menu")}
></ha-icon-button>
<ha-dropdown-item value="show-full-logs">
<ha-svg-icon slot="icon" .path=${mdiText}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.logs.show_full_logs"
)}
</ha-dropdown-item>
</ha-dropdown>
${logFileDownloadUnavailableReason
? nothing
: html`<ha-dropdown
@wa-select=${this._handleOverflowAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
.label=${this.hass.localize("ui.common.menu")}
></ha-icon-button>
<ha-dropdown-item value="show-full-logs">
<ha-svg-icon
slot="icon"
.path=${mdiText}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.logs.show_full_logs"
)}
</ha-dropdown-item>
</ha-dropdown>`}
</div>
</div>
${logFileDownloadUnavailableReason
? html`<ha-alert alert-type="warning">
${this.hass.localize(
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
)}
</ha-alert>`
: nothing}
${this._items.length === 0
? html`
<div class="card-content empty-content">
@@ -229,6 +254,10 @@ export class SystemLogCard extends LitElement {
}
private async _downloadLogs() {
if (getCoreLogFileDownloadUnavailableReason(this.hass)) {
return;
}
const timeString = new Date().toISOString().replace(/:/g, "-");
const downloadUrl = getErrorLogDownloadUrl(this.hass);
const logFileName = `home-assistant_${timeString}.log`;
+1 -1
View File
@@ -27,7 +27,7 @@ import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import {
loadPersonDetailDialog,
showPersonDetailDialog,
@@ -108,7 +108,7 @@ import {
getLabelsTableColumn,
renderRelativeTimeColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
+1 -1
View File
@@ -113,7 +113,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
+3 -1
View File
@@ -197,7 +197,9 @@ export class HaScriptTrace extends LitElement {
</div>
${this._traces === undefined
? html`<div class="container">Loading…</div>`
? html`<div class="container">
${this.hass.localize("ui.panel.config.script.trace.loading")}
</div>`
: this._traces.length === 0
? html`<div class="container">
${this.hass!.localize(
@@ -1,5 +1,5 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { load } from "js-yaml";
import { load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, queryAll } from "lit/decorators";
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
let loaded: any;
try {
loaded = load(paste);
loaded = load(paste, { schema: YAML11_SCHEMA });
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
+1 -1
View File
@@ -37,7 +37,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showTagDetailDialog } from "./show-dialog-tag-detail";
import "./tag-image";
+1 -1
View File
@@ -23,7 +23,7 @@ import {
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddUserDialog } from "./show-dialog-add-user";
import { showUserDetailDialog } from "./show-dialog-user-detail";
import { storage } from "../../../common/decorators/storage";
+1 -1
View File
@@ -43,7 +43,7 @@ import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
+24 -4
View File
@@ -1,4 +1,4 @@
import { mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -17,6 +17,7 @@ import "../../components/ha-relative-time";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-tooltip";
import "../../components/user/ha-user-badge";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { LogbookEntry } from "../../data/logbook";
@@ -47,6 +48,12 @@ interface LogbookRenderItem extends LogbookItem {
renderedValue: TemplateResult | string;
}
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -56,6 +63,8 @@ class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public userIdToName: Record<string, string> =
{};
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@@ -87,6 +96,7 @@ class HaLogbookEntry extends LitElement {
const item = computeLogbookItem(this.hass, entry, {
nameDetail: this.nameDetail,
userIdToName: this.userIdToName,
systemUserIds: this.systemUserIds,
});
const traceContext =
@@ -208,9 +218,10 @@ class HaLogbookEntry extends LitElement {
) {
return html`<span class="trailing">
${cause
? html`<span class="cause-badge" title=${cause.name}
>${this._renderCauseIcon(cause)}</span
>`
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${this._renderCauseIcon(cause)}</span
>`
: nothing}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
${this._renderTimeChip(renderedTime)}
@@ -443,6 +454,15 @@ class HaLogbookEntry extends LitElement {
private _renderCauseIcon(cause: LogbookCause) {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
@@ -30,6 +30,8 @@ class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public userIdToName: Record<string, string> =
{};
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@@ -134,6 +136,7 @@ class HaLogbookRenderer extends LitElement {
.hass=${this.hass}
.item=${item}
.userIdToName=${this.userIdToName}
.systemUserIds=${this.systemUserIds}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
+48
View File
@@ -81,6 +81,8 @@ export class HaLogbook extends LitElement {
@state() private _userIdToName = {};
@state() private _systemUserIds = new Set<string>();
@state() private _error?: string;
private _unsubLogbook?: Promise<UnsubscribeFunc>;
@@ -96,6 +98,8 @@ export class HaLogbook extends LitElement {
private _logbookSubscriptionId = 0;
private _readyListenerAttached = false;
protected render() {
if (!isComponentLoaded(this.hass.config, "logbook")) {
return nothing;
@@ -135,6 +139,7 @@ export class HaLogbook extends LitElement {
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
.systemUserIds=${this._systemUserIds}
@hass-logbook-live=${this._handleLogbookLive}
></ha-logbook-renderer>
`;
@@ -241,6 +246,7 @@ export class HaLogbook extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._attachReadyListener();
if (this.hasUpdated) {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
@@ -249,9 +255,43 @@ export class HaLogbook extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._detachReadyListener();
this._unsubscribe(true);
}
private _attachReadyListener(): void {
if (this._readyListenerAttached || !this.hass) {
return;
}
this.hass.connection.addEventListener("ready", this._handleConnectionReady);
this._readyListenerAttached = true;
}
private _detachReadyListener(): void {
if (!this._readyListenerAttached) {
return;
}
this.hass?.connection.removeEventListener(
"ready",
this._handleConnectionReady
);
this._readyListenerAttached = false;
}
private _handleConnectionReady = () => {
// The old subscription died with the dropped connection and isn't restored
// server-side. Drop the stale handle and resubscribe from scratch, else the
// replayed history would duplicate the entries we already have.
if (!this._unsubLogbook) {
return;
}
this._unsubLogbook = undefined;
this._logbookEntries = undefined;
this._pendingStreamMessages = [];
this._liveUpdatesEnabled = true;
this._throttleGetLogbookEntries();
};
private _calculateLogbookPeriod() {
const now = new Date();
if ("range" in this.time) {
@@ -285,6 +325,9 @@ export class HaLogbook extends LitElement {
return;
}
// connectedCallback may have run before hass was set; attach now.
this._attachReadyListener();
try {
this._logbookSubscriptionId++;
@@ -415,6 +458,7 @@ export class HaLogbook extends LitElement {
private _updateUsers = throttle(async () => {
const userIdToName = {};
const systemUserIds = new Set<string>();
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
@@ -437,10 +481,14 @@ export class HaLogbook extends LitElement {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
if (user.system_generated) {
systemUserIds.add(user.id);
}
}
}
this._userIdToName = userIdToName;
this._systemUserIds = systemUserIds;
}, 60000);
static get styles() {
+16 -3
View File
@@ -126,6 +126,7 @@ export interface LogbookCause {
type: LogbookCauseType;
name: string;
userId?: string;
systemUser?: boolean;
entityId?: string;
brandDomain?: string;
}
@@ -133,13 +134,19 @@ export interface LogbookCause {
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined => {
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (userName) {
return { type: "user", name: userName, userId: item.context_user_id };
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
}
if (
@@ -312,6 +319,7 @@ export interface LogbookItem {
export interface BuildLogbookItemOptions {
nameDetail?: LogbookNameDetail;
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
export const computeLogbookItem = (
@@ -341,7 +349,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
cause: computeLogbookCause(hass, entry, opts.userIdToName ?? {}),
cause: computeLogbookCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
+1 -9
View File
@@ -7,7 +7,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
import type { LovelaceBadge } from "../types";
@@ -165,14 +164,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
this._setElementVisibility(visible);
}
@@ -58,7 +58,9 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge {
class="error"
@click=${this._viewDetail}
type="button"
label="Error"
.label=${this.hass?.localize(
"ui.panel.lovelace.editor.error_section.title"
) ?? ""}
>
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
<div class="content">${this._config.error}</div>
@@ -135,7 +135,11 @@ class HuiHistoryChartCardFeature
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
)}
</div>
</div>
`;
}
@@ -303,6 +303,43 @@ class HuiEnergySankeyCard
}
deviceNodes.push(node);
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full consumption
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
if (untracked > 0) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
@@ -445,6 +445,44 @@ class HuiPowerSankeyCard
deviceNodes.push(otherNode);
}
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full power
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
// only show if larger than 1W
if (untracked > 1) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
+1 -9
View File
@@ -9,7 +9,6 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { tryCreateCardElement } from "../create-element/create-card-element";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
@@ -262,14 +261,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
this._setElementVisibility(visible);
}
@@ -383,6 +383,43 @@ class HuiWaterFlowSankeyCard
}
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full flow
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
// only show if larger than 1 L/min
if (untracked > 1) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
@@ -246,6 +246,43 @@ class HuiWaterSankeyCard
}
deviceNodes.push(node);
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full consumption
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
if (untracked > 0) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
+12 -2
View File
@@ -2,17 +2,23 @@ import {
mdiAccount,
mdiAmpersand,
mdiCalendarClock,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiMapMarker,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiResponsive,
mdiStateMachine,
mdiViewColumnOutline,
mdiWeatherSunny,
} from "@mdi/js";
import type { Condition } from "./validate-condition";
export const ICON_CONDITION: Record<Condition["condition"], string> = {
// Keyed by the condition `condition` string. Covers the client-only lovelace
// types, the logical combinators, and the core-format server types edited via
// the automation condition editors (template/sun/zone/device).
export const ICON_CONDITION: Record<string, string> = {
view_columns: mdiViewColumnOutline,
location: mdiMapMarker,
numeric_state: mdiNumeric,
@@ -23,4 +29,8 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
and: mdiAmpersand,
not: mdiNotEqualVariant,
or: mdiGateOr,
template: mdiCodeBraces,
sun: mdiWeatherSunny,
zone: mdiMapMarkerRadius,
device: mdiDevices,
};
@@ -8,6 +8,15 @@ import {
type WeekdayShort,
} from "../../../common/datetime/weekday";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import type {
NumericStateCondition as CoreNumericStateCondition,
PlatformCondition as CorePlatformCondition,
StateCondition as CoreStateCondition,
SunCondition,
TemplateCondition,
ZoneCondition,
} from "../../../data/automation";
import type { DeviceCondition } from "../../../data/device/device_automation";
import { UNKNOWN } from "../../../data/entity/entity";
import { getUserPerson } from "../../../data/person";
import type { HomeAssistant } from "../../../types";
@@ -99,6 +108,77 @@ export interface NotCondition extends BaseCondition {
conditions?: Condition[];
}
/**
* Dashboard visibility conditions
* ===============================
*
* Historically, dashboard visibility (`visibility` on cards/badges/sections/
* views and `conditions` on the conditional card/row/element) used the
* lovelace-only {@link Condition} format above, evaluated synchronously on the
* client by {@link checkConditionsMet}.
*
* We are moving the *evaluation* of stateful conditions to core (see
* https://github.com/home-assistant/frontend/issues/52836). The visibility
* format therefore becomes the union of:
*
* - the **client-only** lovelace conditions that have no usable core
* equivalent for dashboards `screen`, `user`, `view_columns`, `location`,
* and `time` (evaluated against the viewer's local context); and
* - any **core** automation condition (`state`, `numeric_state`, `template`,
* `sun`, `zone`, `device`, and integration-provided conditions), which is
* evaluated server-side through `subscribe_condition`.
*
* The two may be mixed freely, including inside `and` / `or` / `not`.
*
* Back-compat is **read both / write new**: existing dashboards keep their
* lovelace-format `state` / `numeric_state` conditions (`entity`, `state_not`,
* ) and are translated to core format on the fly (see
* `common/condition/translate.ts`); only conditions the user edits and saves
* are persisted in core format.
*
* Note: lovelace `state` / `numeric_state` use `entity`, while their core
* counterparts use `entity_id`. Both shapes coexist in this union and are
* disambiguated by that field centralized in `common/condition/translate.ts`.
*/
export type VisibilityCondition =
// Client-only lovelace conditions (no core equivalent for dashboards)
| ScreenCondition
| UserCondition
| ViewColumnsCondition
| LocationCondition
| TimeCondition
// Lovelace stateful conditions (read-both back-compat; `entity`-based)
| StateCondition
| NumericStateCondition
| LegacyCondition
// Core automation conditions (server-evaluated; `entity_id`-based)
| CoreVisibilityCondition
// Logical combinators over the mixed union
| VisibilityLogicalCondition;
/**
* Core automation conditions usable for dashboard visibility, evaluated
* server-side. Mirrors `data/automation`'s condition types, minus the ones
* kept client-side by decision (`time`) and the ones with no dashboard meaning
* (`trigger`). The `PlatformCondition` member covers integration-provided
* conditions and, being a `condition: string` catch-all, also subsumes the
* already-core `state` / `numeric_state` shapes.
*/
export type CoreVisibilityCondition =
| CoreStateCondition
| CoreNumericStateCondition
| SunCondition
| ZoneCondition
| TemplateCondition
| DeviceCondition
| CorePlatformCondition;
/** `and` / `or` / `not` combinator whose children are the mixed union. */
export interface VisibilityLogicalCondition extends BaseCondition {
condition: "and" | "or" | "not";
conditions?: VisibilityCondition[];
}
function getValueFromEntityId(
hass: HomeAssistant,
value: string
@@ -114,7 +194,15 @@ function checkStateCondition(
hass: HomeAssistant,
context: ConditionContext
) {
const entityId = condition.entity || context.entity_id;
// A core-format condition carries its own `entity_id`; prefer it over the
// lovelace `entity` and the host's context entity so the optimistic seed
// targets the same entity the server-side subscription does.
const entityId =
("entity_id" in condition
? (condition as { entity_id?: string }).entity_id
: undefined) ||
condition.entity ||
context.entity_id;
const stateObj = entityId ? hass.states[entityId] : undefined;
const attribute = "attribute" in condition ? condition.attribute : undefined;
let state: string;
@@ -157,7 +245,14 @@ function checkStateNumericCondition(
hass: HomeAssistant,
context: ConditionContext
) {
const entityId = condition.entity || context.entity_id;
// See checkStateCondition: prefer a core-format `entity_id` over the lovelace
// `entity` and the host's context entity.
const entityId =
("entity_id" in condition
? (condition as { entity_id?: string }).entity_id
: undefined) ||
condition.entity ||
context.entity_id;
const stateObj = entityId ? hass.states[entityId] : undefined;
const state = condition.attribute
? stateObj?.attributes[condition.attribute]
@@ -432,6 +527,8 @@ export function validateConditionalConfig(
return validateLocationCondition(c);
case "numeric_state":
return validateNumericStateCondition(c);
case "state":
return validateStateCondition(c);
case "and":
return validateAndCondition(c);
case "not":
@@ -439,7 +536,9 @@ export function validateConditionalConfig(
case "or":
return validateOrCondition(c);
default:
return validateStateCondition(c);
// Server-evaluated conditions (template, sun, zone, device, and
// integration-provided types) are validated by core, not the client.
return true;
}
}
return validateStateCondition(c);
@@ -466,8 +565,12 @@ export function addEntityToCondition(
}
if (
condition.condition === "state" ||
condition.condition === "numeric_state"
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
// A core-format condition already targets its own `entity_id`; do not graft
// the host's context entity onto it (that would both mis-evaluate and emit a
// schema-invalid core condition carrying both `entity` and `entity_id`).
!("entity_id" in condition)
) {
return {
entity: entityId,
@@ -5,11 +5,8 @@ import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import type { HuiCard } from "../cards/hui-card";
import type { ConditionalCardConfig } from "../cards/types";
import type { Condition } from "../common/validate-condition";
import {
checkConditionsMet,
validateConditionalConfig,
} from "../common/validate-condition";
import type { VisibilityCondition } from "../common/validate-condition";
import { validateConditionalConfig } from "../common/validate-condition";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
declare global {
@@ -26,6 +23,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
@property({ type: Boolean }) public preview = false;
// Stay mounted while hidden so the evaluator keeps its subscriptions alive and
// can report a server-evaluated condition flipping to visible. Otherwise the
// wrapper (hui-card) removes the hidden conditional card from the DOM, tearing
// the evaluator down; the synchronous seed can revive a client condition but
// not a server one (template/sun/…), so it would never reappear.
public connectedWhileHidden = true;
@state() protected _config?: ConditionalCardConfig | ConditionalRowConfig;
protected _element?: HuiCard | LovelaceRow;
@@ -66,17 +70,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
}
protected setupConditionalListeners() {
if (!this._config || !this.hass) {
if (!this._config) {
return;
}
// Filter to supported conditions (those with 'condition' property)
const supportedConditions = this._config.conditions.filter(
(c) => "condition" in c
) as Condition[];
// Pass filtered conditions to parent implementation
super.setupConditionalListeners(supportedConditions);
// The evaluator handles every condition type, including legacy
// `{ entity, state }` conditions, so feed them all through.
super.setupConditionalListeners(
this._config.conditions as VisibilityCondition[]
);
}
protected update(changed: PropertyValues): void {
@@ -88,7 +90,6 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
changed.has("hass") ||
changed.has("preview")
) {
this.clearConditionalListeners();
this.setupConditionalListeners();
this._updateVisibility();
}
@@ -101,13 +102,7 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
this._element.preview = this.preview;
const conditionMet =
conditionsMet ??
checkConditionsMet(
this._config.conditions,
this.hass,
this._conditionContext
);
const conditionMet = conditionsMet ?? this._conditionsVisible();
this.setVisibility(conditionMet);
}
@@ -13,7 +13,9 @@ import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import { isPureClientCondition } from "../../../../common/condition/translate";
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -32,19 +34,33 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../config/automation/condition/ha-automation-condition-editor";
import "../../../config/automation/condition/types/ha-automation-condition-device";
import "../../../config/automation/condition/types/ha-automation-condition-numeric_state";
import "../../../config/automation/condition/types/ha-automation-condition-state";
import "../../../config/automation/condition/types/ha-automation-condition-sun";
import "../../../config/automation/condition/types/ha-automation-condition-template";
import "../../../config/automation/condition/types/ha-automation-condition-zone";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
NumericStateCondition as CoreNumericStateCondition,
StateCondition as CoreStateCondition,
} from "../../../../data/automation";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
AndCondition,
Condition,
ConditionContext,
LegacyCondition,
NotCondition,
NumericStateCondition,
OrCondition,
StateCondition,
VisibilityCondition,
} from "../../common/validate-condition";
import {
checkConditionsMet,
addEntityToCondition,
validateConditionalConfig,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
@@ -72,14 +88,98 @@ const containsNoEntityCondition = (
noEntity &&
CONTAINER_CONDITIONS.includes(condition.condition) &&
(condition as OrCondition | AndCondition | NotCondition).conditions?.some(
(c) => NO_ENTITY_CONDITIONS.includes(c.condition)
(c) =>
NO_ENTITY_CONDITIONS.includes(c.condition) ||
containsNoEntityCondition(c, noEntity)
) === true;
// Server-class condition types with no lovelace editor; edited via the
// automation condition editors (which already speak core format).
export const SERVER_EDITOR_CONDITIONS = ["template", "sun", "zone", "device"];
export const isServerEditorCondition = (condition: string): boolean =>
SERVER_EDITOR_CONDITIONS.includes(condition);
// Condition types edited via the core automation condition editors. The
// server-class types always are; `state` / `numeric_state` are too, except in
// entity-filter mode, where they keep the lovelace no-entity syntax and editor.
export const usesAutomationConditionEditor = (
conditionType: string,
noEntity: boolean
): boolean =>
isServerEditorCondition(conditionType) ||
(!noEntity &&
(conditionType === "state" || conditionType === "numeric_state"));
// Render-only translation: present a lovelace `state` / `numeric_state`
// condition in the struct-valid core format the automation editor speaks. This
// is edit-faithful — unlike the eval-oriented `translateToCoreCondition`, it
// never collapses an incomplete config to always-false. Already-core conditions
// (carrying `entity_id`) and every other type pass through unchanged. When the
// lovelace condition is entity-less (it implicitly targets the host card's
// entity), `contextEntityId` is folded in as the `entity_id` so the automation
// editor shows the effective entity instead of an empty, invalid field.
const toCoreEditorCondition = (
condition: VisibilityCondition,
contextEntityId?: string
): VisibilityCondition => {
if ("entity_id" in condition) {
return condition;
}
// Legacy `{ entity, state }` has no `condition` key and is treated as `state`.
if (!("condition" in condition) || condition.condition === "state") {
const lovelace = condition as StateCondition | LegacyCondition;
const attribute = "attribute" in lovelace ? lovelace.attribute : undefined;
const entity_id = lovelace.entity ?? contextEntityId ?? "";
// Core has no `state_not`; represent it as `not(state)`, which routes to
// the (lovelace) `not` editor wrapping a core `state` editor.
if (lovelace.state === undefined && lovelace.state_not !== undefined) {
const inner: CoreStateCondition = {
condition: "state",
entity_id,
state: lovelace.state_not,
};
if (attribute !== undefined) {
inner.attribute = attribute;
}
return { condition: "not", conditions: [inner] };
}
// Incomplete configs keep an empty `state` so the editor stays usable.
const core: CoreStateCondition = {
condition: "state",
entity_id,
state: lovelace.state ?? [],
};
if (attribute !== undefined) {
core.attribute = attribute;
}
return core;
}
if (condition.condition === "numeric_state") {
const lovelace = condition as NumericStateCondition;
const core: CoreNumericStateCondition = {
condition: "numeric_state",
entity_id: lovelace.entity ?? contextEntityId ?? "",
};
if (lovelace.attribute !== undefined) {
core.attribute = lovelace.attribute;
}
if (lovelace.above !== undefined) {
core.above = lovelace.above;
}
if (lovelace.below !== undefined) {
core.below = lovelace.below;
}
return core;
}
return condition;
};
@customElement("ha-card-condition-editor")
export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) condition!: Condition | LegacyCondition;
@property({ attribute: false }) condition!: VisibilityCondition;
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -95,7 +195,7 @@ export class HaCardConditionEditor extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: Condition | LegacyCondition;
protected _clipboard?: VisibilityCondition;
@state() public _yamlMode = false;
@@ -112,7 +212,31 @@ export class HaCardConditionEditor extends LitElement {
message?: string;
} = { state: "unknown" };
private _listeners = new ConditionListenersController(this);
// Live-test indicator, driven by the same server-backed evaluator the
// dashboard uses at runtime: client leaves locally, server-class subtrees via
// `subscribe_condition`, combined with three-valued logic.
private _conditionEvaluator = new ConditionEvaluatorController(this, {
// Debounce so editing (e.g. typing a template) doesn't churn subscriptions.
resubscribeDelay: 500,
onResult: (result, error) => this._setLiveTestResult(result, error),
});
// Cache of the folded observation (and its client-validity) keyed by the
// source condition + entity context, so the evaluator's reference-based
// signature memo keeps hitting on hass-only ticks instead of rebuilding the
// array — mirrors ConditionalListenerMixin.
private __observedSource?: VisibilityCondition;
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
private __clientInvalid = false;
// Pins the live-test result for the hidden / client-invalid branches that
// bypass the evaluator, so its torn-down `unknown` callback can't clobber
// them — mirrors ha-visibility-status.
private _override?: LiveTestState;
private get _editor() {
if (!this._condition) return undefined;
@@ -121,82 +245,141 @@ export class HaCardConditionEditor extends LitElement {
) as LovelaceConditionEditorConstructor | undefined;
}
private get _usesAutomationEditor(): boolean {
return (
!!this._condition &&
usesAutomationConditionEditor(this._condition.condition, this._noEntity)
);
}
// No-entity (filter-mode) conditions have no entity to evaluate against, so
// the live-test indicator is suppressed for those.
private _hideLiveTest(condition: Condition): boolean {
return (
isNoEntityCondition(condition.condition, this._noEntity) ||
containsNoEntityCondition(condition, this._noEntity)
);
}
public expand() {
this.updateComplete.then(() => {
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
});
}
private _setupConditionListeners() {
this._listeners.setup(
this.condition ? [this.condition as Condition] : [],
this.hass,
() => this._evaluateLiveTest()
);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("condition")) {
this._condition = {
// Recompute on entity-context change too: an entity-less condition folds in
// the host card's entity, which arrives via context (possibly after the
// condition is first set).
if (
changedProperties.has("condition") ||
(changedProperties as Map<string, unknown>).has("_entityContext")
) {
const normalized = {
condition: "state",
...this.condition,
};
const validator = this._editor?.validateUIConfig;
if (validator) {
try {
validator(this._condition, this.hass);
this._uiAvailable = true;
this._uiWarnings = [];
} catch (err) {
this._uiWarnings = handleStructError(
this.hass,
err as Error
).warnings;
this._uiAvailable = false;
}
} else {
this._uiAvailable = false;
} as Condition;
// In "current" mode the card supplies the entity for entity-less
// conditions; fold it into the displayed core condition.
const contextEntityId =
this._entityContext?.mode === "current"
? this._entityContext.entityId
: undefined;
// Present lovelace `state` / `numeric_state` in core format for the
// automation editor (read-both back-compat); every other type passes
// through unchanged. `_condition` always carries a `condition` key (core
// entries coexist as the wider runtime shape, narrowed here for display).
this._condition = (
usesAutomationConditionEditor(normalized.condition, this._noEntity)
? toCoreEditorCondition(normalized, contextEntityId)
: normalized
) as Condition;
if (this._usesAutomationEditor) {
// Rendered by the embedded automation condition editor, which provides
// its own UI for these core-format types.
this._uiAvailable = true;
this._uiWarnings = [];
} else {
const validator = this._editor?.validateUIConfig;
if (validator) {
try {
validator(this._condition, this.hass);
this._uiAvailable = true;
this._uiWarnings = [];
} catch (err) {
this._uiWarnings = handleStructError(
this.hass,
err as Error
).warnings;
this._uiAvailable = false;
}
} else {
this._uiAvailable = false;
this._uiWarnings = [];
}
}
if (!this._uiAvailable && !this._yamlMode) {
this._yamlMode = true;
}
this._setupConditionListeners();
}
if (changedProperties.has("condition") || changedProperties.has("hass")) {
this._evaluateLiveTest();
this._updateLiveTest();
}
}
protected updated(changedProperties: PropertyValues<this>): void {
if ((changedProperties as Map<string, unknown>).has("_entityContext")) {
this._evaluateLiveTest();
this._updateLiveTest();
}
}
private _evaluateLiveTest() {
if (!this.condition || !this._condition) {
private _liveTestContext(): ConditionContext {
return this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
}
// Feed the condition (with the card's entity folded in when in "current"
// mode) to the evaluator, which subscribes server subtrees and evaluates
// client leaves locally. `onResult` maps its verdict to the indicator.
private _updateLiveTest() {
if (
!this.condition ||
!this._condition ||
this._hideLiveTest(this._condition)
) {
this._override = "unknown";
this._conditionEvaluator.observe(undefined, this.hass);
this._liveTestResult = { state: "unknown" };
return;
}
const entityId = this._liveTestContext().entity_id;
// Rebuild the folded observation + client-validity only when the source
// condition or entity context changes, so a fresh array isn't fed to the
// evaluator on every hass tick (which would defeat its signature memo).
if (
isNoEntityCondition(this._condition.condition, this._noEntity) ||
containsNoEntityCondition(this._condition, this._noEntity)
this.condition !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.live_test_state.unknown"
),
};
return;
this.__observedSource = this.condition;
this.__observedEntityId = entityId;
this.__clientInvalid =
isPureClientCondition(this.condition) &&
!validateConditionalConfig([this.condition] as Condition[]);
const observed = entityId
? addEntityToCondition(this.condition as Condition, entityId)
: this.condition;
this.__observed = [observed] as VisibilityCondition[];
}
if (!validateConditionalConfig([this.condition])) {
// The server-backed path only reports errors for server-class subtrees, so
// surface a malformed client-only config as `invalid` here.
if (this.__clientInvalid) {
this._override = "invalid";
this._conditionEvaluator.observe(undefined, this.hass);
this._liveTestResult = {
state: "invalid",
message: this.hass.localize(
@@ -206,15 +389,32 @@ export class HaCardConditionEditor extends LitElement {
return;
}
const testContext =
this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
const pass = checkConditionsMet([this.condition], this.hass, testContext);
this._override = undefined;
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
this._liveTestContext()
);
}
private _setLiveTestResult(result: ConditionEvaluation, error?: string) {
// The hidden / client-invalid branches pin the result; ignore the
// evaluator's (torn-down) callback in those cases — mirrors
// ha-visibility-status.
if (this._override !== undefined) {
return;
}
if (error) {
// Surface the raw server error as the tooltip detail (the localized
// `invalid` label remains the indicator's aria-label) — matches how the
// automation condition editor reports validation/test errors.
this._liveTestResult = { state: "invalid", message: error };
return;
}
const liveState: LiveTestState =
result === "visible" ? "pass" : result === "hidden" ? "fail" : "unknown";
this._liveTestResult = {
state: pass ? "pass" : "fail",
state: liveState,
message: this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${pass ? "pass" : "fail"}`
`ui.panel.lovelace.editor.condition-editor.live_test_state.${liveState}`
),
};
}
@@ -224,9 +424,7 @@ export class HaCardConditionEditor extends LitElement {
if (!condition) return nothing;
const hideLiveTest =
isNoEntityCondition(condition.condition, this._noEntity) ||
containsNoEntityCondition(condition, this._noEntity);
const hideLiveTest = this._hideLiveTest(condition);
return html`
<div class="container">
@@ -286,8 +484,7 @@ export class HaCardConditionEditor extends LitElement {
>
</ha-icon-button>
${isNoEntityCondition(condition.condition, this._noEntity) ||
containsNoEntityCondition(condition, this._noEntity)
${hideLiveTest
? nothing
: html`<ha-dropdown-item value="test">
${this.hass.localize(
@@ -365,22 +562,33 @@ export class HaCardConditionEditor extends LitElement {
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
${dynamicElement(
getConditionClassName(condition.condition, this._noEntity),
{
hass: this.hass,
condition: condition,
}
)}
`}
: this._usesAutomationEditor
? html`
<ha-automation-condition-editor
.hass=${this.hass}
.condition=${condition}
.uiSupported=${true}
></ha-automation-condition-editor>
`
: html`
${dynamicElement(
getConditionClassName(
condition.condition,
this._noEntity
),
{
hass: this.hass,
condition: condition,
}
)}
`}
</div>
</ha-expansion-panel>
</div>
`;
}
private async _handleAction(ev: HaDropdownSelectEvent) {
private _handleAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
if (action === undefined) {
@@ -389,7 +597,7 @@ export class HaCardConditionEditor extends LitElement {
switch (action) {
case "test":
await this._testCondition();
this._testCondition();
return;
case "duplicate":
this._duplicateCondition();
@@ -410,37 +618,20 @@ export class HaCardConditionEditor extends LitElement {
private _timeout?: number;
private async _testCondition() {
private _testCondition() {
if (this._timeout) {
window.clearTimeout(this._timeout);
this._timeout = undefined;
}
this._testingResult = undefined;
const condition = this.condition;
const validateResult = validateConditionalConfig([this.condition]);
if (!validateResult) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.invalid_config_title"
),
text: this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.invalid_config_text"
),
});
// Surface the evaluator's current live verdict as a transient chip. A
// not-yet-reported (unknown) server result shows no chip rather than
// asserting a false failure.
const result = this._conditionEvaluator.result;
if (result === "unknown") {
this._testingResult = undefined;
return;
}
const testContext =
this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
this._testingResult = checkConditionsMet(
[condition],
this.hass,
testContext
);
this._testingResult = result === "visible";
this._timeout = window.setTimeout(() => {
this._testingResult = undefined;
@@ -522,6 +713,6 @@ declare global {
}
interface HASSDomEvents {
"duplicate-condition": { value: Condition | LegacyCondition };
"duplicate-condition": { value: VisibilityCondition };
}
}
@@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../../types";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
Condition,
LegacyCondition,
VisibilityCondition,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
import { conditionsEntityContext } from "./context";
@@ -23,16 +23,15 @@ import "./ha-card-condition-editor";
import {
type HaCardConditionEditor,
getConditionClassName,
usesAutomationConditionEditor,
} from "./ha-card-condition-editor";
import type { LovelaceConditionEditorConstructor } from "./types";
import "./types/ha-card-condition-and";
import "./types/ha-card-condition-location";
import "./types/ha-card-condition-not";
import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-numeric_state-no_entity";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-state-no_entity";
import "./types/ha-card-condition-time";
import "./types/ha-card-condition-user";
@@ -44,10 +43,15 @@ const UI_CONDITION = [
"screen",
"time",
"user",
// Server-class types, edited via the automation condition editors.
"template",
"sun",
"zone",
"device",
"and",
"not",
"or",
] as const satisfies readonly Condition["condition"][];
] as const satisfies readonly string[];
@customElement("ha-card-conditions-editor")
export class HaCardConditionsEditor extends LitElement {
@@ -59,12 +63,9 @@ export class HaCardConditionsEditor extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: Condition | LegacyCondition;
protected _clipboard?: VisibilityCondition;
@property({ attribute: false }) public conditions!: (
| Condition
| LegacyCondition
)[];
@property({ attribute: false }) public conditions!: VisibilityCondition[];
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -77,6 +78,11 @@ export class HaCardConditionsEditor extends LitElement {
private _focusLastConditionOnChange = false;
protected firstUpdated() {
// The reused automation condition editors (state / numeric_state / template
// / sun / zone / device) label their form fields from the `config`
// translation fragment, which the dashboard editor does not otherwise load.
this.hass.loadFragmentTranslation("config");
// Expand the condition if there is only one
if (this.conditions.length === 1) {
const row = this.shadowRoot!.querySelector<HaCardConditionEditor>(
@@ -161,17 +167,31 @@ export class HaCardConditionsEditor extends LitElement {
}
private _addCondition(ev: HaDropdownSelectEvent) {
const condition = ev.detail.item.value as "paste" | Condition["condition"];
const value = ev.detail.item.value as string;
const conditions = [...this.conditions];
if (!condition || (condition === "paste" && !this._clipboard)) {
if (!value || (value === "paste" && !this._clipboard)) {
return;
}
if (condition === "paste") {
if (value === "paste") {
const newCondition = deepClone(this._clipboard!);
conditions.push(newCondition);
} else if (usesAutomationConditionEditor(value, this._noEntity)) {
// Authored in core format via the automation condition editors (server
// types, plus state/numeric_state outside entity-filter mode); seed with
// that editor's default config.
const elClass = customElements.get(`ha-automation-condition-${value}`) as
| { defaultConfig?: object }
| undefined;
const defaultConfig = elClass?.defaultConfig;
conditions.push(
(defaultConfig
? { ...defaultConfig }
: { condition: value }) as VisibilityCondition
);
} else {
const condition = value as Condition["condition"];
const elClass = customElements.get(
getConditionClassName(condition, this._noEntity)
) as LovelaceConditionEditorConstructor | undefined;
@@ -1,29 +1,33 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import { mdiAlertCircle, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import { isPureClientCondition } from "../../../../common/condition/translate";
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
import "../../../../components/ha-alert";
import "../../../../components/ha-svg-icon";
import { HaRowItem } from "../../../../components/item/ha-row-item";
import type { HomeAssistant } from "../../../../types";
import type {
Condition,
LegacyCondition,
ConditionContext,
VisibilityCondition,
} from "../../common/validate-condition";
import {
checkConditionsMet,
addEntityToCondition,
validateConditionalConfig,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
import { conditionsEntityContext } from "./context";
type VisibilityState = "visible" | "hidden" | "invalid";
type VisibilityState = "visible" | "hidden" | "unknown" | "invalid";
const STATE_ICONS: Record<VisibilityState, string> = {
visible: mdiEye,
hidden: mdiEyeOff,
unknown: mdiHelpCircle,
invalid: mdiAlertCircle,
};
@@ -34,14 +38,14 @@ const STATE_ICONS: Record<VisibilityState, string> = {
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
* @attr {"visible"|"hidden"|"unknown"|"invalid"} state - Computed visibility state
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public conditions: (Condition | LegacyCondition)[] = [];
public conditions: VisibilityCondition[] = [];
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -50,17 +54,30 @@ export class HaVisibilityStatus extends LitElement {
@property()
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
// Evaluate the whole set through the same server-backed controller the
// dashboard uses at runtime, so server-class conditions report a real
// verdict instead of being flagged as an invalid configuration.
private _conditionEvaluator = new ConditionEvaluatorController(this, {
resubscribeDelay: 500,
onResult: (result, error) => this._applyResult(result, error),
});
// Cache the folded observation + client-validity keyed by (conditions ref,
// entity id) so the controller's signature memo keeps hitting on hass-only
// ticks. `_override` pins the state for the empty / client-invalid branches
// that bypass the controller.
private __observedSource?: VisibilityCondition[];
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
private __clientInvalid = false;
private _override?: VisibilityState;
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("conditions") || changedProperties.has("hass")) {
this._listeners.setup(
(this.conditions ?? []) as Condition[],
this.hass,
() => this._evaluate()
);
}
if (
changedProperties.has("hass") ||
changedProperties.has("conditions") ||
@@ -77,7 +94,9 @@ export class HaVisibilityStatus extends LitElement {
? "success"
: this.state === "hidden"
? "warning"
: "error"}
: this.state === "unknown"
? "info"
: "error"}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
<div class="headline">
@@ -94,27 +113,76 @@ export class HaVisibilityStatus extends LitElement {
`;
}
private _context(): ConditionContext {
return this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
}
private _evaluate() {
const conditions = this.conditions ?? [];
let newState: VisibilityState;
if (conditions.length === 0) {
newState = "visible";
} else if (!validateConditionalConfig(conditions)) {
newState = "invalid";
} else {
const context =
this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
newState = checkConditionsMet(conditions, this.hass, context)
? "visible"
: "hidden";
}
if (newState === this.state) {
this._override = "visible";
this._conditionEvaluator.observe(undefined, this.hass);
this.state = "visible";
return;
}
this.state = newState;
const entityId =
this._entityContext?.mode === "current"
? this._entityContext.entityId
: undefined;
// Rebuild the folded observation + client-validity only when the source
// set or entity context changes, so a fresh array isn't fed to the
// evaluator on every hass tick.
if (
conditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
this.__observedSource = conditions;
this.__observedEntityId = entityId;
this.__clientInvalid =
conditions.every((c) => isPureClientCondition(c)) &&
!validateConditionalConfig(conditions as Condition[]);
this.__observed = (
entityId
? conditions.map((c) =>
addEntityToCondition(c as Condition, entityId)
)
: conditions
) as VisibilityCondition[];
}
// `validateConditionalConfig` only understands client types; a malformed
// server config surfaces through the controller's error instead.
if (this.__clientInvalid) {
this._override = "invalid";
this._conditionEvaluator.observe(undefined, this.hass);
this.state = "invalid";
return;
}
this._override = undefined;
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
this._context()
);
}
private _applyResult(result: ConditionEvaluation, error?: string) {
// The empty / client-invalid branches pin the state; ignore the
// controller's (torn-down) result in those cases.
if (this._override !== undefined) {
return;
}
this.state = error
? "invalid"
: result === "visible"
? "visible"
: result === "hidden"
? "hidden"
: "unknown";
}
static styles: CSSResultGroup = [
@@ -1,6 +1,6 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, literal, number, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
@@ -40,7 +40,9 @@ interface NumericStateConditionData {
below?: number | string;
}
@customElement("ha-card-condition-numeric_state")
// Base class for the entity-filter (no-entity) numeric_state editor. The
// with-entity dashboard editing path now uses the core automation condition
// editor, so this class is not registered as an element on its own.
export class HaCardConditionNumericState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -211,9 +213,3 @@ export class HaCardConditionNumericState extends LitElement {
}
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-numeric_state": HaCardConditionNumericState;
}
}
@@ -1,6 +1,6 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, literal, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
@@ -37,7 +37,9 @@ interface StateConditionData {
state?: string | string[];
}
@customElement("ha-card-condition-state")
// Base class for the entity-filter (no-entity) state editor. The with-entity
// dashboard editing path now uses the core automation condition editor, so this
// class is not registered as an element on its own.
export class HaCardConditionState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -228,9 +230,3 @@ export class HaCardConditionState extends LitElement {
}
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-state": HaCardConditionState;
}
}
@@ -1,10 +1,11 @@
import { customElement } from "lit/decorators";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { createStyledHuiElement } from "../cards/picture-elements/create-styled-hui-element";
import {
checkConditionsMet,
validateConditionalConfig,
} from "../common/validate-condition";
import type { VisibilityCondition } from "../common/validate-condition";
import { validateConditionalConfig } from "../common/validate-condition";
import type { LovelacePictureElementEditor } from "../types";
import type {
ConditionalElementConfig,
@@ -13,18 +14,25 @@ import type {
} from "./types";
@customElement("hui-conditional-element")
class HuiConditionalElement extends HTMLElement implements LovelaceElement {
class HuiConditionalElement
extends ConditionalListenerMixin<ConditionalElementConfig>(ReactiveElement)
implements LovelaceElement
{
public static async getConfigElement(): Promise<LovelacePictureElementEditor> {
await import("../editor/config-elements/elements/hui-conditional-element-editor");
return document.createElement("hui-conditional-element-editor");
}
public _hass?: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
private _config?: ConditionalElementConfig;
@state() protected _config?: ConditionalElementConfig;
private _elements: LovelaceElement[] = [];
protected createRenderRoot() {
return this;
}
public setConfig(config: ConditionalElementConfig): void {
if (
!config.conditions ||
@@ -36,46 +44,58 @@ class HuiConditionalElement extends HTMLElement implements LovelaceElement {
throw new Error("Invalid configuration");
}
if (this._elements.length > 0) {
this._elements.forEach((el: LovelaceElement) => {
if (el.parentElement) {
el.parentElement.removeChild(el);
}
});
this._elements.forEach((el) => el.parentElement?.removeChild(el));
this._elements = [];
this._elements = [];
}
this._config = config;
this._config.elements.forEach((elementConfig: LovelaceElementConfig) => {
config.elements.forEach((elementConfig: LovelaceElementConfig) => {
this._elements.push(createStyledHuiElement(elementConfig));
});
this._updateElements();
this._config = config;
}
set hass(hass: HomeAssistant) {
this._hass = hass;
this._updateElements();
public connectedCallback() {
super.connectedCallback();
this._updateVisibility();
}
private _updateElements() {
if (!this._hass || !this._config) {
protected setupConditionalListeners() {
if (!this._config) {
return;
}
const visible = checkConditionsMet(this._config.conditions, this._hass, {});
// The evaluator delegates the stateful conditions (state, numeric_state,
// template, sun, zone, device, integration) to core and evaluates the
// client-only ones locally, including legacy `{ entity, state }`.
super.setupConditionalListeners(
this._config.conditions as VisibilityCondition[]
);
}
this._elements.forEach((el: LovelaceElement) => {
protected update(changed: PropertyValues): void {
super.update(changed);
if (changed.has("_config") || changed.has("hass")) {
this.setupConditionalListeners();
this._updateVisibility();
}
}
protected _updateVisibility() {
if (!this.hass || !this._config) {
return;
}
const visible = this._conditionsVisible();
this._elements.forEach((el) => {
if (visible) {
el.hass = this._hass;
el.hass = this.hass;
if (!el.parentElement) {
this.appendChild(el);
}
} else if (el.parentElement) {
el.parentElement.removeChild(el);
this.removeChild(el);
}
});
}
@@ -122,7 +122,11 @@ export class HuiGraphHeaderFooter
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
)}
</div>
</div>
`;
}
@@ -5,7 +5,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import type { LovelaceHeadingBadgeConfig } from "./types";
@@ -160,14 +159,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
this._setElementVisibility(visible);
}
@@ -47,7 +47,9 @@ export class HuiErrorSection
// Todo improve
return html`
<h1>Error</h1>
<h1>
${this.hass!.localize("ui.panel.lovelace.editor.error_section.title")}
</h1>
<p>${this._config.error}</p>
`;
}
+1 -9
View File
@@ -19,7 +19,6 @@ import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import { checkConditionsMet } from "../common/validate-condition";
import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
@@ -280,14 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
return;
}
const visible =
conditionsMet ??
(!this._config.visibility ||
checkConditionsMet(
this._config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
if (!visible) {
this._setElementVisibility(false);

Some files were not shown because too many files have changed in this diff Show More