Compare commits

..

123 Commits

Author SHA1 Message Date
Aidan Timson 69959f73a3 Keep dependency cache fallbacks fast 2026-07-13 15:45:43 +01:00
Aidan Timson 06b7836794 Fall back when dependency cache is unavailable 2026-07-13 15:39:13 +01:00
Aidan Timson 55ea661e31 Allow manual CI runs 2026-07-13 14:53:29 +01:00
Aidan Timson 23b9e97ee5 Use one dependency cache key 2026-07-13 14:24:02 +01:00
Aidan Timson f807806bed Use shared dependencies in CI 2026-07-13 14:06:57 +01:00
Aidan Timson 450a132fc7 Benchmark build with shared dependencies 2026-07-13 12:55:03 +01:00
Aidan Timson c2fdde7ad9 Avoid restoring warm producer cache 2026-07-13 12:37:36 +01:00
Aidan Timson 2b659d27b1 Benchmark shared dependency cache 2026-07-13 12:25:54 +01:00
renovate[bot] 5f46594ee2 Update tsparticles to v4.3.2 (#53112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 13:15:36 +02:00
renovate[bot] 58bcf1e865 Update dependency @html-eslint/eslint-plugin to v0.64.0 (#53111) 2026-07-13 10:05:02 +01:00
Simon Lamon 196d71c4f7 Update demo deployment to output unique url again (#53091) 2026-07-13 09:52:33 +01:00
Logan Rosen 7e60fbb66e Allow dismissing clean data entry flows (#53109)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 67dc41de-ac39-4e22-8adb-c9d35b2a5672
2026-07-13 08:34:40 +03:00
renovate[bot] cea86f0aa0 Update dependency prettier to v3.9.5 (#53107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 08:13:10 +03:00
renovate[bot] 85eb2c9f6e Update dependency marked to v18.0.6 (#53106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 21:31:01 +02:00
renovate[bot] b401cd1500 Update dependency @rsdoctor/rspack-plugin to v1.5.18 (#53105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 21:30:39 +02:00
Raphael Hehl 598e8d4a45 Route picture-elements taps to the nearest icon or label (#52928)
* Route picture-elements taps to the nearest icon or label

* Scope picture-elements double-tap per element and add gesture tests

* Drive routed taps through the shared action handler via a container resolver

* Clear the resolved gesture when a press becomes a multi-touch

* Route conditional-nested picture-elements taps to nearest target

Mark routed element types as delegated in createStyledHuiElement so elements built inside hui-conditional-element get the same nearest-target routing and hit boxes, and collect seeds from the full rendered subtree instead of only top-level elements.
2026-07-12 11:49:58 +02:00
renovate[bot] 030d777f26 Update formatjs monorepo (#53103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 09:30:44 +00:00
Jan-Philipp Benecke 194b7edf3a Remove custom app header text color in zha group page (#53101) 2026-07-12 10:17:07 +02:00
dependabot[bot] 97ed3afe4b Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#53097)
* Bump github/codeql-action/init from 4.36.2 to 4.36.3

Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.36.2 to 4.36.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...54f647b7e1bb85c95cddabcd46b0c578ec92bc1a)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update CodeQL action version to v4.36.3

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-12 08:49:26 +02:00
renovate[bot] 77cc3cfe32 Update dependency @formatjs/intl-durationformat to v0.10.16 (#53098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 08:31:06 +02:00
Simon Lamon 1350fd3674 Use ChevronRight instead of OpenInNew icon in ZHA group device list (#53087)
Update icon
2026-07-12 08:22:04 +03:00
Simon Lamon 9a283aba78 Pass in area in ZHA initialization area picker (#53086)
* Pass in area in ZHA initialization area picker

* Fixup
2026-07-12 08:21:04 +03:00
renovate[bot] 7f04b0884b Update dependency idb-keyval to v6.3.0 (#53090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-11 22:31:18 +02:00
renovate[bot] a4c07e5fc2 Update Yarn to v4.17.1 (#53093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-11 22:29:15 +02:00
renovate[bot] 42e0a1e4b5 Update dependency @rspack/core to v2.1.3 (#53081)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 13:09:57 +02:00
renovate[bot] 438e2e5173 Update dependency typescript-eslint to v8.63.0 (#53075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 08:40:54 +03:00
Petar Petrov 0548f99057 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-09 22:06:47 +02:00
elcaptain ea75339392 Update ohf.svg (#53071)
replaced depreciated logo version with correct one. svg will adapt to light/dark mode.
2026-07-09 22:05:28 +02:00
AlCalzone 7e244a706b Z-Wave: create users with credentials in a single API call (#52339)
* Z-Wave: use add_user service to add users with credentials

* refactor to match backend changes
2026-07-09 06:27:25 -04:00
renovate[bot] 7911aef6c0 Update vitest monorepo to v4.1.10 (#53067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:44:12 +03:00
renovate[bot] ed00d4ded1 Update dependency @codemirror/view to v6.43.6 (#53066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:43:36 +03:00
Petar Petrov be55a2197e Wrap device automation picker option labels instead of truncating (#53065) 2026-07-09 10:30:28 +02:00
Arsène Reymond a73c659840 fix: weather card more info on simple click (#53064) 2026-07-09 08:23:36 +03:00
Petar Petrov 207025d9a4 Recompute Y-axis tick precision when zooming charts (#52970) 2026-07-09 07:07:41 +02:00
renovate[bot] e9431105b0 Update dependency @codemirror/state to v6.7.1 (#53061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 07:04:46 +02:00
Petar Petrov 1e5560fd42 Fix search icon overlapping label in dev tools states on Safari (#52924)
* Fix search icon overlapping label in ha-input on Safari

* Remove redundant connectedCallback re-observe in ha-input
2026-07-09 06:59:20 +02:00
Petar Petrov 369e446149 Fix energy graph axis limits and bar width with sparse statistics (#52946)
* Zero-fill energy chart buckets so sparse data keeps correct axis and bar width

* Anchor fill grid on compare series when main has no data
2026-07-09 06:53:06 +02:00
Aidan Timson 318dac3bf1 Split agents.md into skills (#53048)
* Split agents into skills, keeping architecture and commands in main file with routing for agents which dont follow standards

* Rename `copy` skill to `user-facing-text`
2026-07-09 06:51:15 +02:00
Petar Petrov 8ee9c95454 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-08 12:01:52 +01:00
Aidan Timson 8ea65987a7 Clarify E2E report failures (#53052)
* Clarify E2E report retry failures

* Add failing E2E report test

* Format E2E report project names

* Remove failing E2E report test
2026-07-08 14:01:26 +03:00
Yosi Levy ada5be6af5 Various RTL fixes (#53049) 2026-07-08 12:00:56 +01:00
Aidan Timson 5bcb6628b0 Add panel route e2e coverage (#53045)
* Add panel route e2e coverage

* Fix

* Ignore playwright mcp files

* Fix

* Remove bad mock

* Fix URL constructor

* Add panel url normalization tests

* Stabilise Lovelace URL normalization tests

* Fail Lovelace URL tests faster
2026-07-08 13:59:28 +03:00
Krisjanis Lejejs 24fd2241df Fix cloud account page onboarding width (#53053) 2026-07-08 11:59:14 +01:00
Aidan Timson db0813904c Add Settings panel e2e route coverage (#53044) 2026-07-08 10:53:32 +03:00
karwosts b30668995d Suggest picture card for image entities (#53046) 2026-07-08 06:00:58 +02:00
Petar Petrov 9ba003f86a Only re-render context consumers when the selected value changes (#52885)
The consume* decorators paired @lit/context's @consume({ subscribe: true })
with @transform to narrow a context down to a single value. ContextConsumer
calls host.requestUpdate() on every provider notification, so every consumer
ran an (often empty) render cycle on every unrelated statesContext change.

Replace the built-in consumer with a small reactive controller that subscribes
the same way but leaves update scheduling to the property setter, which already
gates on hasChanged. A render is now requested only when the selected value
actually changes.
2026-07-07 18:31:34 +02:00
Krisjanis Lejejs 0536e2cd2a Sync cloud page onboarding_postponed key, update styles (#53043) 2026-07-07 14:37:38 +01:00
Ryan Mounce 0c25f061ef Fix date range picker presets rendering blank when translations load late (#53040)
The preset ranges in ha-date-range-picker were computed once in
connectedCallback(). If the component connected before the translation
chunk containing the range labels had loaded, localize() returned an
empty string for every key, collapsing all presets into a single
empty-keyed entry — the preset list rendered blank and never recovered.

Compute _ranges in willUpdate() instead, recomputing whenever _i18n,
_hassConfig, or extendedPresets changes. This also fixes the presets
not updating when the locale or timezone changes while the page is open.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:14:05 +00:00
Petar Petrov 62e5c9dee9 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-07 14:12:11 +01:00
Krisjanis Lejejs 36fa14ee26 Redesign cloud page (#53025)
* Redesign clould page

* Fix Prettier after upgrade

* Fix formatting after merge

* Further prettier fixes
2026-07-07 16:10:04 +03:00
Petar Petrov 14294998e7 Show date picker in energy-date-selection card without energy config (#53038) 2026-07-07 13:45:30 +01:00
Aidan Timson 60549695b0 Add auto-labels for tests (#53034) 2026-07-07 14:52:25 +03:00
dependabot[bot] 41068657bd Bump home-assistant/wheels from 2026.06.0 to 2026.07.0 (#53039)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2026.06.0 to 2026.07.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/34957438948e0b3dcde73c77750643dadae594f5...9e17ab1ed5c4c79d8b61e29fa63de25ca2710716)

---
updated-dependencies:
- dependency-name: home-assistant/wheels
  dependency-version: 2026.07.0
  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-07-07 11:35:50 +00:00
renovate[bot] 7d11f283d2 Update dependency @codemirror/view to v6.43.5 (#53037)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-07 13:28:54 +02:00
Petar Petrov 0927a55a0c Merge Port 8123 feature branch to dev (#53022)
* Add HTTP server settings to the network panel (#51981)

* Fix focus loss in ha-input-multi when items change

* Add HTTP server settings to the network panel

* Surface fetch errors and validate before saving in HTTP config form

* Update src/panels/config/network/ha-config-http-form.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Group HTTP form fields into collapsible sections

* Add bottom margin to ha-input-multi add button

* Only apply add-button margin when helper text is present

* Update HTTP config form to new WebSocket API

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* HTTP config: confirm or revert pending changes after restart (#52452)

* HTTP config: prompt admin to confirm or revert pending changes after restart

After saving HTTP server settings, core now writes the new config as pending,
restarts Home Assistant, and keeps the previous stable config as a recovery
fallback. The frontend now:

- Reads {stable, pending} from http/config and adds a promoteHttpConfig call.
- Asks the admin to confirm before saving (since saving now auto-restarts).
- After reconnect, shows a non-dismissable popup for admins with the changed
  fields, where Confirm promotes pending -> stable and Revert clears pending
  (which triggers another restart).
- Renders an info banner above the form whenever pending is set.

* Drop pending banner and pending handling from HTTP form

The popup blocks any other interaction while pending exists, so the form
is only reachable when pending is null. Stop fetching/displaying the
pending config and the unconfirmed-config banner.

* Use HTTP settings in dialog title

* Show auto-revert countdown in HTTP pending-config dialog (#52799)

* Show auto-revert countdown in HTTP pending-config dialog

Surface the new revert_at deadline (core PR #174428) in the popup that
opens after saving HTTP server settings. While pending, the dialog shows
a ticking 'Settings will revert in M:SS.' line; when the deadline passes
it switches into a reverted state with an info alert and a Close button.
Confirm/Revert remain available until the deadline.

* Add bottom margin to ha-alert in pending-config dialog

Without it the changes list sits flush against the reverted-state info
alert.

* text tweak

* Render under-a-minute revert countdown as '45 seconds'

secondsToDuration returns a bare '45' for under-a-minute durations, so
the line read 'Settings will revert in 45.'. formatNumericDuration uses
Intl unit formatting in that range, giving 'Settings will revert in
45 seconds.', and keeps the M:SS format above one minute.

* Fix Prettier formatting in dialog-http-pending-config.ts and ha-config-http-form.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 13:27:54 +02:00
Aidan Timson 9e75f9282e Extract shared workflow steps into composite actions (#52982) 2026-07-07 12:25:08 +01:00
Petar Petrov 56af189032 Hide external-link icon on internal repairs "Learn more" links (#53035) 2026-07-07 12:16:02 +01:00
Petar Petrov 02c99dd61d Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-07 12:13:02 +01:00
Petar Petrov f00342d522 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-07 12:12:00 +01:00
Petar Petrov 5538be0d0a Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-07 12:03:12 +01:00
Norbert Rittel 96b5c2910e Use more user-friendly 'Fit mode' options in Picture entity card (#53024)
More user-friendly 'Fit mode' options
2026-07-07 12:02:50 +03:00
Yosi Levy 30c44403de Fix RTL in selection in date time picker (#53020) 2026-07-06 16:27:14 +03:00
Petar Petrov 43c16f0c77 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-06 11:46:55 +01:00
Petar Petrov 42fe3c6f97 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-06 13:11:51 +03:00
Petar Petrov 903f954947 Round fan speed slider display to whole percentages (#53004) 2026-07-06 11:08:54 +01:00
Petar Petrov 50e6691c30 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-06 11:08:41 +01:00
Simon Lamon b0e8d45003 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-06 08:33:56 +01:00
Simon Lamon 14e7f434e0 No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-06 05:47:35 +00:00
Raman Gupta 535b1b03f2 Fix Z-Wave JS config parameter value type (#53011)
* Fix Z-Wave JS config parameter value type

Type ZWaveJSNodeConfigParam.value as number | null instead of any,
matching what the backend sends. This surfaced two comparison bugs:

- The enumerated picker no-op check compared the stored number against
  the picker's string value, so it never matched and re-selecting the
  current option re-sent the command to the device.
- The numeric input guard coerced null (unknown value) to 0, so
  entering 0 for a parameter with an unknown value was ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:22:10 +03:00
dependabot[bot] 14db6c99fe Bump actions/cache from 5.0.5 to 6.1.0 (#52999)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.1.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-07-05 11:06:13 +03:00
dependabot[bot] 97226acab9 Bump release-drafter/release-drafter from 7.4.0 to 7.5.1 (#52998)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.4.0 to 7.5.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/ed4bc48ec97379be2258e7b7ac2624a3e26ab809...4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.5.1
  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-07-05 11:05:42 +03:00
dependabot[bot] df0d1a945f Bump actions/setup-python from 6.2.0 to 6.3.0 (#52997)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.3.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-07-05 11:05:24 +03:00
renovate[bot] dccd798c26 Update dependency js-yaml to v5.2.1 (#52996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-05 11:05:06 +03:00
renovate[bot] 757c7a3e7f Update tsparticles to v4.3.1 (#52992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 20:06:40 +02:00
Petar Petrov 7ccd1371bf Match Statistic card line height to Entity card (#52981) 2026-07-03 17:42:30 +02:00
Paulus Schoutsen fe9017fbdf Clean up energy dashboard card titles and labels (#28778)
* Remove usage labels from energy cards and rename Sources to Costs

- Remove redundant "usage" text from energy card titles and labels
- Simplify Sources table labels by removing "total" text
- Rename Sources table title to Costs

* Apply suggestion from @mindfreeze

* Apply suggestion from @balloob

* Keep 'total' in source type summary labels

Addresses review feedback from @karwosts — in the detail view,
dropping "total" from labels like "Gas total" makes the summary
row ambiguous when listed alongside individual source names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-03 14:33:37 +00:00
Petar Petrov 86181e293a Fix focus loss in text selector with multiple (#52980) 2026-07-03 14:35:37 +01:00
Aidan Timson 0879d01dee Reduce scope of github token in CI build steps (#52977)
Scope GITHUB_TOKEN to the CI build steps
2026-07-03 14:52:49 +03:00
Aidan Timson de358e4834 Standardise translations upload on env block for Lokalise token (#52978)
* Standardise translations.yaml on env block for Lokalise token

* Trigger translations workflow on its own file changes
2026-07-03 14:52:01 +03:00
Aidan Timson d4855bfddd Disable credential persistence in device-class sync checkout (#52976) 2026-07-03 14:51:12 +03:00
Aidan Timson ce3b38f2b4 Standardise workflow file extensions to yaml over yml (#52979)
Standardise workflow file extensions on .yaml
2026-07-03 14:50:40 +03:00
Aidan Timson 18608a17ca Surface all lint failures in a single CI run (#52975) 2026-07-03 11:27:12 +01:00
Aidan Timson 80bffd605f Key Playwright browser cache on installed version (#52974)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-03 11:26:57 +01:00
renovate[bot] f4778bef29 Update dependency @rspack/core to v2.1.2 (#52973) 2026-07-03 10:34:03 +01:00
Petar Petrov 0533b11816 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 10:17:55 +01:00
github-actions[bot] 5f1e495203 Update numeric sensor device classes (#52969)
Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>
2026-07-03 05:41:21 +00:00
Aidan Timson 6f1b4fdc6c Add actionlint check for workflow files (#52958) 2026-07-03 08:37:05 +03:00
Aidan Timson 67e1ca3f43 Add dev scripts and background support for agents (#52948)
* Dont open demo

* Scripts to run script/develop*

* Scripts for dev demo and gallery

* Background wrapper for agents with `--background`

* Background `yarn dev` and `dev:serve` for agents

* Dedupe lifecycle functions

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-03 08:31:58 +03:00
Aidan Timson 36330d8220 Update CodeQL workflow to current v4 template standard (#52957) 2026-07-03 08:25:49 +03:00
renovate[bot] 0df3919a49 Update dependency fs-extra to v11.3.6 (#52965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 06:52:15 +02:00
renovate[bot] b5e03b23b9 Update dependency typescript-eslint to v8.62.1 (#52966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 06:52:02 +02:00
renovate[bot] ca1ab06384 Update dependency prettier to v3.9.4 (#52967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 06:51:49 +02:00
renovate[bot] fb7ed8bfd4 Update dependency minify-literals to v2.1.0 (#52968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 06:51:34 +02:00
Aidan Timson b72b6c77bf Developer Tools -> Tools: Frontend panels (#52927)
* Rename

* Rename developer tools element tags and imports

* Point config panel routing at /config/tools

* Redirect old developer tools URLs to /config/tools

* Add tools my-link redirects

* Rename developer tools panel to Tools

* Update e2e tests for the tools panel

* Update developer tools link in issue template

* Rename developer tools translation keys to tools

* Load config fragment for statistics repairs

* Update bug report description

* Redirect old developer tools URLs on initial load and add tools e2e tests

* Casing

Co-authored-by: Norbert Rittel <norbert@rittel.de>

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-07-02 19:43:52 +02:00
Petar Petrov 7f0ddae91e Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-02 19:42:03 +02:00
Petar Petrov 71e4303fa5 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-02 19:39:17 +02:00
Aidan Timson 9bb7704a3a Add e2e app tests for sidebar and map (#52950)
* Add app e2e sidebar and map coverage

* Add more app e2e sidebar coverage
2026-07-02 19:36:59 +02:00
Aidan Timson 3cc9817b90 Standardise Netlify deploy URLs with json flag (#52956) 2026-07-02 17:44:09 +02:00
renovate[bot] 674755e430 Update dependency prettier to v3.9.3 (#52947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 15:14:39 +03:00
Franck Nijhof f84664909f Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-02 14:26:57 +03:00
Franck Nijhof 4fd631f229 Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-02 14:18:07 +03:00
renovate[bot] 18cf41b793 Update dependency @rsdoctor/rspack-plugin to v1.5.17 (#52943)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 14:14:44 +03:00
renovate[bot] e28788cb95 Update dependency idb-keyval to v6.2.6 (#52944)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 14:14:24 +03:00
Petar Petrov f81b43491d Add My links for infrared and radio frequency config panels (#52931) 2026-07-01 20:06:34 +02:00
Aidan Timson 23335fffdb Migrate hui-warning and hui-error-card to lazy context (#52926) 2026-07-01 15:32:50 +03:00
Paul Bottein 0a93a681e3 Show the event type in the logbook for event entities (#52863) 2026-07-01 13:32:10 +02:00
renovate[bot] 7bc2cad83e Update dependency eslint-plugin-import-x to v4.17.1 (#52923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 14:17:12 +03:00
Aidan Timson 39ee60a8ef Migrate all card features to lazy context (#52922)
* Migrate all card features to lazy context

* Gate render() on _locale in hui-date-set-card-feature

* fix typo: rename supportsFanOscilatteCardFeatureFromState to supportsFanOscillateCardFeatureFromState

* Apply Prettier formatting to rebased card feature templates.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 14:16:48 +03:00
renovate[bot] e06d46e87f Update dependency prettier to v3.9.1 (#52913)
* Update dependency prettier to v3.9.1

* format

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-01 10:55:38 +00:00
renovate[bot] 45704587f3 Update tsparticles to v4.3.0 (#52919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 08:39:30 +03:00
renovate[bot] f7c9033b22 Update dependency tar to v7.5.19 (#52920)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 08:39:05 +03:00
Andrei Nistor 0edb5c5241 Show device area and entity count in connected devices list (#52914)
* Show device area and entity count in connected devices list

Display each connected (via) device's area and entity count as secondary
text, so devices that share a name (e.g. multiple Floor Heating devices)
can be told apart by their location and size. The count is read from the
full entity registry so it matches what the integration page shows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-30 14:01:18 +00:00
Bram Kragten 3ca9b8b9aa Show a warning when deprecated automation options where used and migr… (#52915)
Show a warning when deprecated automation options where used and migrated
2026-06-30 16:45:33 +03:00
renovate[bot] ba10fd0447 Update dependency @codemirror/view to v6.43.4 (#52912)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 16:27:26 +03:00
renovate[bot] 3171e00929 Update dependency @html-eslint/eslint-plugin to v0.63.0 (#52911) 2026-06-30 08:57:12 +01:00
karwosts 032790750c Improve/unify icon behavior in entities and helpers tables (#52887)
* Improve/unify icon behavior in entities and helpers tables

* remove unused changedProp

* unused imports

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-30 07:41:14 +00:00
renovate[bot] 9807b9bb33 Update dependency prettier to v3.8.5 (#52905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 08:23:16 +03:00
karwosts 29853ab37d Do not flicker "no items" when loading todo list (#52909) 2026-06-30 08:22:26 +03:00
renovate[bot] d9e996d901 Update dependency eslint to v10.6.0 (#52907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 08:20:45 +03:00
renovate[bot] 3d1630e497 Update dependency @rspack/core to v2.1.1 (#52910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 08:20:22 +03:00
renovate[bot] 301c08dc86 Update dependency js-yaml to v5.2.0 (#52908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 06:35:14 +02:00
Aidan Timson bc9af7fc2f Fix loading spinner position for more info weather forecast (#52903) 2026-06-29 19:05:28 +02:00
renovate[bot] 86c1a81aca Update dependency @rspack/core to v2.1.0 (#52898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-29 11:49:14 +03:00
1110 changed files with 60276 additions and 43411 deletions
@@ -0,0 +1,98 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
## Dialogs
Open dialogs through the fire-event pattern:
```ts
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
Dialog implementation requirements:
- Use `ha-dialog`.
- Implement `HassDialog<T>`.
- Use `@state() private _open = false` to control visibility.
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
- Return `nothing` while required params are absent.
- Fire `dialog-closed` in the close handler.
- Use `header-title` and `header-subtitle` for simple header text.
- Use slots when standard header attributes are not enough.
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
## Buttons
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
Axes:
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
- `size`: `xs`, `s`, `m`, `l`, `xl`.
Common usage:
- Use `appearance="filled"` for primary emphasis when needed.
- Use `appearance="plain"` for cancel and dismiss actions.
- Use `variant="danger"` for destructive actions.
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
## Forms
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
```ts
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
## Alerts
Use `ha-alert` for user-visible status messaging.
- Alert types: `error`, `warning`, `info`, `success`.
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
- Slots: `icon` for custom leading icon, `action` for custom action content.
- Content is announced by screen readers when dynamically displayed.
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
## Panels And Lovelace Cards
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
@@ -0,0 +1,78 @@
---
name: ha-frontend-contexts
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
---
# HA Frontend Contexts
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
## Goal
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
## Core Files
- Context definitions: `src/data/context/index.ts`
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
- Transform decorator: `src/common/decorators/transform.ts`
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
## Context Selection
| Context | Replaces |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
## Consumption Patterns
Use entity-scoped helpers when the component watches an entity id held on the host:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
private _entity?: EntityRegistryDisplayEntry;
@state() @consumeLocalize()
private _localize!: LocalizeFunc;
```
For a single field from a grouped context, pair `@consume` with `@transform`:
```ts
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
private _themes!: Themes;
```
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
## Review Checklist
- The component consumes the narrowest context needed for the data it reads.
- A broad `hass` property is kept only when the component is a container or external API requires it.
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
- Context fields are marked `@state()` so updates trigger rendering.
- Tests and mocks only provide the data the component actually consumes.
@@ -0,0 +1,73 @@
---
name: ha-frontend-review
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
---
# HA Frontend Review
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
- Do not omit, reorder, or rewrite template sections.
- Check the appropriate "Type of change" box based on the actual change.
- Do not check checklist items on behalf of the user.
- If the PR includes UI changes, remind the user to add screenshots or a short video.
- Explain what the change does for users, not only implementation details.
- Use Markdown.
## Pre-Submission Checklist
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
- Event listeners and subscriptions are cleaned up.
- UI is accessible to screen readers and keyboard users.
## Recurring Review Issues
User experience and accessibility:
- Forms need proper labels, helper text, and validation feedback.
- Form markup should not cause password managers to identify fields incorrectly.
- Clickable areas should be large enough for touch interaction.
- Hover, active, disabled, loading, and focus states should be clear.
Dialog and modal patterns:
- Multi-step operations should show progress.
- Dialog state should survive background operations correctly.
- Cancel and close buttons should behave consistently.
- Defaults should be helpful without blocking user override.
Component design patterns:
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
- Components should align to the design grid.
- Badges and indicators should be placed consistently.
Code quality:
- Null and undefined paths should be handled explicitly.
- Potentially undefined array and object access should be guarded.
- Event handlers, timers, observers, and subscriptions should be cleaned up.
Configuration and props:
- Make configuration fields optional when sensible.
- Provide reasonable defaults.
- Keep APIs extensible without adding speculative abstractions.
- Validate configuration before applying changes.
## Review Flow
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
@@ -0,0 +1,78 @@
---
name: ha-frontend-styling
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
---
# HA Frontend Styling
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
## Theme And Layout Basics
- Use Home Assistant CSS custom properties instead of hardcoded colors.
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
- Keep components mobile-first and enhance for desktop.
- Keep layouts RTL-safe. Prefer logical properties when they fit.
- Prefer `ha-*` components and current Web Awesome wrappers.
- Avoid adding new legacy Material Web Components (`mwc-*`).
- Scope styles to the component. Do not rely on global styles for component internals.
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
```ts
static get styles() {
return css`
:host {
padding: var(--ha-space-4);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) {
:host {
padding: var(--ha-space-2);
}
}
`;
}
```
## Interaction States
- Make touch targets large enough for mobile.
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
- Preserve keyboard navigation and visible focus indicators.
- Maintain WCAG AA contrast for text and essential UI affordances.
## View Transitions
Use the View Transitions API only for meaningful continuity between DOM states.
Core resources:
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
- Launch-screen fade example: `src/util/launch-screen.ts`.
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
Implementation rules:
- Use `withViewTransition()` for fallback behavior.
- Keep transitions simple. Subtle fades and crossfades usually work best.
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
- Ensure each `view-transition-name` is unique at any given time.
- Remember only one view transition can run at a time.
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
## Review Checklist
- Styling uses theme variables and spacing tokens where practical.
- Layout is mobile-first and RTL-safe.
- Component styles are scoped.
- Interactive states are clear and accessible.
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
@@ -0,0 +1,70 @@
---
name: ha-frontend-testing
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
---
# HA Frontend Testing
Use this skill when choosing or running validation for frontend changes.
## Core Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
## Dev Servers
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
## Playwright E2E
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
Start the relevant suite server, then run that suite:
| Suite | Server | Test command |
| ------- | ------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
## Benchmarks
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
## Verification Selection
- Documentation-only change: no code test required unless examples or commands changed.
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
- Broad refactor: run `yarn lint` and relevant test suites when practical.
@@ -0,0 +1,98 @@
---
name: ha-frontend-user-facing-text
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
---
# HA Frontend User-Facing Text
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
## Localization
- All user-facing text must be translatable.
- Add translation keys to `src/translations/en.json` when introducing new strings.
- Use the localization system instead of inline user-visible strings.
- Prefer complete localized strings with placeholders over concatenating translated fragments.
- Give translators enough context through key naming and placeholders.
```ts
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
## Voice And Style
- Use American English.
- Use a friendly, informational tone.
- Address users directly with "you" and "your" when appropriate.
- Be inclusive, objective, and non-discriminatory.
- Be concise and clear.
- Use active voice.
- Avoid jargon where a familiar home automation term works.
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
- Spell out terms when possible.
- Use sentence case for titles, headings, buttons, labels, and UI elements.
- Use the Oxford comma in lists.
- Prefer "like" over "e.g." and "for example" over "i.e.".
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
- Write for both technical and non-technical users.
Sentence case examples:
- Use: "Create new automation"
- Avoid: "Create New Automation"
- Use: "Device settings"
- Avoid: "Device Settings"
## Terminology
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
Technical product terms are lowercase in prose: automation, entity, device, service.
## Delete, Remove, Create, Add
Use "Remove" for actions that can be restored or reapplied:
- Removing a user's permission.
- Removing a user from a group.
- Removing links between items.
- Removing a widget from a dashboard.
- Removing an item from a cart.
Use "Delete" for permanent, non-recoverable actions:
- Deleting a field.
- Deleting a value in a field.
- Deleting a task.
- Deleting a group.
- Deleting a permission.
- Deleting a calendar event.
Use "Add" for already-existing items:
- Adding a permission to a user.
- Adding a user to a group.
- Adding links between items.
- Adding a widget to a dashboard.
- Adding an item to a cart.
Use "Create" for something made from scratch:
- Creating a new field.
- Creating a new task.
- Creating a new group.
- Creating a new permission.
- Creating a new calendar event.
Create pairs with Delete. Add pairs with Remove.
## Review Checklist
- Text is localized.
- Copy uses sentence case.
- "Home Assistant" is written in full.
- Delete/Remove and Create/Add match recoverability and object lifecycle.
- Placeholders are used instead of string concatenation.
- The wording is concise and understandable without implementation knowledge.
+1
View File
@@ -0,0 +1 @@
../.agents/skills
+1 -1
View File
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
<!--
If your issue is about how an entity is shown in the UI, please add the state
and attributes for all situations with a screenshot of the UI.
You can find this information at `/config/developer-tools/state`
You can find this information at `/config/tools/state`
-->
```yaml
+2 -2
View File
@@ -94,8 +94,8 @@ body:
label: State of relevant entities
description: >
If your issue is about how an entity is shown in the UI, please add the
state and attributes for all situations. You can find this information
at Developer Tools -> States.
state and attributes for all situations. You can find this
information in the Details view of the More info dialog.
render: txt
- type: textarea
attributes:
+23
View File
@@ -0,0 +1,23 @@
name: Build frontend target
description: Run a gulp build target
inputs:
target:
description: gulp target to run
required: true
github-token:
description: GitHub token for fetching nightly translations; omit to build English-only
default: ""
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
runs:
using: composite
steps:
- name: Build ${{ inputs.target }}
shell: bash
run: ./node_modules/.bin/gulp ${{ inputs.target }}
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
+52
View File
@@ -0,0 +1,52 @@
name: Deploy to Netlify
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
inputs:
dir:
description: Directory to deploy
required: true
alias:
description: Deploy alias; leave empty to deploy to production
default: ""
auth-token:
description: NETLIFY_AUTH_TOKEN
required: true
site-id:
description: NETLIFY_SITE_ID
required: true
outputs:
netlify_url:
description: The deployed URL
value: ${{ steps.deploy.outputs.netlify_url }}
unique_deploy_url:
description: The unique hash-based deployment URL for this specific build
value: ${{ steps.deploy.outputs.unique_deploy_url }}
runs:
using: composite
steps:
- name: Deploy to Netlify
id: deploy
shell: bash
env:
DIR: ${{ inputs.dir }}
ALIAS: ${{ inputs.alias }}
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
NETLIFY_SITE_ID: ${{ inputs.site-id }}
run: |
# Execute the deployment
if [ -n "$ALIAS" ]; then
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
else
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
fi
# Collect the urls from the deployment output
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
+40
View File
@@ -0,0 +1,40 @@
name: Setup Node and install
description: Set up Node from .nvmrc and install yarn dependencies
inputs:
immutable:
description: Pass --immutable to yarn install
default: "true"
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache before installing
default: "false"
runs:
using: composite
steps:
- name: Restore complete dependency tree
id: dependency-cache
if: inputs.node-modules-cache == 'true'
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
- name: Install dependencies
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
-648
View File
@@ -1,648 +0,0 @@
# GitHub Copilot & Claude Code Instructions
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
## Table of Contents
- [Quick Reference](#quick-reference)
- [Core Architecture](#core-architecture)
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- [Development Standards](#development-standards)
- [Component Library](#component-library)
- [Common Patterns](#common-patterns)
- [Text and Copy Guidelines](#text-and-copy-guidelines)
- [Development Workflow](#development-workflow)
- [Review Guidelines](#review-guidelines)
## Quick Reference
### Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
script/develop # Development server
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
### Component Prefixes
- `ha-` - Home Assistant components
- `hui-` - Lovelace UI components
- `dialog-` - Dialog components
### Import Patterns
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
```
## Core Architecture
The Home Assistant frontend is a modern web application that:
- Uses Web Components (custom elements) built with Lit framework
- Is written entirely in TypeScript with strict type checking
- Communicates with the backend via WebSocket API
- Provides comprehensive theming and internationalization
## State Access: Contexts Instead of `hass`
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
### Contexts
Consume the narrowest context that covers your reads:
| Context | Replaces |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
### Consuming
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
private _entity?: EntityRegistryDisplayEntry;
@state() @consumeLocalize()
private _localize!: LocalizeFunc;
```
For any other single field, pair `@consume` with `@transform`:
```ts
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
private _themes!: Themes;
```
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
## Development Standards
### Code Quality Requirements
**Linting and Formatting (Enforced by Tools)**
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
**Naming Conventions**
- PascalCase for types and classes
- camelCase for variables, methods
- Private methods require leading underscore
- Public methods forbid leading underscore
### TypeScript Usage
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
- **Proper type imports**: Use `import type` for type-only imports
- **Define interfaces**: Create proper interfaces for data structures
- **Type component properties**: All Lit properties must be properly typed
- **No unused variables**: Prefix with `_` if intentionally unused
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
```typescript
// Good
import type { HomeAssistant } from "../types";
interface EntityConfig {
entity: string;
name?: string;
}
@property({ type: Object })
hass!: HomeAssistant;
// Bad
@property()
hass: any;
```
### Web Components with Lit
- **Use Lit 3.x patterns**: Follow modern Lit practices
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
- **Define custom element names**: Use `ha-` prefix for components
```typescript
@customElement("ha-my-component")
export class HaMyComponent extends LitElement {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyComponentConfig;
static get styles() {
return css`
:host {
display: block;
}
`;
}
render() {
return html`<div>Content</div>`;
}
}
```
### Component Guidelines
- **Use composition**: Prefer composition over inheritance
- **Lazy load panels**: Heavy panels should be dynamically imported
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
- **Handle loading states**: Always show appropriate loading indicators
- **Support themes**: Use CSS custom properties from theme
### Data Management
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- **Cache appropriately**: Use collections and caching for frequently accessed data
- **Handle errors gracefully**: All API calls should have error handling
- **Update real-time**: Subscribe to state changes for live updates
```typescript
// Good
try {
const result = await fetchEntityRegistry(this.hass.connection);
this._processResult(result);
} catch (err) {
showAlertDialog(this, {
text: `Failed to load: ${err.message}`,
});
}
```
### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
static get styles() {
return css`
:host {
padding: var(--ha-space-4);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) {
:host {
padding: var(--ha-space-2);
}
}
`;
}
```
### View Transitions
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
**Core Resources:**
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
**Default Root Transition:**
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
**Important Constraints:**
- Each `view-transition-name` must be unique at any given time
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
### Performance Best Practices
- **Code split**: Split code at the panel/dialog level
- **Lazy load**: Use dynamic imports for heavy components
- **Optimize bundle**: Keep initial bundle size minimal
- **Use virtual scrolling**: For long lists, implement virtual scrolling
- **Memoize computations**: Cache expensive calculations
### Testing Requirements
- **Write tests**: Add tests for data processing and utilities
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
## Component Library
### Dialog Component
**Opening Dialogs (Fire Event Pattern - Recommended):**
```typescript
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
**Dialog Implementation Requirements:**
- Use `ha-dialog` component
- Implement `HassDialog<T>` interface
- Use `@state() private _open = false` to control dialog visibility
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
- Return `nothing` when no params (loading state)
- Fire `dialog-closed` event in `_dialogClosed()` handler
- Use `header-title` attribute for simple titles
- Use `header-subtitle` attribute for simple subtitles
- Use slots for custom content where the standard attributes are not enough
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
**Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
**Button Appearance Guidelines:**
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
Common patterns:
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
- Use `computeLabel`, `computeError`, `computeHelper` for translations
```typescript
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
- Slots: `icon` (override the leading icon), `action` (custom action content)
- Content announced by screen readers when dynamically displayed
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
**Key Features:**
- Automatically blocks shortcuts when input fields are focused
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
**Implementation:**
- **Class definition**: `src/common/keyboard/shortcuts.ts`
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
### Tooltip Component (ha-tooltip)
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
**Implementation:**
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
## Common Patterns
### Creating a Panel
```typescript
@customElement("ha-panel-myfeature")
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
@property({ attribute: false })
hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
narrow!: boolean;
@property()
route!: Route;
hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entities) => {
this._entities = entities;
}),
];
}
}
```
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house.
```typescript
@customElement("hui-my-card")
export class HuiMyCard extends LitElement implements LovelaceCard {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyCardConfig;
public setConfig(config: MyCardConfig): void {
if (!config.entity) {
throw new Error("Entity required");
}
this._config = config;
}
public getCardSize(): number {
return 3; // Height in grid units
}
// Optional: Editor for card configuration
public static getConfigElement(): LovelaceCardEditor {
return document.createElement("hui-my-card-editor");
}
// Optional: Stub config for card picker
public static getStubConfig(): object {
return { entity: "" };
}
}
```
**Card Guidelines:**
- Cards are highly customizable for different households
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
- Use proper error handling in `setConfig()`
- Consider all possible states (loading, error, unavailable)
- Support different entity types and states
- Follow responsive design principles
- Add configuration editor when needed
### Internationalization
- **Use localize**: Always use the localization system
- **Add translation keys**: Add keys to src/translations/en.json
- **Support placeholders**: Use proper placeholder syntax
```typescript
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
### Accessibility
- **ARIA labels**: Add appropriate ARIA labels
- **Keyboard navigation**: Ensure all interactions work with keyboard
- **Screen reader support**: Test with screen readers
- **Color contrast**: Meet WCAG AA standards
## Development Workflow
### Setup and Commands
1. **Setup**: `script/setup` - Install dependencies
2. **Develop**: `script/develop` - Development server
3. **Lint**: `yarn lint` - Run all linting before committing
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Gallery
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
### Common Pitfalls to Avoid
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
- Don't manipulate DOM directly - Let Lit handle rendering
- Don't use global styles - Scope styles to components
- Don't block the main thread - Use web workers for heavy computation
- Don't ignore TypeScript errors - Fix all type issues
### Security Best Practices
- Sanitize HTML - Never use `unsafeHTML` with user content
- Validate inputs - Always validate user inputs
- Use HTTPS - All external resources must use HTTPS
- CSP compliance - Ensure code works with Content Security Policy
### Pull Requests
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
- Do not omit, reorder, or rewrite the template sections
- Check the appropriate "Type of change" box based on the changes
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
- Be simple and user friendly — explain what the change does, not implementation details
- Use markdown so the user can copy it
### Text and Copy Guidelines
#### Terminology Standards
**Delete vs Remove**
- **Use "Remove"** for actions that can be restored or reapplied:
- Removing a user's permission
- Removing a user from a group
- Removing links between items
- Removing a widget from dashboard
- Removing an item from a cart
- **Use "Delete"** for permanent, non-recoverable actions:
- Deleting a field
- Deleting a value in a field
- Deleting a task
- Deleting a group
- Deleting a permission
- Deleting a calendar event
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
- **Use "Add"** for already-existing items:
- Adding a permission to a user
- Adding a user to a group
- Adding links between items
- Adding a widget to dashboard
- Adding an item to a cart
- **Use "Create"** for something made from scratch:
- Creating a new field
- Creating a new task
- Creating a new group
- Creating a new permission
- Creating a new calendar event
#### Writing Style (Consistent with Home Assistant Documentation)
- **Use American English**: Standard spelling and terminology
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
- **Address users directly**: Use "you" and "your"
- **Be inclusive**: Objective, non-discriminatory language
- **Be concise**: Use clear, direct language
- **Be consistent**: Follow established terminology patterns
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
- **Avoid jargon**: Use terms familiar to home automation users
#### Language Standards
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
- **Avoid abbreviations**: Spell out terms when possible
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
- ✅ "Create new automation"
- ❌ "Create New Automation"
- ✅ "Device settings"
- ❌ "Device Settings"
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
- **Avoid CAPS for emphasis**: Use bold or italics instead
- **Write for all skill levels**: Both technical and non-technical users
#### Key Terminology
- **"integration"** (preferred over "component")
- **Technical terms**: Use lowercase (automation, entity, device, service)
#### Translation Considerations
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
- **Keep context**: Provide enough context for translators
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
### Common Review Issues (From PR Analysis)
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
#### User Experience and Accessibility
- **Form validation**: Always provide proper field labels and validation feedback
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
- **Hit targets**: Make clickable areas large enough for touch interaction
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
#### Dialog and Modal Patterns
- **Interview progress**: Show clear progress for multi-step operations
- **State persistence**: Handle dialog state properly during background operations
- **Cancel behavior**: Ensure cancel/close buttons work consistently
- **Form prefilling**: Use smart defaults but allow user override
#### Component Design Patterns
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
- **Grid alignment**: Components should align to the design grid system
- **Badge placement**: Position badges and indicators consistently
#### Code Quality Issues
- **Null checking**: Always check if entities exist before accessing properties
- **TypeScript safety**: Handle potentially undefined array/object access
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
#### Configuration and Props
- **Optional parameters**: Make configuration fields optional when sensible
- **Smart defaults**: Provide reasonable default values
- **Future extensibility**: Design APIs that can be extended later
- **Validation**: Validate configuration before applying changes
## Review Guidelines
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
- [ ] Tests added for new data processing/utilities (where applicable)
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event/subscription listeners cleaned up (no memory leaks)
- [ ] Accessible to screen readers and keyboard
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+8 -1
View File
@@ -1,7 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Dependabot only scans .github/workflows by default; composite actions
# under .github/actions must be listed explicitly to stay updated.
# https://github.com/dependabot/dependabot-core/issues/6704
directories:
- "/"
- "/.github/actions/setup"
- "/.github/actions/build"
- "/.github/actions/netlify-deploy"
schedule:
interval: weekly
time: "06:00"
+31
View File
@@ -42,5 +42,36 @@ Dependencies:
GitHub Actions:
- changed-files:
- any-glob-to-any-file:
- .github/actions/**
- .github/workflows/**
- .github/*.yml
"Tests: E2E":
- changed-files:
- any-glob-to-any-file:
- test/e2e/**
"Tests: App":
- changed-files:
- any-glob-to-any-file:
- test/e2e/app.spec.ts
- test/e2e/app/**
- test/e2e/playwright.app.config.ts
"Tests: Demo":
- changed-files:
- any-glob-to-any-file:
- test/e2e/demo.spec.ts
- test/e2e/playwright.demo.config.ts
"Tests: Design":
- changed-files:
- any-glob-to-any-file:
- test/e2e/gallery.spec.ts
- test/e2e/playwright.gallery.config.ts
"Tests: Unit":
- changed-files:
- any-glob-to-any-file:
- test/*.ts
- "test/!(e2e)/**"
+1 -1
View File
@@ -1,7 +1,7 @@
#!/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
// `check-authorization` job in .github/workflows/restrict-task-creation.yaml via
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
+42
View File
@@ -0,0 +1,42 @@
name: Lint workflow files
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
actionlint:
name: Check workflow files
runs-on: ubuntu-latest
env:
# renovate: datasource=github-releases depName=rhysd/actionlint
ACTIONLINT_VERSION: 1.7.12
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run actionlint
run: |
curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz actionlint
./actionlint -color
+25 -34
View File
@@ -21,7 +21,7 @@ jobs:
if: github.event_name != 'push'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -29,27 +29,23 @@ jobs:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Cast
run: ./node_modules/.bin/gulp build-cast
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --alias dev
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
deploy_master:
runs-on: ubuntu-latest
@@ -57,7 +53,7 @@ jobs:
if: github.event_name == 'push'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -65,24 +61,19 @@ jobs:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Cast
run: ./node_modules/.bin/gulp build-cast
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
+70 -26
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -12,7 +13,6 @@ on:
env:
NODE_OPTIONS: --max_old_space_size=6144
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -22,27 +22,65 @@ permissions:
contents: read
jobs:
lint:
name: Lint and check format
prepare-dependencies:
name: Prepare dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Check for complete dependency tree
id: dependencies
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lint:
name: Lint and check format
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
id: build_resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup lint cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules/.cache/prettier
@@ -53,52 +91,58 @@ jobs:
- name: Run eslint
run: yarn run lint:eslint --quiet
- name: Run tsc
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:types
- name: Run lit-analyzer
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:lit --quiet
- name: Run prettier
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:prettier
- name: Check dependency licenses
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:licenses
test:
name: Run tests
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
node-modules-cache: true
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run Tests
run: yarn run test
build:
name: Build frontend
needs: [lint, test]
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
node-modules-cache: true
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
IS_TEST: "true"
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+43
View File
@@ -0,0 +1,43 @@
name: "CodeQL"
on:
push:
branches:
- dev
- master
pull_request:
# The branches below must be a subset of the branches above
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
contents: read # To check out the repository
security-events: write # To upload CodeQL results
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: "/language:javascript-typescript"
-65
View File
@@ -1,65 +0,0 @@
name: "CodeQL"
on:
push:
branches: [dev, master]
pull_request:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ["javascript"]
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
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.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+24 -34
View File
@@ -22,7 +22,7 @@ jobs:
if: github.event_name != 'push' || github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -30,27 +30,22 @@ jobs:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
deploy_master:
runs-on: ubuntu-latest
@@ -58,7 +53,7 @@ jobs:
if: github.event_name == 'push' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -66,24 +61,19 @@ jobs:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
+12 -17
View File
@@ -16,31 +16,26 @@ jobs:
runs-on: ubuntu-latest
environment:
name: Design
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
+13 -20
View File
@@ -28,30 +28,23 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
- name: Generate summary
run: |
NETLIFY_LIVE_URL=$(jq -r '.deploy_url' deploy_output.json)
echo "$NETLIFY_LIVE_URL" >> "$GITHUB_STEP_SUMMARY"
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
+31 -52
View File
@@ -32,19 +32,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -64,19 +59,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-e2e-test-app
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -96,19 +86,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -133,22 +118,22 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Install dependencies
run: yarn install --immutable
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
@@ -199,14 +184,8 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Download blob report (local)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+4 -8
View File
@@ -25,18 +25,14 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
immutable: false
- name: Download translations
run: ./script/translations_download
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10 -13
View File
@@ -31,20 +31,18 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: yarn install
immutable: false
cache: false
- name: Download Translations
run: ./script/translations_download
@@ -97,7 +95,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
with:
abi: cp314
tag: musllinux_1_2
@@ -116,12 +114,11 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: yarn install
immutable: false
cache: false
- name: Download Translations
run: ./script/translations_download
env:
@@ -22,15 +22,11 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
persist-credentials: false
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
+4 -3
View File
@@ -6,6 +6,7 @@ on:
branches:
- dev
paths:
- .github/workflows/translations.yaml
- src/translations/en.json
permissions:
@@ -22,6 +23,6 @@ jobs:
persist-credentials: false
- name: Upload Translations
run: |
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
./script/translations_upload_base
run: ./script/translations_upload_base
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
+6 -1
View File
@@ -6,6 +6,8 @@ build/
dist/
/hass_frontend/
/translations/
# Composite action source, not build output
!/.github/actions/build/
# yarn
.yarn/*
@@ -59,9 +61,12 @@ test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# MCP server
.playwright-mcp/
# AI tooling
.claude
.claude/*
!.claude/skills
.cursor
.opencode
.serena
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.17.0.cjs
yarnPath: .yarn/releases/yarn-4.17.1.cjs
-1
View File
@@ -1 +0,0 @@
.github/copilot-instructions.md
+52
View File
@@ -0,0 +1,52 @@
# Home Assistant Frontend Agent Guide
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
## Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn dev # App dev server, supports --background/--status/--stop/--logs
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
```
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
## Architecture
- The frontend uses custom elements built with Lit and TypeScript strict mode.
- Components communicate with the backend through the Home Assistant WebSocket API.
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
## Development Standards
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
- Keep imports organized and remove unused imports.
- Do not use `console`; use existing logging or user-visible error patterns.
- Use `@state()` for internal Lit state and `@property()` for public API.
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
## Project Skills
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
## Pull Requests
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
+1 -1
View File
@@ -1 +1 @@
.github/copilot-instructions.md
AGENTS.md
+678
View File
@@ -0,0 +1,678 @@
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
//
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
//
// (no mode) Run in the foreground.
// --background Start detached, wait until it is ready, print the URL
// (when it has one) and pid, then exit and leave it running.
// --status Report whether the suite's dev server is running.
// --stop Stop a running background dev server.
// --logs [--follow] Print (or follow) the background dev server log.
//
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
// script. Suites use one of two liveness models:
//
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
// endpoint each dev server exposes (see runDevServer in
// build-scripts/gulp/rspack.js). The port is the source of truth and
// the pid is found from it; no state file.
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
// no health endpoint, and plain yarn dev has no port at all, so these
// track a pidfile and treat the first "Build done" log line as ready.
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const developAndServeScript = path.join(
repoRoot,
"script",
"develop_and_serve"
);
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
// Each suite names its yarn alias (for hints), a liveness model, and how to
// spawn it. health suites carry a fixed port; process suites carry the log line
// that means "ready" and, for app-serve, forward extra args to the script.
const SUITES = {
"e2e-app": {
alias: "test:e2e:app:dev",
liveness: "health",
port: 8095,
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
},
demo: {
alias: "dev:demo",
liveness: "health",
port: 8090,
spawn: { cmd: gulpBin, args: ["develop-demo"] },
},
gallery: {
alias: "dev:gallery",
liveness: "health",
port: 8100,
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
},
app: {
alias: "dev",
liveness: "process",
readyLog: /Build done @/,
spawn: { cmd: gulpBin, args: ["develop-app"] },
},
"app-serve": {
alias: "dev:serve",
liveness: "process",
acceptsArgs: true,
readyLog: /Build done @/,
spawn: { cmd: developAndServeScript, args: [] },
},
};
// Cover a cold build on a slow machine before the server starts listening.
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
const READY_TIMEOUT_MS =
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
// Detect a coding agent from a small set of environment markers set by common
// agent CLIs (env-only; no process-ancestry detection).
const detectAgent = () => {
const env = process.env;
const has = (name) => Boolean(env[name]);
const eq = (name, value) => env[name] === value;
const signals = {
opencode: () =>
[
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
].some(has),
"claude-code": () => has("CLAUDECODE"),
cursor: () => has("CURSOR_TRACE_ID"),
"github-copilot": () =>
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
// Convention shared by several agents (Crush, Amp, ...).
generic: () => has("AGENT") || has("AI_AGENT"),
};
return Object.keys(signals).find((id) => signals[id]());
};
const usage = () => {
const suites = Object.keys(SUITES).join("|");
process.stderr.write(
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
`[--background | --status | --stop | --logs [--follow]]\n`
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
follow: false,
suite: undefined,
passthrough: [],
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--suite":
args.suite = argv[++i];
break;
case "--background":
args.mode = "background";
break;
case "--status":
args.mode = "status";
break;
case "--stop":
args.mode = "stop";
break;
case "--logs":
args.mode = "logs";
break;
case "--follow":
args.follow = true;
break;
default:
// Anything unrecognised is forwarded to the underlying script.
args.passthrough.push(arg);
}
}
return args;
};
const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
const hints = (suite) => {
const alias = `yarn ${SUITES[suite].alias}`;
return (
` Stop: ${alias} --stop\n` +
` Status: ${alias} --status\n` +
` Logs: ${alias} --logs\n`
);
};
// --- shared spawning and lifecycle ------------------------------------------
// Signal the whole process group (the background server is its group leader),
// falling back to the bare pid if that is not permitted.
const killProcessTree = (pid, sig) => {
try {
process.kill(-pid, sig);
} catch {
try {
process.kill(pid, sig);
} catch {
// Already gone.
}
}
};
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
const spawnInherit = (cmd, args) =>
new Promise((resolve) => {
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
child.on("exit", (code) => resolve(code ?? 0));
});
// Spawn a detached server that writes stdout and stderr to the suite's log file.
const spawnDetachedToLog = (suite, cmd, args) => {
fs.mkdirSync(logDir, { recursive: true });
const logFile = logFileFor(suite);
const fd = fs.openSync(logFile, "w");
const child = spawn(cmd, args, {
cwd: repoRoot,
detached: true,
stdio: ["ignore", fd, fd],
});
fs.closeSync(fd);
child.unref();
return { child, logFile };
};
// Poll until the server is ready, the child exits, or we time out. Prints the
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
// the child dies before it is ready (used to clear a stale pidfile).
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
let childExited = false;
child.on("exit", () => {
childExited = true;
});
const deadline = Date.now() + READY_TIMEOUT_MS;
process.stdout.write(`Starting ${suite} dev server`);
/* eslint-disable no-await-in-loop -- poll until the server is ready */
while (Date.now() < deadline) {
if (childExited) {
process.stdout.write("\n");
process.stderr.write(
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
);
onExit?.();
return 1;
}
if (await isReady()) {
process.stdout.write("\n");
process.stdout.write(
`Dev server (${suite}) running${urlSuffix(port)} ` +
`(pid ${child.pid})\n${hints(suite)}`
);
return 0;
}
process.stdout.write(".");
await sleep(1000);
}
/* eslint-enable no-await-in-loop */
process.stdout.write("\n");
process.stderr.write(
`Dev server (${suite}) did not become ready within ${
READY_TIMEOUT_MS / 1000
}s. See ${logFile}\n`
);
return 1;
};
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
const terminate = async (suite, pid, isStopped, onStopped) => {
killProcessTree(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
/* eslint-disable no-await-in-loop -- poll until the server is gone */
while (Date.now() < deadline) {
await sleep(300);
if (await isStopped()) {
onStopped?.();
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
return 0;
}
}
/* eslint-enable no-await-in-loop */
// Escalate if it is still up.
killProcessTree(pid, "SIGKILL");
await sleep(300);
if (!(await isStopped())) {
process.stderr.write(
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
);
return 1;
}
onStopped?.();
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
return 0;
};
// --- health liveness (port + /__ha_dev_status) ------------------------------
/**
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
* on the OS, so try each; the port is "free" only if every address refuses.
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
*/
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
const probe = async (port, timeoutMs = 1000) => {
let sawResponse = false;
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
for (const host of PROBE_HOSTS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
signal: controller.signal,
});
sawResponse = true;
if (res.ok) {
const body = await res.json().catch(() => null);
if (body && body.server === "ha-frontend-dev") {
return { state: "ours", suite: body.suite };
}
}
} catch {
// Try the next address.
} finally {
clearTimeout(timer);
}
}
/* eslint-enable no-await-in-loop */
return sawResponse ? { state: "foreign" } : { state: "free" };
};
// Find the pid listening on a port via the first available tool (no state file).
const pidFromPort = (port) => {
const attempts = [
[
"lsof",
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
(out) => out.trim().split("\n")[0],
],
[
"ss",
["-ltnpH", `sport = :${port}`],
(out) => out.match(/pid=(\d+)/)?.[1],
],
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
];
for (const [cmd, cmdArgs, extract] of attempts) {
try {
const out = execFileSync(cmd, cmdArgs, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const pid = Number(extract(out));
if (Number.isInteger(pid) && pid > 0) {
return pid;
}
} catch {
// Try the next tool.
}
}
return undefined;
};
const runForegroundHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
process.stdout.write(
`Dev server (${suite}) is already running at http://localhost:${port}\n`
);
return 0;
}
if (status.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
};
const runBackgroundHealth = async (suite, cfg) => {
const { port } = cfg;
const preflight = await probe(port);
if (preflight.state === "ours" && preflight.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) already running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
);
return 0;
}
if (preflight.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
cfg.spawn.args
);
return awaitReady({
suite,
child,
logFile,
port,
isReady: async () => {
const status = await probe(port, 1000);
return status.state === "ours" && status.suite === suite;
},
});
};
const runStatusHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n`
);
} else if (status.state === "ours") {
process.stdout.write(
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
);
} else if (status.state === "foreign") {
process.stdout.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
} else {
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
return 0;
};
const runStopHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (!(status.state === "ours" && status.suite === suite)) {
// Idempotent: stopping something that is not running is a success.
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const pid = pidFromPort(port);
if (!pid) {
process.stderr.write(
`Dev server (${suite}) is running but its pid could not be found ` +
`(no lsof/ss/fuser?). Stop it manually.\n`
);
return 1;
}
return terminate(
suite,
pid,
async () => (await probe(port, 800)).state === "free"
);
};
// --- process liveness (pidfile + log-readiness) -----------------------------
const isAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
const readPidFile = (suite) => {
try {
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
if (data && Number.isInteger(data.pid)) {
return data;
}
} catch {
// Missing or corrupt.
}
return undefined;
};
const writePidFile = (suite, data) => {
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
};
const removePidFile = (suite) => {
try {
fs.rmSync(pidFileFor(suite));
} catch {
// Already gone.
}
};
const logIsReady = (logFile, readyLog) => {
try {
return readyLog.test(fs.readFileSync(logFile, "utf8"));
} catch {
return false;
}
};
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
const resolveServePort = (passthrough) => {
const i = passthrough.indexOf("-p");
if (i !== -1) {
const port = Number(passthrough[i + 1]);
if (Number.isInteger(port) && port > 0) {
return port;
}
}
return process.env.DEVCONTAINER ? 8123 : 8124;
};
const spawnArgs = (cfg, passthrough) => [
...cfg.spawn.args,
...(cfg.acceptsArgs ? passthrough : []),
];
const runForegroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running in the background ` +
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
);
return 0;
}
if (existing) {
removePidFile(suite);
}
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
};
const runBackgroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n${hints(suite)}`
);
return 0;
}
if (existing) {
removePidFile(suite);
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
spawnArgs(cfg, passthrough)
);
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
writePidFile(suite, { pid: child.pid, port });
return awaitReady({
suite,
child,
logFile,
port,
isReady: () => logIsReady(logFile, cfg.readyLog),
onExit: () => removePidFile(suite),
});
};
const runStatusProcess = async (suite) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n`
);
} else {
if (existing) {
removePidFile(suite);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
return 0;
};
const runStopProcess = async (suite) => {
const existing = readPidFile(suite);
if (!existing || !isAlive(existing.pid)) {
// Idempotent: stopping something that is not running is a success.
if (existing) {
removePidFile(suite);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const { pid } = existing;
return terminate(
suite,
pid,
() => !isAlive(pid),
() => removePidFile(suite)
);
};
// --- shared -----------------------------------------------------------------
const runLogs = (suite, follow) => {
const logFile = logFileFor(suite);
if (!fs.existsSync(logFile)) {
process.stdout.write(
`No log for the ${suite} dev server yet (${logFile}).\n`
);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
tail.on("error", () => {
// No tail available; fall back to a one-shot dump.
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
});
tail.on("exit", (code) => resolve(code ?? 0));
});
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const cfg = SUITES[args.suite];
if (!cfg) {
usage();
return 1;
}
if (args.passthrough.length && !cfg.acceptsArgs) {
process.stderr.write(
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
);
}
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
// are untouched.
let { mode } = args;
if (
mode === "foreground" &&
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
) {
const agent = detectAgent();
if (agent) {
process.stdout.write(
`Detected coding agent (${agent}); starting in the background. ` +
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
);
mode = "background";
}
}
const health = cfg.liveness === "health";
switch (mode) {
case "background":
return health
? runBackgroundHealth(args.suite, cfg)
: runBackgroundProcess(args.suite, cfg, args.passthrough);
case "status":
return health
? runStatusHealth(args.suite, cfg)
: runStatusProcess(args.suite);
case "stop":
return health
? runStopHealth(args.suite, cfg)
: runStopProcess(args.suite);
case "logs":
return runLogs(args.suite, args.follow);
default:
return health
? runForegroundHealth(args.suite, cfg)
: runForegroundProcess(args.suite, cfg, args.passthrough);
}
};
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
+20
View File
@@ -37,6 +37,7 @@ const isWsl =
* listenHost?: string,
* open?: boolean,
* logUrlAfterFirstBuild?: boolean,
* suite?: string,
* }}
*/
const runDevServer = async ({
@@ -47,6 +48,7 @@ const runDevServer = async ({
open = true,
logUrlAfterFirstBuild = false,
proxy = undefined,
suite = undefined,
}) => {
if (listenHost === undefined) {
// For dev container, we need to listen on all hosts
@@ -81,6 +83,19 @@ const runDevServer = async ({
!error?.message?.includes("ResizeObserver loop"),
},
},
setupMiddlewares: (middlewares) => {
// Status endpoint so the dev-server manager can confirm this is our
// server for the expected suite. Unshifted to beat the static handler.
middlewares.unshift({
name: "ha-dev-status",
path: "/__ha_dev_status",
middleware: (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ server: "ha-frontend-dev", suite, port }));
},
});
return middlewares;
},
proxy,
},
compiler
@@ -152,6 +167,8 @@ gulp.task("rspack-dev-server-demo", () =>
),
contentBase: paths.demo_output_root,
port: 8090,
open: false,
suite: "demo",
})
);
@@ -173,6 +190,7 @@ gulp.task("rspack-dev-server-cast", () =>
port: 8080,
// Accessible from the network, because that's how Cast hits it.
listenHost: "0.0.0.0",
suite: "cast",
})
);
@@ -194,6 +212,7 @@ gulp.task("rspack-dev-server-gallery", () =>
listenHost: "0.0.0.0",
open: false,
logUrlAfterFirstBuild: true,
suite: "gallery",
})
);
@@ -241,6 +260,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
suite: "e2e-app",
})
);
+89 -77
View File
@@ -56,88 +56,100 @@ class HcCast extends LitElement {
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${this.askWrite
? html`
<p class="question action-item">
Stay logged in?
<span>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSaveTokens}
>
YES
</ha-button>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSkipSaveTokens}
>
NO
</ha-button>
</span>
</p>
`
: ""}
${error
? html` <div class="card-content">${error}</div> `
: !this.castManager.status
${
this.askWrite
? html`
<p class="center-item">
<ha-button @click=${this._handleLaunch}>
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
Start Casting
</ha-button>
<p class="question action-item">
Stay logged in?
<span>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSaveTokens}
>
YES
</ha-button>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSkipSaveTokens}
>
NO
</ha-button>
</span>
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${this.castManager.status?.lovelacePath ===
(view.path ?? idx)}
.selected=${this.castManager.status?.lovelacePath ===
(view.path ?? idx)}
>
${view.title || view.path || "Unnamed view"}
${view.icon
? html`
<ha-icon
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`}
</ha-list-item>
`
)}</ha-list
>
`}
: ""
}
${
error
? html` <div class="card-content">${error}</div> `
: !this.castManager.status
? html`
<p class="center-item">
<ha-button @click=${this._handleLaunch}>
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
Start Casting
</ha-button>
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
`
)}</ha-list
>
`
}
<div class="card-actions">
${this.castManager.status
? html`
<ha-button appearance="plain" @click=${this._handleLaunch}>
<ha-svg-icon
slot="start"
.path=${mdiCastConnected}
></ha-svg-icon>
Manage
</ha-button>
`
: ""}
${
this.castManager.status
? html`
<ha-button appearance="plain" @click=${this._handleLaunch}>
<ha-svg-icon
slot="start"
.path=${mdiCastConnected}
></ha-svg-icon>
Manage
</ha-button>
`
: ""
}
<div class="spacer"></div>
<ha-button
variant="danger"
+5 -3
View File
@@ -135,9 +135,11 @@ export class HcConnect extends LitElement {
Show Demo
<ha-svg-icon
slot="end"
.path=${this.castManager.castState === "CONNECTED"
? mdiCastConnected
: mdiCast}
.path=${
this.castManager.castState === "CONNECTED"
? mdiCastConnected
: mdiCast
}
></ha-svg-icon>
</ha-button>
<div class="spacer"></div>
+14 -12
View File
@@ -26,18 +26,20 @@ class HcLayout extends LitElement {
/>
<h1 class="card-header">
Home Assistant Cast${this.subtitle ? ` ${this.subtitle}` : ""}
${this.auth
? html`
<div class="subtitle">
<a href=${this.auth.data.hassUrl} target="_blank"
>${this.auth.data.hassUrl.substr(
this.auth.data.hassUrl.indexOf("//") + 2
)}</a
>
${this.user ? html` ${this.user.name} ` : ""}
</div>
`
: ""}
${
this.auth
? html`
<div class="subtitle">
<a href=${this.auth.data.hassUrl} target="_blank"
>${this.auth.data.hassUrl.substr(
this.auth.data.hassUrl.indexOf("//") + 2
)}</a
>
${this.user ? html` ${this.user.name} ` : ""}
</div>
`
: ""
}
</h1>
<slot></slot>
</div>
+3 -2
View File
@@ -1,9 +1,10 @@
#!/bin/sh
# Develop the demo
# Develop the demo. Pass --background/--status/--stop/--logs to manage a
# detached instance (see build-scripts/dev-server.mjs).
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp develop-demo
exec node build-scripts/dev-server.mjs --suite demo "$@"
+304
View File
@@ -0,0 +1,304 @@
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { mainWindow } from "../../../src/common/dom/get_main_window";
import { navigate } from "../../../src/common/navigate";
import "../../../src/components/ha-button";
import "../../../src/components/ha-card";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "../stubs/cloud-demo-state";
// Walk the DOM, descending into shadow roots, to find the first matching
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
// can ask it to re-fetch after a scenario change.
const deepQuery = (
selector: string,
root: Document | ShadowRoot = document
): Element | null => {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
const elements = root.querySelectorAll("*");
for (const element of elements) {
const shadow = element.shadowRoot;
if (shadow) {
const found = deepQuery(selector, shadow);
if (found) {
return found;
}
}
}
return null;
};
/**
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
* reviewers can preview every UI state of the cloud account page. It writes to
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
* then nudges the page to re-read it. Lives entirely under demo/.
*/
@customElement("cloud-demo-controls")
export class CloudDemoControls extends LitElement {
@state() private _open = true;
@state() private _visible = false;
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
private _unsub?: () => void;
// The demo uses hash-based routing (navigate() sets location.hash), so the
// active route lives in the hash, not the pathname.
private get _currentPath(): string {
const hash = mainWindow.location.hash;
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
}
private _locationChanged = () => {
this._visible = this._currentPath.startsWith("/config/cloud");
};
public connectedCallback(): void {
super.connectedCallback();
this._locationChanged();
mainWindow.addEventListener("location-changed", this._locationChanged);
mainWindow.addEventListener("popstate", this._locationChanged);
mainWindow.addEventListener("hashchange", this._locationChanged);
this._unsub = subscribeCloudDemoScenario((scenario) => {
this._scenario = { ...scenario };
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._locationChanged);
mainWindow.removeEventListener("popstate", this._locationChanged);
mainWindow.removeEventListener("hashchange", this._locationChanged);
this._unsub?.();
}
protected render() {
if (!this._visible) {
return nothing;
}
if (!this._open) {
return html`
<ha-icon-button
class="fab"
label="Cloud demo controls"
.path=${mdiFlaskOutline}
@click=${this._toggleOpen}
></ha-icon-button>
`;
}
return html`
<ha-card>
<div class="header">
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
<span class="title">Cloud demo controls</span>
<ha-icon-button
label="Close"
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<p class="note">
Demo only. Flips the mocked cloud state shown on this page.
</p>
<div class="controls">
${this._segment("Subscription", "account", [
["active", "Active"],
["trialing", "Trialing"],
["canceled", "Canceled"],
["expired", "Expired"],
["unknown", "Unknown"],
])}
${this._toggle("Onboarded", "onboarded")}
${this._toggle("Onboarding postponed", "postponed")}
${this._toggle("Remote access", "remote")}
${this._segment("Remote status", "remoteStatus", [
["ready", "Ready"],
["generating", "Preparing"],
["loading", "Loading"],
["loaded", "Loaded"],
["error", "Error"],
])}
${this._segment("Backups", "backup", [
["fresh", "Recent"],
["stale", "Old"],
["failed", "Failed"],
["local", "Local only"],
["none", "None"],
])}
${this._toggle("Alexa linked", "alexa")}
${this._toggle("Google linked", "google")}
${this._toggle("Cameras (WebRTC)", "webrtc")}
${this._toggle("Has webhooks", "webhooks")}
</div>
</ha-card>
`;
}
private _segment(
label: string,
field: keyof CloudDemoScenario,
options: [string, string][]
) {
return html`
<div class="row">
<span>${label}</span>
<div class="segment">
${options.map(
([value, text]) => html`
<ha-button
size="s"
appearance=${
this._scenario[field] === value ? "filled" : "plain"
}
data-field=${field}
data-value=${value}
@click=${this._segmentClick}
>
${text}
</ha-button>
`
)}
</div>
</div>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<div class="row">
<span>${label}</span>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._toggleChange}
></ha-switch>
</div>
`;
}
private _toggleOpen() {
this._open = !this._open;
}
private _segmentClick(ev: Event) {
const target = ev.currentTarget as HTMLElement;
this._set(
target.dataset.field as keyof CloudDemoScenario,
target.dataset.value!
);
}
private _toggleChange(ev: Event) {
const target = ev.target as HaSwitch;
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
}
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
this._refresh();
}
private _refresh() {
// Refresh the shared cloud status so login-state changes (signed out) and
// status-derived fields update.
const panel = deepQuery("ha-panel-config");
if (panel) {
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
}
// cloud-account fetches its subscription/backup/webhook data once on mount
// and is not cached by the router, so bounce through a sibling cloud route
// to force a clean remount that re-reads the updated mocks.
const path = this._currentPath;
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
const sibling =
path === "/config/cloud/remote"
? "/config/cloud/account"
: "/config/cloud/remote";
navigate(sibling, { replace: true });
window.setTimeout(() => navigate(path, { replace: true }), 0);
}
}
static styles = css`
:host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
}
.fab {
--mdc-icon-button-size: 48px;
--mdc-icon-size: 24px;
background-color: var(--primary-color);
color: var(--text-primary-color, #fff);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
ha-card {
display: block;
width: 320px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--divider-color);
}
.header .title {
flex: 1;
font-weight: var(--ha-font-weight-medium, 500);
}
.header ha-svg-icon {
color: var(--secondary-text-color);
}
.note {
margin: 8px 16px;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s, 0.875rem);
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.segment {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"cloud-demo-controls": CloudDemoControls;
}
}
+1 -2
View File
@@ -9,8 +9,7 @@ export interface DemoConfig {
authorName: string;
authorUrl: string;
description?:
| string
| ((localize: LocalizeFunc) => string | TemplateResult<1>);
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
theme: () => Record<string, string> | null;
+24 -22
View File
@@ -43,28 +43,30 @@ export class HADemoCard extends LitElement implements LovelaceCard {
<ha-card>
<div class="picker">
<div class="label">
${this._switching
? html`<ha-spinner></ha-spinner>`
: until(
selectedDemoConfig.then(
(conf) => html`
${conf.name}
<small>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{
name: html`
<a target="_blank" href=${conf.authorUrl}>
${conf.authorName}
</a>
`,
}
)}
</small>
`
),
""
)}
${
this._switching
? html`<ha-spinner></ha-spinner>`
: until(
selectedDemoConfig.then(
(conf) => html`
${conf.name}
<small>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{
name: html`
<a target="_blank" href=${conf.authorUrl}>
${conf.authorName}
</a>
`,
}
)}
</small>
`
),
""
)
}
</div>
<ha-button @click=${this._nextConfig} .disabled=${this._switching}>
+18
View File
@@ -29,11 +29,13 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
const CONFIG_PANEL_COMMANDS = [
"cloud/",
"webhook/list",
"validate_config",
"config_entries/",
"device_automation/",
@@ -69,6 +71,22 @@ export class HaDemo extends HomeAssistantAppEl {
// `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);
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried.
hass.updateHass({
config: {
...hass.config,
components: [...(hass.config?.components ?? []), "backup", "webhook"],
},
});
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
// the document level; it shows itself only on the cloud panel.
if (!document.querySelector("cloud-demo-controls")) {
document.body.appendChild(document.createElement("cloud-demo-controls"));
}
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+139 -25
View File
@@ -7,42 +7,34 @@ import type {
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { DemoCloudBackup } from "./cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
const backups: BackupContent[] = [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: lastBackupDate,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
},
];
const CLOUD_AGENT = "cloud.cloud";
const backupInfo: BackupInfo = {
backups,
backups: [],
agent_errors: {},
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_action_event: { manager_state: "idle" },
next_automatic_backup: nextBackupDate,
next_automatic_backup: null,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
next_automatic_backup: nextBackupDate,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", "cloud.cloud"],
agent_ids: ["backup.local", CLOUD_AGENT],
include_addons: [],
include_all_addons: true,
include_database: true,
@@ -69,10 +61,132 @@ const agentsInfo: BackupAgentsInfo = {
],
};
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
// cloud overview status line and the backup sub-page reflect the chosen state.
const applyScenario = () => {
const kind = getCloudDemoScenario().backup;
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
// actually reads as overdue rather than slipping under the margin.
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
// The cloud agent is a backup target for the cloud-backed states only. For
// "local" a backup exists but is stored locally (no cloud copy), and for
// "none" there are no automatic backups at all.
const cloudEnabled =
kind === "fresh" || kind === "stale" || kind === "failed";
backupConfig.create_backup.agent_ids = cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"];
switch (kind) {
case "fresh":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "local":
// Automatic backups run, but only to the local agent.
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "stale":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
backupConfig.last_attempted_automatic_backup = old;
// Next scheduled backup is in the past, so it reads as overdue.
backupConfig.next_automatic_backup = overdue;
break;
case "failed":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
// Most recent attempt is newer than the last success, so it failed.
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "none":
backupConfig.automatic_backups_configured = false;
backupConfig.last_completed_automatic_backup = null;
backupConfig.last_attempted_automatic_backup = null;
backupConfig.next_automatic_backup = null;
break;
}
backupInfo.last_completed_automatic_backup =
backupConfig.last_completed_automatic_backup;
backupInfo.last_attempted_automatic_backup =
backupConfig.last_attempted_automatic_backup;
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
backupInfo.backups =
cloudEnabled && backupConfig.last_completed_automatic_backup
? [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: backupConfig.last_completed_automatic_backup,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
]
: [];
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
export const mockBackup = (hass: MockHomeAssistant) => {
hass.mockWS("backup/info", () => backupInfo);
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
// Fresh objects each fetch so re-reading after a mutation actually re-renders
// (Lit change detection is identity-based; the real WS API returns new
// objects too).
hass.mockWS("backup/info", () => ({ ...backupInfo }));
hass.mockWS("backup/config/info", () => ({ config: { ...backupConfig } }));
hass.mockWS("backup/agents/info", () => agentsInfo);
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
backupConfig.create_backup = {
...backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
if (update.schedule) {
backupConfig.schedule = { ...backupConfig.schedule, ...update.schedule };
}
if (update.retention) {
backupConfig.retention = update.retention;
}
if (update.agents) {
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
}
// Reflect the UI-driven backup change into the demo scenario so the demo
// controls panel stays in sync with the mocked state.
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const current = getCloudDemoScenario().backup;
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
? "none"
: cloudNow
? current === "fresh" || current === "stale" || current === "failed"
? current
: "fresh"
: "local";
if (next !== current) {
setCloudDemoScenario({ backup: next });
}
return null;
});
hass.mockWS(
"backup/subscribe_events",
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
+89
View File
@@ -0,0 +1,89 @@
// Demo-only switchable Home Assistant Cloud scenario.
//
// The redesigned cloud account page (src/panels/config/cloud/account) renders
// purely from real WS data. To let reviewers preview every UI state without a
// real cloud account, this module holds a mutable "scenario" that the cloud and
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
// to. It is persisted to localStorage so the choice survives the data the page
// fetches once per visit (subscription, backup config, webhooks).
//
// This lives entirely under demo/ — no production code imports it.
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
// The five PaymentSubscriptionState values.
export type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups are configured, but not to the cloud agent
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
account: "active",
onboarded: true,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: true,
webhooks: true,
};
const STORAGE_KEY = "cloudDemoScenario";
const readScenario = (): CloudDemoScenario => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
}
} catch (_err) {
// Ignore malformed or unavailable storage and fall back to the default.
}
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
};
let scenario: CloudDemoScenario = readScenario();
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
export const subscribeCloudDemoScenario = (
listener: (scenario: CloudDemoScenario) => void
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const setCloudDemoScenario = (
partial: Partial<CloudDemoScenario>
): void => {
scenario = { ...scenario, ...partial };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
} catch (_err) {
// Ignore storage failures (e.g. private mode); state still applies in-memory.
}
listeners.forEach((listener) => listener(scenario));
};
+157 -2
View File
@@ -2,8 +2,15 @@ import type {
CloudStatusLoggedIn,
SubscriptionInfo,
} from "../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { Webhook } from "../../../src/data/webhook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const emptyFilter = () => ({
include_domains: [],
@@ -12,8 +19,23 @@ const emptyFilter = () => ({
exclude_entities: [],
});
const demoWebhooks: Webhook[] = [
{
webhook_id: "demo_front_door",
domain: "automation",
name: "Front door motion",
local_only: false,
},
{
webhook_id: "demo_companion_app",
domain: "mobile_app",
name: "Companion app",
local_only: false,
},
];
// A single mutable status object so that preference changes made in the demo
// are reflected back in the UI.
// (both via the real UI and the demo scenario controls) are reflected back.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
@@ -35,6 +57,8 @@ const cloudStatus: CloudStatusLoggedIn = {
remote_certificate_status: "ready",
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: true,
prefs: {
google_enabled: true,
alexa_enabled: true,
@@ -47,6 +71,8 @@ const cloudStatus: CloudStatusLoggedIn = {
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
onboarding_postponed_until: null,
},
};
@@ -54,6 +80,7 @@ const subscription: SubscriptionInfo = {
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: "active" },
};
const ttsInfo: CloudTTSInfo = {
@@ -66,17 +93,140 @@ const ttsInfo: CloudTTSInfo = {
],
};
// Map the high-level demo scenario onto the mutable cloud status / subscription.
const applyScenario = () => {
const scenario = getCloudDemoScenario();
switch (scenario.account) {
case "trialing":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "trialing" };
break;
case "canceled":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "canceled" };
break;
case "expired":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "expired" };
break;
case "unknown":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "unknown" };
break;
default:
// "active"
cloudStatus.active_subscription = true;
subscription.subscription = { status: "active" };
}
cloudStatus.prefs.onboarded_items = scenario.onboarded
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
cloudStatus.prefs.remote_enabled = scenario.remote;
cloudStatus.remote_connected = scenario.remote;
cloudStatus.remote_certificate_status = scenario.remoteStatus;
cloudStatus.alexa_registered = scenario.alexa;
cloudStatus.google_registered = scenario.google;
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
if (scenario.webhooks && !hasCloudhooks) {
cloudStatus.prefs.cloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
} else if (!scenario.webhooks && hasCloudhooks) {
cloudStatus.prefs.cloudhooks = {};
}
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
// back into the demo scenario so the demo controls panel stays in sync with the
// mocked state. Only writes when a value actually changed, to avoid needless
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
// this stays idempotent and does not fight the direct mutation above.
const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
};
if (
scenario.onboarded !== next.onboarded ||
scenario.postponed !== next.postponed ||
scenario.remote !== next.remote ||
scenario.webrtc !== next.webrtc ||
scenario.webhooks !== next.webhooks
) {
setCloudDemoScenario(next);
}
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => cloudStatus);
hass.mockWS("cloud/status", () => ({
...cloudStatus,
prefs: { ...cloudStatus.prefs },
}));
hass.mockWS("cloud/subscription", () => subscription);
hass.mockWS("cloud/tts/info", () => ttsInfo);
hass.mockWS("webhook/list", () => demoWebhooks);
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
hass.mockWS("cloud/onboarding/complete", (msg) => {
const items: string[] = msg.items ?? [];
const missing = items.filter(
(item) => !cloudStatus.prefs.onboarded_items.includes(item)
);
if (missing.length) {
cloudStatus.prefs.onboarded_items = [
...cloudStatus.prefs.onboarded_items,
...missing,
];
}
cloudStatus.onboarding_completed = ONBOARDING_ITEMS.every(
(onboardingItem) =>
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
);
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
hass.mockWS("cloud/cloudhook/create", (msg) => {
const webhook = {
webhook_id: msg.webhook_id,
@@ -95,15 +245,20 @@ export const mockCloud = (hass: MockHomeAssistant) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
cloudStatus.prefs.remote_enabled = true;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
cloudStatus.prefs.remote_enabled = false;
syncScenarioFromStatus();
return null;
});
+137 -143
View File
@@ -8,103 +8,100 @@ import type {
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEnergy = (hass: MockHomeAssistant) => {
hass.mockWS(
"energy/get_prefs",
(): EnergyPreferences => ({
energy_sources: [
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"],
},
{
type: "battery",
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
stat_rate: "sensor.power_battery",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
stat_cost: "sensor.energy_gas_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{
stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
},
{
stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
},
{
stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
},
{
stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
},
{
stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
},
{
stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
},
],
})
);
hass.mockWS(
"energy/info",
(): EnergyInfo => ({ cost_sensors: {}, solar_forecast_domains: [] })
);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
energy_sources: [
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"],
},
{
type: "battery",
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
stat_rate: "sensor.power_battery",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
stat_cost: "sensor.energy_gas_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{
stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
},
{
stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
},
{
stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
},
{
stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
},
{
stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
},
{
stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
},
],
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
@@ -113,51 +110,48 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
);
const todayString = format(startOfToday(), "yyyy-MM-dd");
const tomorrowString = format(startOfTomorrow(), "yyyy-MM-dd");
hass.mockWS(
"energy/solar_forecast",
(): EnergySolarForecasts => ({
solar_forecast: {
wh_hours: {
[`${todayString}T06:00:00`]: 0,
[`${todayString}T06:23:00`]: 6,
[`${todayString}T06:45:00`]: 39,
[`${todayString}T07:00:00`]: 28,
[`${todayString}T08:00:00`]: 208,
[`${todayString}T09:00:00`]: 352,
[`${todayString}T10:00:00`]: 544,
[`${todayString}T11:00:00`]: 748,
[`${todayString}T12:00:00`]: 1259,
[`${todayString}T13:00:00`]: 1361,
[`${todayString}T14:00:00`]: 1373,
[`${todayString}T15:00:00`]: 1370,
[`${todayString}T16:00:00`]: 1186,
[`${todayString}T17:00:00`]: 937,
[`${todayString}T18:00:00`]: 652,
[`${todayString}T19:00:00`]: 370,
[`${todayString}T20:00:00`]: 155,
[`${todayString}T21:48:00`]: 24,
[`${todayString}T22:36:00`]: 0,
[`${tomorrowString}T06:01:00`]: 0,
[`${tomorrowString}T06:23:00`]: 9,
[`${tomorrowString}T06:45:00`]: 47,
[`${tomorrowString}T07:00:00`]: 48,
[`${tomorrowString}T08:00:00`]: 473,
[`${tomorrowString}T09:00:00`]: 827,
[`${tomorrowString}T10:00:00`]: 1153,
[`${tomorrowString}T11:00:00`]: 1413,
[`${tomorrowString}T12:00:00`]: 1590,
[`${tomorrowString}T13:00:00`]: 1652,
[`${tomorrowString}T14:00:00`]: 1612,
[`${tomorrowString}T15:00:00`]: 1438,
[`${tomorrowString}T16:00:00`]: 1149,
[`${tomorrowString}T17:00:00`]: 830,
[`${tomorrowString}T18:00:00`]: 542,
[`${tomorrowString}T19:00:00`]: 311,
[`${tomorrowString}T20:00:00`]: 140,
[`${tomorrowString}T21:47:00`]: 22,
[`${tomorrowString}T22:34:00`]: 0,
},
hass.mockWS("energy/solar_forecast", (): EnergySolarForecasts => ({
solar_forecast: {
wh_hours: {
[`${todayString}T06:00:00`]: 0,
[`${todayString}T06:23:00`]: 6,
[`${todayString}T06:45:00`]: 39,
[`${todayString}T07:00:00`]: 28,
[`${todayString}T08:00:00`]: 208,
[`${todayString}T09:00:00`]: 352,
[`${todayString}T10:00:00`]: 544,
[`${todayString}T11:00:00`]: 748,
[`${todayString}T12:00:00`]: 1259,
[`${todayString}T13:00:00`]: 1361,
[`${todayString}T14:00:00`]: 1373,
[`${todayString}T15:00:00`]: 1370,
[`${todayString}T16:00:00`]: 1186,
[`${todayString}T17:00:00`]: 937,
[`${todayString}T18:00:00`]: 652,
[`${todayString}T19:00:00`]: 370,
[`${todayString}T20:00:00`]: 155,
[`${todayString}T21:48:00`]: 24,
[`${todayString}T22:36:00`]: 0,
[`${tomorrowString}T06:01:00`]: 0,
[`${tomorrowString}T06:23:00`]: 9,
[`${tomorrowString}T06:45:00`]: 47,
[`${tomorrowString}T07:00:00`]: 48,
[`${tomorrowString}T08:00:00`]: 473,
[`${tomorrowString}T09:00:00`]: 827,
[`${tomorrowString}T10:00:00`]: 1153,
[`${tomorrowString}T11:00:00`]: 1413,
[`${tomorrowString}T12:00:00`]: 1590,
[`${tomorrowString}T13:00:00`]: 1652,
[`${tomorrowString}T14:00:00`]: 1612,
[`${tomorrowString}T15:00:00`]: 1438,
[`${tomorrowString}T16:00:00`]: 1149,
[`${tomorrowString}T17:00:00`]: 830,
[`${tomorrowString}T18:00:00`]: 542,
[`${tomorrowString}T19:00:00`]: 311,
[`${tomorrowString}T20:00:00`]: 140,
[`${tomorrowString}T21:47:00`]: 22,
[`${tomorrowString}T22:34:00`]: 0,
},
})
);
},
}));
};
+4 -7
View File
@@ -2,11 +2,8 @@ import type { EntitySources } from "../../../src/data/entity/entity_sources";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntitySources = (hass: MockHomeAssistant) => {
hass.mockWS(
"entity/source",
(): EntitySources => ({
"sensor.co2_intensity": { domain: "co2signal" },
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
})
);
hass.mockWS("entity/source", (): EntitySources => ({
"sensor.co2_intensity": { domain: "co2signal" },
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
}));
};
+5 -8
View File
@@ -2,12 +2,9 @@ import type { NetworkUrls } from "../../../src/data/network";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockNetwork = (hass: MockHomeAssistant) => {
hass.mockWS(
"network/url",
(): NetworkUrls => ({
internal: "http://homeassistant.local:8123",
external: "https://demo-instance.ui.nabu.casa",
cloud: "https://demo-instance.ui.nabu.casa",
})
);
hass.mockWS("network/url", (): NetworkUrls => ({
internal: "http://homeassistant.local:8123",
external: "https://demo-instance.ui.nabu.casa",
cloud: "https://demo-instance.ui.nabu.casa",
}));
};
+3 -4
View File
@@ -1,8 +1,7 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS(
"frontend/get_translations",
(/* msg: {language: string, category: string} */) => ({ resources: {} })
);
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
};
+3 -2
View File
@@ -1,9 +1,10 @@
#!/bin/sh
# Run the gallery
# Run the gallery. Pass --background/--status/--stop/--logs to manage a
# detached instance (see build-scripts/dev-server.mjs).
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp develop-gallery
exec node build-scripts/dev-server.mjs --suite gallery "$@"
@@ -101,9 +101,11 @@ class DemoBlackWhiteRow extends LitElement {
</ha-button>
</div>
</ha-card>
${this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing}
${
this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing
}
</section>
</div>
`;
+10 -6
View File
@@ -34,9 +34,11 @@ class DemoCard extends LitElement {
return html`
<h2>
${this.config.heading}
${this._size !== undefined
? html`<small>(size ${this._size})</small>`
: ""}
${
this._size !== undefined
? html`<small>(size ${this._size})</small>`
: ""
}
</h2>
<div class="root">
<hui-card
@@ -44,9 +46,11 @@ class DemoCard extends LitElement {
.hass=${this.hass}
@card-updated=${this._cardUpdated}
></hui-card>
${this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
: nothing}
${
this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
: nothing
}
</div>
`;
}
+9 -7
View File
@@ -22,13 +22,15 @@ class DemoMoreInfo extends LitElement {
<div class="root">
<div id="card">
<ha-card>
${!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing}
${
!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing
}
<more-info-content
.hass=${this.hass}
+74 -55
View File
@@ -211,38 +211,46 @@ class HaGallery extends LitElement {
</ha-sidebar>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed .narrow=${this._narrow}>
${this._narrow || !this._drawerOpen
? html`<ha-icon-button
slot="navigationIcon"
@click=${this._toggleDrawer}
.path=${mdiMenu}
></ha-icon-button>`
: nothing}
${
this._narrow || !this._drawerOpen
? html`<ha-icon-button
slot="navigationIcon"
@click=${this._toggleDrawer}
.path=${mdiMenu}
></ha-icon-button>`
: nothing
}
<div slot="title">
${isSettingsPage
? "Settings"
: page?.metadata.title || this._page.split("/")[1]}
${
isSettingsPage
? "Settings"
: page?.metadata.title || this._page.split("/")[1]
}
</div>
<div class="content">
${isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@theme-settings-changed=${this._themeSettingsChanged}
@gallery-rtl-changed=${this._rtlChanged}
></gallery-settings>`
: html`
${page?.description
? html`
<page-description .page=${this._page}>
</page-description>
`
: nothing}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
`}
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@theme-settings-changed=${this._themeSettingsChanged}
@gallery-rtl-changed=${this._rtlChanged}
></gallery-settings>`
: html`
${
page?.description
? html`
<page-description .page=${this._page}>
</page-description>
`
: nothing
}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
`
}
</div>
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
</ha-top-app-bar-fixed>
@@ -390,13 +398,15 @@ class HaGallery extends LitElement {
.header=${group.header}
?expanded=${expanded}
>
${group.icon
? html`<ha-svg-icon
slot="leading-icon"
class="gallery-sidebar-icon"
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${
group.icon
? html`<ha-svg-icon
slot="leading-icon"
class="gallery-sidebar-icon"
.path=${group.icon}
></ha-svg-icon>`
: nothing
}
${content}
</ha-expansion-panel>
`
@@ -454,9 +464,11 @@ class HaGallery extends LitElement {
?selected=${this._page === page}
href=${`#${page}`}
>
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: nothing}
${
iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: nothing
}
<span slot="headline">${title}</span>
</ha-list-item-button>
`;
@@ -487,23 +499,30 @@ class HaGallery extends LitElement {
Suggest an edit to this page, or provide/view feedback for this page.
</div>
<div>
${page.description || Object.keys(page.metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: nothing}
${page.demo
? html`
<a href=${`${GITHUB_DEMO_URL}${this._page}.ts`} target="_blank">
Edit demo
</a>
`
: nothing}
${
page.description || Object.keys(page.metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: nothing
}
${
page.demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: nothing
}
</div>
</div>
</div>`;
@@ -149,9 +149,11 @@ export class DemoAutomationDescribeAction extends LitElement {
<ha-card header="Actions">
<div class="action">
<span>
${this._action
? describeAction(this.hass, [], this._action)
: "<invalid YAML>"}
${
this._action
? describeAction(this.hass, [], this._action)
: "<invalid YAML>"
}
</span>
<ha-yaml-editor
label="Action Config"
@@ -74,9 +74,11 @@ export class DemoAutomationDescribeCondition extends LitElement {
<ha-card header="Conditions">
<div class="condition">
<span>
${this._condition
? describeCondition(this._condition, this.hass, [])
: "<invalid YAML>"}
${
this._condition
? describeCondition(this._condition, this.hass, [])
: "<invalid YAML>"
}
</span>
<ha-yaml-editor
label="Condition Config"
@@ -98,9 +98,11 @@ export class DemoAutomationDescribeTrigger extends LitElement {
<ha-card header="Triggers">
<div class="trigger">
<span>
${this._trigger
? describeTrigger(this._trigger, this.hass, [])
: "<invalid YAML>"}
${
this._trigger
? describeTrigger(this._trigger, this.hass, [])
: "<invalid YAML>"
}
</span>
<ha-yaml-editor
label="Trigger Config"
+12 -10
View File
@@ -54,16 +54,18 @@ export class DemoAutomationTrace extends LitElement {
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
${
selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing
}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
+36 -24
View File
@@ -33,20 +33,24 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-assist-chip .label=${chip.content}>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
</ha-assist-chip>
`
)}
${chips.map(
(chip) => html`
<ha-assist-chip .label=${chip.content} selected>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
</ha-assist-chip>
`
)}
@@ -56,20 +60,24 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-filter-chip .label=${chip.content}>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
</ha-filter-chip>
`
)}
${chips.map(
(chip) => html`
<ha-filter-chip .label=${chip.content} selected>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
</ha-filter-chip>
`
)}
@@ -79,10 +87,12 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-input-chip .label=${chip.content}>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.content}
</ha-input-chip>
`
@@ -90,10 +100,12 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-input-chip .label=${chip.content} selected>
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
</ha-input-chip>
`
)}
@@ -92,14 +92,16 @@ export class DemoHaControlSelectMenu extends LitElement {
.value=${option.value}
.graphic=${option.icon ? "icon" : undefined}
>
${option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing}
${
option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing
}
${option.label ?? option.value}
</ha-list-item>
`
@@ -60,9 +60,9 @@ export class DemoHaLabelBadge extends LitElement {
${badges.map(
(badge) => html`
<ha-label-badge
style="--ha-label-badge-color: ${colors[
Math.floor(Math.random() * colors.length)
]}"
style="--ha-label-badge-color: ${
colors[Math.floor(Math.random() * colors.length)]
}"
.label=${badge.label}
.description=${badge.description}
.image=${badge.image}
@@ -78,9 +78,9 @@ export class DemoHaLabelBadge extends LitElement {
(badge) => html`
<div class="badge">
<ha-label-badge
style="--ha-label-badge-color: ${colors[
Math.floor(Math.random() * colors.length)
]}"
style="--ha-label-badge-color: ${
colors[Math.floor(Math.random() * colors.length)]
}"
.label=${badge.label}
.description=${badge.description}
.image=${badge.image}
+2 -4
View File
@@ -244,8 +244,7 @@ export class DemoHaList extends LitElement {
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
</ha-card>
<ha-card
@@ -272,8 +271,7 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Option: all combinations">
+7 -5
View File
@@ -696,11 +696,13 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
([key, value]) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${value?.name || key}</span>
${value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing}
${
value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing
}
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
@@ -65,30 +65,34 @@ class LandingPageLogs extends LitElement {
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
${this.localize(this._show ? "hide_details" : "show_details")}
</ha-button>
${this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
: nothing}
${
this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
: nothing
}
</div>
${this._error
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
${
this._error
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
>
${this.localize("logs.retry")}
</ha-button>
</ha-alert>
`
: nothing}
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
${this.localize("logs.retry")}
</ha-button>
</ha-alert>
`
: nothing
}
<div
class=${classMap({
logs: true,
@@ -55,13 +55,15 @@ class LandingPageNetwork extends LitElement {
})}
</p>
<p>${this.localize("network_issue.resolve_different")}</p>
${!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
</p>
`
: nothing}
${
!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
</p>
`
: nothing
}
<div class="actions">
${ALTERNATIVE_DNS_SERVERS.map(
({ translationKey }, key) =>
+41 -33
View File
@@ -61,39 +61,47 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-card>
<div class="card-content">
<h1>${this.localize("header")}</h1>
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${this._progress > 0
? `${Math.round(this._progress)}%`
: nothing}</ha-progress-bar
>
`
: nothing}
${networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
></landing-page-network>
`
: nothing}
${this._supervisorError
? html`
<ha-alert
alert-type="error"
.title=${this.localize("error_title")}
>
${this.localize("error_description")}
</ha-alert>
`
: nothing}
${
!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${
this._progress > 0
? `${Math.round(this._progress)}%`
: nothing
}</ha-progress-bar
>
`
: nothing
}
${
networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
></landing-page-network>
`
: nothing
}
${
this._supervisorError
? html`
<ha-alert
alert-type="error"
.title=${this.localize("error_title")}
>
${this.localize("error_description")}
</ha-alert>
`
: nothing
}
<landing-page-logs
.localize=${this.localize}
@landing-page-error=${this._showError}
+32 -28
View File
@@ -24,6 +24,10 @@
"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",
"dev": "node build-scripts/dev-server.mjs --suite app",
"dev:serve": "node build-scripts/dev-server.mjs --suite app-serve",
"dev:demo": "demo/script/develop_demo",
"dev:gallery": "gallery/script/develop_gallery",
"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",
@@ -44,19 +48,19 @@
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.0",
"@codemirror/view": "6.43.3",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-datetimeformat": "7.4.10",
"@formatjs/intl-displaynames": "7.3.11",
"@formatjs/intl-durationformat": "0.10.16",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-listformat": "8.3.11",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@formatjs/intl-numberformat": "9.3.12",
"@formatjs/intl-pluralrules": "6.3.11",
"@formatjs/intl-relativetimeformat": "12.3.11",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -79,8 +83,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -102,16 +106,16 @@
"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.9",
"js-yaml": "5.1.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.10",
"js-yaml": "5.2.1",
"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",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.5",
"marked": "18.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -141,14 +145,14 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.16",
"@rspack/core": "2.0.8",
"@rsdoctor/rspack-plugin": "1.5.18",
"@rspack/core": "2.1.3",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -164,21 +168,21 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint": "10.6.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.0",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"fs-extra": "11.3.6",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
@@ -196,19 +200,19 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.0.2",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.8.4",
"prettier": "3.9.5",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.17",
"tar": "7.5.19",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.62.0",
"typescript-eslint": "8.63.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -224,7 +228,7 @@
"@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"
},
"packageManager": "yarn@4.17.0",
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.0"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

+10 -1
View File
@@ -11,7 +11,7 @@
"group:recommended",
"security:minimumReleaseAgeNpm"
],
"enabledManagers": ["npm", "nvm"],
"enabledManagers": ["npm", "nvm", "custom.regex"],
"postUpdateOptions": ["yarnDedupeHighest"],
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
@@ -49,6 +49,15 @@
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python",
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
},
{
"description": "Keep actionlint used in CI up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/actionlint\\.yaml$/"],
"matchStrings": ["ACTIONLINT_VERSION: (?<currentValue>\\S+)"],
"depNameTemplate": "rhysd/actionlint",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
}
],
"packageRules": [
+23 -17
View File
@@ -182,9 +182,11 @@ export class HaAuthFlow extends LitElement {
@click=${this._handleSubmit}
.loading=${this._submitting}
>
${this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")}
${
this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")
}
</ha-button>
</div>
`;
@@ -224,9 +226,11 @@ export class HaAuthFlow extends LitElement {
case "form":
return html`
<h1>
${!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")}
${
!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")
}
</h1>
${this._computeStepDescription(step)}
${keyed(
@@ -244,17 +248,19 @@ export class HaAuthFlow extends LitElement {
)}
<div class="space-between">
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""}
${
this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""
}
<a
class="forgot-password"
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
+50 -37
View File
@@ -147,45 +147,58 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
}
</style>
${!this._ownInstance
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
${app
? this.localize("ui.panel.page-authorize.authorizing_app", {
app: appNames[this.clientId!],
})
: this.localize("ui.panel.page-authorize.authorizing_client", {
clientId: html`<b
>${this.clientId
? punycode.toASCII(this.clientId)
: this.clientId}</b
>`,
})}
</ha-alert>`
: nothing}
${
!this._ownInstance
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
${
app
? this.localize("ui.panel.page-authorize.authorizing_app", {
app: appNames[this.clientId!],
})
: this.localize(
"ui.panel.page-authorize.authorizing_client",
{
clientId: html`<b
>${
this.clientId
? punycode.toASCII(this.clientId)
: this.clientId
}</b
>`,
}
)
}
</ha-alert>`
: nothing
}
<div class="card-content">
${!this._authProvider
? html`<p>
${this.localize("ui.panel.page-authorize.initializing")}
</p> `
: html`<ha-auth-flow
.clientId=${this.clientId}
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
></ha-pick-auth-provider>
`
: ""}`}
${
!this._authProvider
? html`<p>
${this.localize("ui.panel.page-authorize.initializing")}
</p> `
: html`<ha-auth-flow
.clientId=${this.clientId}
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${
inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
></ha-pick-auth-provider>
`
: ""
}`
}
</div>
<div class="footer">
<ha-language-picker
+1 -4
View File
@@ -33,10 +33,7 @@ export interface ShowDemoMessage extends BaseCastMessage {
}
export type HassMessage =
| ShowDemoMessage
| GetStatusMessage
| ConnectMessage
| ShowLovelaceViewMessage;
ShowDemoMessage | GetStatusMessage | ConnectMessage | ShowLovelaceViewMessage;
export const castSendAuth = (cast: CastManager, auth: Auth) =>
cast.sendMessage({
+6 -15
View File
@@ -1,24 +1,17 @@
import type {
Condition,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(
conditions: VisibilityCondition[]
): string[] {
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (
"condition" in c &&
c.condition === "screen" &&
"media_query" in c &&
c.media_query
) {
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
@@ -29,16 +22,14 @@ export function extractMediaQueries(
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: VisibilityCondition[]
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
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);
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
+78 -50
View File
@@ -1,9 +1,10 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
TimeCondition,
VisibilityCondition,
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -15,68 +16,95 @@ import { calculateNextTimeUpdate } from "./time-calculator";
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* 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.
* Helper to setup media query listeners for conditional visibility
*/
function scheduleTimeBoundaryListener(
getHass: () => HomeAssistant,
timeCondition: Omit<TimeCondition, "condition">,
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onBoundary: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const mediaQueries = extractMediaQueries(conditions);
const scheduleUpdate = () => {
// Read hass lazily so timezone changes are picked up on the next boundary.
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
if (mediaQueries.length === 0) return;
if (delay === undefined) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
onBoundary();
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
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);
}
});
addListener(unsub);
});
scheduleUpdate();
}
/**
* 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.
* Helper to setup time-based listeners for conditional visibility
*/
export function observeConditionChanges(
conditions: VisibilityCondition[],
getHass: () => HomeAssistant,
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onChange: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
extractMediaQueries(conditions).forEach((mediaQuery) => {
addListener(listenMediaQuery(mediaQuery, () => onChange()));
});
const timeConditions = extractTimeConditions(conditions);
extractTimeConditions(conditions).forEach((timeCondition) => {
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, 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();
});
}
/**
* 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
@@ -1,176 +0,0 @@
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
@@ -1,251 +0,0 @@
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 };
};
@@ -1,329 +0,0 @@
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;
}
}
@@ -0,0 +1,59 @@
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 = [];
}
}
+1 -7
View File
@@ -1,13 +1,7 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
"sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export type WeekdayLong =
| "sunday"
+85 -8
View File
@@ -1,5 +1,7 @@
import { consume } from "@lit/context";
import { ContextEvent } from "@lit/context";
import type { Context, ContextCallback } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveElement } from "lit";
import type {
HomeAssistant,
HomeAssistantInternationalization,
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
return previous;
};
/**
* Reactive controller that subscribes to a Lit context and assigns each
* delivered value to a host property WITHOUT forcing a host update on every
* delivery.
*
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
* unconditionally on every provider notification. For a hot context such as
* `statesContext` (replaced on every entity state change) that means every
* consumer runs an (often empty) update/render cycle on every unrelated state
* change, even when the value it actually reads is unchanged.
*
* This controller instead leaves update scheduling to the property's own
* setter. Combined with {@link transform}, that setter only requests an update
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
* with `hasChanged`), so unrelated context churn no longer triggers renders.
*/
class ContextSubscriptionController<ValueType> implements ReactiveController {
private _unsubscribe?: () => void;
constructor(
private readonly _host: ReactiveElement,
private readonly _context: Context<unknown, ValueType>,
private readonly _assign: (value: ValueType) => void
) {
this._host.addController(this);
}
public hostConnected(): void {
this._host.dispatchEvent(
new ContextEvent(this._context, this._host, this._callback, true)
);
}
public hostDisconnected(): void {
this._unsubscribe?.();
this._unsubscribe = undefined;
}
// Class field arrow function so the identity is stable per instance, which the
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
private readonly _callback: ContextCallback<ValueType> = (
value,
unsubscribe
) => {
// A different provider answered (e.g. re-parenting); drop the stale one.
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
this._unsubscribe();
}
this._unsubscribe = unsubscribe;
// Assign through the property setter, which decides — via `hasChanged` —
// whether an update is actually needed. We intentionally never call
// `host.requestUpdate()` here.
this._assign(value);
};
}
/**
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
* host update on every provider notification see
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
* update is scheduled only when the derived value actually changes.
*/
const subscribeContext =
<ValueType>(context: Context<unknown, ValueType>) =>
(proto: object, propertyKey: string): void => {
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
(host) => {
new ContextSubscriptionController<ValueType>(
host as ReactiveElement,
context,
(value) => {
(host as unknown as Record<string, unknown>)[propertyKey] = value;
}
);
}
);
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
context: Context<unknown, T>,
watchKey: string | undefined,
select: (this: unknown, value: T) => V | undefined
) => {
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({ context, subscribe: true });
const consumeDec = subscribeContext<T>(context);
return (proto: any, propertyKey: string) => {
transformDec(proto, propertyKey);
consumeDec(proto, propertyKey);
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
const consumeDec = subscribeContext<HassEntities>(statesContext);
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed the decorated property updates
* whenever the i18n context changes.
* whenever `localize` changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
+1 -2
View File
@@ -13,8 +13,7 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
const anchor = e
.composedPath()
.find((n) => (n as HTMLElement).tagName === "A") as
| HTMLAnchorElement
| undefined;
HTMLAnchorElement | undefined;
if (
!anchor ||
anchor.target ||
@@ -82,8 +82,7 @@ export const computeAttributeValueToParts = (
: formatNumber(attributeValue, locale);
let unit = DOMAIN_ATTRIBUTES_UNITS[domain]?.[attribute] as
| string
| undefined;
string | undefined;
if (domain === "weather") {
unit = getWeatherUnit(config, stateObj as WeatherEntity, attribute);
@@ -156,8 +155,7 @@ export const computeAttributeValueToParts = (
const domain = computeDomain(entityId);
const deviceClass = stateObj.attributes.device_class;
const registryEntry = entities[entityId] as
| EntityRegistryDisplayEntry
| undefined;
EntityRegistryDisplayEntry | undefined;
const translationKey = registryEntry?.translation_key;
const formattedValue =
+1 -2
View File
@@ -14,8 +14,7 @@ export const computeEntityName = (
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
EntityRegistryDisplayEntry | undefined;
if (!entry) {
// Fall back to state name if not in the entity registry (friendly name)
+2 -4
View File
@@ -49,8 +49,7 @@ export const computeStateDisplay = (
state?: string
): string => {
const entity = entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
EntityRegistryDisplayEntry | undefined;
return computeStateDisplayFromEntityAttributes(
localize,
locale,
@@ -302,8 +301,7 @@ export const computeStateToParts = (
state?: string
): ValuePart[] => {
const entity = entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
EntityRegistryDisplayEntry | undefined;
return computeStateToPartsFromEntityAttributes(
localize,
locale,
@@ -24,8 +24,7 @@ export const getEntityContext = (
floors: HomeAssistant["floors"]
): EntityContext => {
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return {
@@ -52,9 +51,7 @@ export const getEntityAreaId = (
export const getEntityEntryContext = (
entry:
| EntityRegistryDisplayEntry
| EntityRegistryEntry
| ExtEntityRegistryEntry,
EntityRegistryDisplayEntry | EntityRegistryEntry | ExtEntityRegistryEntry,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
+17
View File
@@ -64,6 +64,23 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (__DEMO__) {
if (path.includes("#")) {
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
return true;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
+1 -2
View File
@@ -114,8 +114,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
const messageKey = key + translatedValue;
let translatedMessage = cache._localizationCache![messageKey] as
| IntlMessageFormat
| undefined;
IntlMessageFormat | undefined;
if (!translatedMessage) {
try {
+2 -1
View File
@@ -1,5 +1,6 @@
export const constructUrlCurrentPath = (searchParams: string): string => {
const base = window.location.pathname;
const hash = __DEMO__ ? window.location.hash : "";
// Prevent trailing "?" if no parameters exist
return searchParams ? base + "?" + searchParams : base;
return `${searchParams ? `${base}?${searchParams}` : base}${hash}`;
};
@@ -10,11 +10,7 @@ import {
} from "./query-params";
export type HistoryLogbookTargetParamKey =
| "entity_id"
| "label_id"
| "floor_id"
| "area_id"
| "device_id";
"entity_id" | "label_id" | "floor_id" | "area_id" | "device_id";
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
+1 -3
View File
@@ -2,9 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../array/ensure-array";
export type SearchParamsSource =
| URLSearchParams
| Record<string, string>
| string;
URLSearchParams | Record<string, string> | string;
export interface QueryParamConfig {
list?: readonly string[];
+4 -4
View File
@@ -27,7 +27,7 @@ export const deepEqual = (
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
for (i = length; i-- !== 0;) {
if (!deepEqual(a[i], b[i], options)) {
return false;
}
@@ -71,7 +71,7 @@ export const deepEqual = (
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
for (i = length; i-- !== 0;) {
if (a[i] !== b[i]) {
return false;
}
@@ -94,13 +94,13 @@ export const deepEqual = (
if (length !== Object.keys(b).length) {
return false;
}
for (i = length; i-- !== 0; ) {
for (i = length; i-- !== 0;) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
}
}
for (i = length; i-- !== 0; ) {
for (i = length; i-- !== 0;) {
const key = keys[i];
if (!deepEqual(a[key], b[key], options)) {
+1 -8
View File
@@ -3,14 +3,7 @@ import type { FrontendLocaleData } from "../../data/translation";
import { firstWeekdayIndex } from "../datetime/first_weekday";
export type Unit =
| "second"
| "minute"
| "hour"
| "day"
| "week"
| "month"
| "quarter"
| "year";
"second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
const MS_PER_SECOND = 1e3;
const SECS_PER_MIN = 60;

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