Compare commits

..
167 Commits
Author SHA1 Message Date
Petar Petrov 04f744e90e Allow a few pixels of slack when counting sections view columns.
Subtracting wrapper padding made 1080px kiosks fall 8px short of the
3-column threshold, so span-2/3 sections stacked as a single column.
2026-08-13 13:38:20 +03:00
22ca986cce Skip source maps in the gallery build for e2e (#53625)
The gallery build is the only e2e build that still emits source maps: unlike
demo and the e2e test app, `bundle.config.gallery` never accepted
`isTestBuild`, so the production build always used `nosources-source-map` —
even though the artifact is only ever loaded by Playwright.

The e2e run's gallery artifact contains 693 `.map` files (14 MB of a 105 MB
dist); the demo artifact, which already passes `is-test`, contains none.

Thread `isTestBuild` through the gallery config and set `is-test: true` for the
e2e gallery build, so those maps are no longer generated. The design
deployment and preview workflows do not set `IS_TEST`, so they keep their
source maps.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 13:11:23 +03:00
7f8bf69424 List running automations & scripts in restart dialog (#53523)
* List running automations & scripts in restart dialog

* Don't need a new dialog

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <[email protected]>

* Show running automations in quick bar restart confirmation

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Norbert Rittel <[email protected]>
2026-08-13 09:10:23 +00:00
Aidan TimsonandGitHub 92224411e1 Add context to entity details (#53624)
* Add context to entity details

* Include entity context in YAML details

* Remove category from entity details

* Only link integrations with config entries
2026-08-13 12:04:12 +03:00
b52d58eccb Always group updates page entities by integration (#53480)
* Always group updates page entities by integration

Every integration now gets its own card on the updates page, titled with
the integration name, even when it only has a single update entity. The
catch-all Integrations card is removed. The Update all button is only
shown for cards with more than one entity.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DTuGyvS1i7w1B1xEGCEVhs

* Show update button on cards with a single update

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Paul Bottein <[email protected]>
2026-08-13 08:59:25 +00:00
a9cc47888a Add child devices to the device registry (#53617)
* Add child devices to the device registry

Child devices arrive over the WebSocket as stripped entries in the flat
config/device_registry/list response (discriminated by the absence of
full-device fields). Resolve them into complete DeviceRegistryEntry
objects at ingestion so hass.devices only ever holds full entries and the
~200 downstream consumers stay unchanged:

- config-entry association comes from the child's own config_entry_id, so
  children still appear under their integration;
- hardware/display fields are inherited from the parent device;
- connections/via_device_id are not inherited.

Adds parent_device_id and the new "device" disabled_by value to the types.

Frontend data layer for home-assistant/core#178666.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Resolve child device effective area in the UI

A child device without an area of its own inherits its parent's area,
mirroring core's async_get_effective_area_id. getDeviceArea now takes the
devices map and falls back to the parent, so children show in the parent's
area everywhere a normal device would: the device dashboard (area column,
grouping and filtering via computeDeviceAreaLabel) and the device page
header.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address review on child devices data layer

- Discriminate stripped child entries on `connections` (present on every full
  device, never on a stripped child) instead of the deprecated `config_entries`
  compatibility field that core plans to remove.
- Make getDeviceArea's `devices` argument required and update all callers, so a
  child device's effective (inherited) area is resolved consistently everywhere,
  including the device picker's selected-value renderer and the integration
  device rows.
- Lock the enable switch in the device settings dialog when a device is disabled
  by its parent (disabled_by "device"); core ignores enabling a child while its
  parent is disabled.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Inherit the parent device area when resolving entity areas

Extract getDeviceAreaId so an entity on a child device resolves to the child's
effective area (the parent's area when the child has none), matching core's
entity area resolution. Applies to both getEntityAreaId and
getEntityEntryContext, which previously read device.area_id directly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 11:57:28 +03:00
Paul BotteinandGitHub ea0ceecbfc Add ha-grouped-list component (#53618) 2026-08-13 08:47:14 +01:00
5f007a1575 Run the frontend build in parallel with lint and tests (#53623)
* Run the frontend build in parallel with lint and tests

The build job waited for lint and test because a full build was expensive
enough that we did not want to spend it on a PR that fails its checks. With
the rspack persistent cache it now takes ~3 min instead of ~5, and it is the
longest job in the run, so serialising it behind the others dominates CI
wall-clock: 8s + max(lint 86s, test 123s) + build 194s.

Depend only on prepare-dependencies so all three run together, which brings a
successful run down from ~5.5 min to ~3.5 min — the build itself becomes the
floor.

To avoid finishing an expensive build for a PR that is already broken, each of
the three jobs cancels the whole run when it fails. The cancel step needs
`actions: write`; on pull requests from forks the token stays read-only, so it
is a no-op there and the jobs just run to completion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Keep the Actions write scope away from pull request code

The cancellation needed `actions: write`, and granting it at workflow scope
handed it to every job — including the ones that check out the pull request
and pass GITHUB_TOKEN into the gulp build, so PR-controlled code (or a
compromised dependency) would have had write access to Actions.

Move the cancellation into its own job that holds `actions: write` on its own
and never checks out the repository, so the elevated token is never exposed to
PR code. It cannot simply `needs` the checks — a dependent job only starts once
they have all finished, which is too late to cancel anything — so it polls the
run's job statuses and cancels on the first failure.

Costs one extra (idle) runner slot for the duration of the run.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 08:48:24 +03:00
Bram KragtenandGitHub f360a22927 Wait for analytics data during onboarding (#53621) 2026-08-13 06:56:16 +02:00
Paul BotteinandGitHub a67111e41f Render state and numeric state entities as row targets (#53610)
* Render state and numeric state entities as row targets

* Add tests for entity-less trigger and condition descriptions
2026-08-13 06:53:44 +02:00
4b7d3a7e4f Add rspack persistent cache (nightly writes, CI reads) (#53611)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-12 22:30:42 +02:00
Petar PetrovandGitHub 88be7adafa Stop number box rows from reserving unused space (#53614) 2026-08-12 14:42:09 +01:00
Paul BotteinandGitHub 91a6d737b3 Fix row target badge height and font (#53612) 2026-08-12 10:17:08 +01:00
Petar PetrovandGitHub 22c3a6fe67 Don't trim the selected value in pickers (#53613) 2026-08-12 10:16:39 +01:00
Petar PetrovandGitHub bcc799970a Fix lowercase view button in blueprint in-use dialog (#53609) 2026-08-12 09:55:34 +01:00
Petar PetrovandGitHub 31d4a37c15 Fix time condition summary when before is midnight (#53608) 2026-08-12 09:51:51 +01:00
Paul BotteinandGitHub 49ea96e091 Add icon button group animation (#53597)
* Animate the selected toggle circle in ha-icon-button-group

* Round the light color wheels to fit the selected ring

* Use lit motion
2026-08-12 10:19:07 +03:00
08b33ccbc1 Decouple translations artifact from the nightly build (#53602)
* Skip backend translations download in nightly build

The nightly only builds the app (build-app), which does not merge backend
translations — the shipped app fetches those from core at runtime. The
backend Lokalise export is a whole-project download across all languages
and the slowest part of the translations step. Skipping it, as the release
already does, cuts several minutes off every nightly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Decouple translations artifact into a parallel job

The full translations (including the slow Lokalise backend/core export) are
only needed for the uploaded `translations` artifact, not the wheel:
build-app does not merge backend translations. Move that download and the
artifact upload into a separate `translations` job that runs in parallel
with the build, so the backend export no longer sits on the build's
critical path. Both jobs run in the same workflow run, so consumers still
find both the `wheels` and `translations` artifacts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 20:48:38 +02:00
Aidan TimsonandGitHub 048e754149 Match tokens card actions position and size of button with others (#53605) 2026-08-11 18:22:19 +02:00
Aidan TimsonandGitHub 3a30ea5973 Fix loading states for async config pages (#53604) 2026-08-11 15:32:53 +02:00
Aidan TimsonandGitHub f7836fd3d5 Show loading screen while Labs features load (#53601) 2026-08-11 16:11:04 +03:00
Paul BotteinandGitHub 03d8c092ce Render the target picker entities count as a button (#53598)
* Implement the xs button size

* Render the target picker entities count as a button
2026-08-11 15:40:14 +03:00
Aidan TimsonandGitHub b3aa3c83d5 Change area navigation to icon button in device page (#53600)
* Add area navigation button to device page

* Remove area button tooltip
2026-08-11 15:00:00 +03:00
85c7d071fe Recover from a stale build after boot (#53582)
* Recover from a stale build after boot (lazy-chunk 404s)

When the app stays open across a Home Assistant upgrade, the previous
build's content-hashed lazy chunks are deleted, so opening a dialog,
more-info, card, or panel that was not yet loaded 404s. Today that
dead-ends: dialogs fail silently, panels show a Back-only error screen.

Add a shared recovery authority (recover-stale-build.ts): detect a stale
hashed-chunk load failure and either reload onto the current build (drop
the service worker + caches, cache-busting nav, one-shot cooldown guard)
or, when an editor has unsaved changes, show a non-dismissable toast that
reloads once the dirty state clears. Hook it into the global
error/unhandledrejection handlers, the router's swallowed load error, and
give hass-error-screen a reload action. Chunk-error patterns come from a
JSON single source shared with the boot guard.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Wire stale-build recovery into more paths, add tests

- make-dialog-manager: don't cache a rejected dialog import, so a stale
  chunk 404 (or transient failure) no longer permanently breaks that
  dialog until a full page reload — a later open re-imports.
- home-assistant: _checkUpdate now uses reloadFresh() instead of the
  no-longer-effective location.reload(true) (forceGet is ignored by
  modern browsers).
- connection-mixin: drop the dead reload(true) forceGet arg on the
  safe_mode reload.
- Add unit tests for isStaleBuildError and the recoverFromStaleBuild
  clean / dirty / dev-demo / non-stale / loop-guard branches.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Recover in the companion app via the reload_and_clear_cache command

The iOS/Android companion app (WKWebView) has no service worker, and its
document HTTP cache is not cleared by the Cache API, so the web-level
reload path is ineffective there. When an external bus is present,
reloadFresh() now fires the native `frontend/reload_and_clear_cache`
command (shipped in home-assistant/iOS#5190) so the app purges its cache
and reloads; browsers still take the web path.

Adds the outgoing message type and extracts the bus transport into an
exported fireExternalBusMessage() so it can be sent without a hass/bus
reference.

Closes #53405. Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address Copilot review feedback

- reloadFresh: only send frontend/reload_and_clear_cache to the WebKit
  (iOS) bridge; Android and browsers take the service-worker + cache-clear
  path (Android's WebView has a service worker). Fail closed when the
  sessionStorage cooldown marker can't be persisted so it can't loop. Return
  whether a reload was actually started, so a guard-blocked failure is still
  surfaced/logged instead of silently swallowed.
- Add a dirty-aware reloadForUpdate() and route _checkUpdate and the error
  screen's Refresh button through it; drop the dirty toast's immediate
  action (it auto-reloads once changes are saved/discarded).
- Set showReload on the error-screen element at the call site so router
  overrides (e.g. ToolsRouter) can't drop it.
- Tests: exercise the WebKit bridge and the SW/cache-clear branch; assert
  the dirty toast has no reload action.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 13:37:44 +02:00
Aidan TimsonandGitHub 6fe34bbf7e Add device link to Bluetooth advertisement dialog (#53599) 2026-08-11 14:27:06 +03:00
Aidan TimsonandGitHub 5291a84c87 Align highlighted config entries to the top (#53595)
* Align highlighted config entries to the top

* Apply suggestion from @timmo001
2026-08-11 10:54:58 +00:00
Paul BotteinandGitHub c0575fcb42 Unify back navigation across subpages (#53501)
* Unify back navigation across subpages

* Address back navigation review findings

* Fix state

* Add missing back path

* Remove back path from editors to fix unsaved changes prompt

* Use more specific back fallback paths
2026-08-11 10:39:38 +00:00
ea98a85088 Reserve scrollbar gutter in more-info dialog to prevent flicker (#53522)
* Reserve scrollbar gutter in more-info dialog to prevent flicker

* Update src/dialogs/more-info/ha-more-info-dialog.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-11 10:33:12 +00:00
Bram KragtenandGitHub a1370e331f Replace remove device command (#53594) 2026-08-11 11:22:28 +02:00
pcan08andGitHub 75e862540f Add light-effect tile card feature and related suggestion (#53589) 2026-08-11 08:52:15 +02:00
Petar PetrovandGitHub fd0fdb37fe Fix history graphs redrawing with a different shape every few seconds (#53578)
Anchor history graph downsampling to absolute time
2026-08-11 08:48:28 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4893e65841 Update dependency eslint to v10.8.1 (#53591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-11 08:05:37 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
38946ad69d Update dependency @types/luxon to v3.7.4 (#53588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 22:27:59 +02:00
pcan08andGitHub c0b296a5a2 Add vacuum-fan-speed tile card feature and add related tile suggestions (#53551)
* Add vacuum-fan-speed tile card feature and add related tile suggestions

* Remove multi feature suggestion
2026-08-10 19:45:02 +02:00
Bram KragtenandGitHub 8b9cf84ee8 Dont ship unused old translation keys (#53583)
* dont ship unused old translation keys

* avoid prototype pollution
2026-08-10 15:32:09 +02:00
Bram KragtenandGitHub c61622de43 Dont download core translations when releasing app (#53585)
dont download core translations when releasing app
2026-08-10 15:31:18 +02:00
Bram KragtenandGitHub affce99c9b Cache compression output across builds to speed up releases (#53561) 2026-08-10 13:35:32 +02:00
karwostsandGitHub 22993a9d0a Fix areas/floors reorder config page (#53553) 2026-08-10 10:31:09 +01:00
Yosi LevyandGitHub 89b7ff694d Fix ha-selector-number (#53577) 2026-08-10 10:29:54 +02:00
Petar PetrovandGitHub 5a7c2ac175 Fix sensor card graph footers not receiving hass on lazy upgrade (#53576) 2026-08-10 08:57:12 +01:00
Yosi LevyandGitHub 6e007c8c9a Fix date display in logs (#53575) 2026-08-10 08:46:13 +01:00
Bram KragtenandGitHub a0969d0b08 Fix panels missing updates (#53548) 2026-08-10 08:36:49 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
070251f360 Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#53568)
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.1 to 1.14.2.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ba38be9e461d3875417946c167d0b5f3d385a247...dc37677b2e1c63e2034f94d8a5b11f265b73ba33)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 10:43:57 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
11d38fcd6a Bump home-assistant/actions/helpers/verify-version from ab22029681aa532bfe7de5774a9972d67bfbd2c0 to a7c616ce81ccda50150bf1595786c71b1883fabb (#53566)
Bump home-assistant/actions/helpers/verify-version

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 10:43:36 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ce6da0c03d Bump actions/stale from 10.4.0 to 11.0.0 (#53569)
Bumps [actions/stale](https://github.com/actions/stale) from 10.4.0 to 11.0.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 10:43:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
011af7c8ea Bump release-drafter/release-drafter from 7.6.0 to 7.7.0 (#53567)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/eada3c96a64734dd381cfbda23511034e328ddb0...34d80673e067bdc0c24568d3af899c216adcfaa9)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 10:43:26 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
94aa373387 Bump the codeql-action group across 1 directory with 2 updates (#53565)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.3 to 4.37.4
- [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/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.4
- [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/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-09 10:43:19 +02:00
Aura Herrero RuizandGitHub c5bfbd7478 Fix camera domain "recording" state not being considered an active state (#53546)
* Correct stateActive for camera domain

* Use list of active states for camera domain
2026-08-08 18:32:52 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
72fc9c7278 Update dependency hls.js to v1.6.17 (#53562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-08 16:13:16 +00:00
Petar PetrovandGitHub 79cf8b0f04 Add initial value setting to input_boolean helper dialog (#53516)
* Add initial value setting to input_boolean helper dialog

* Move toggle initial value into More options and clarify wording

* Move toggle initial value hint into an info tooltip
2026-08-08 18:02:40 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4a8fca8bb8 Update dependency @codemirror/view to v6.43.8 (#53556)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-08 12:46:29 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6a18686b1c Update dependency core-js to v3.50.0 (#53557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-08 12:46:18 +02:00
Bram KragtenandGitHub f456bd7dc6 Check for wheel on PyPI simple index instead of JSON API (#53549) 2026-08-07 20:59:18 +02:00
Paul BotteinandGitHub 78a2d4d6df Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 13:40:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0f8d7b4d41 Update dependency @rspack/core to v2.1.8 (#53547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 16:27:32 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cf3009f408 Update dependency marked to v18.0.9 (#53544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 16:26:21 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bdf629f836 Update dependency typescript-eslint to v8.66.0 (#53541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 05:18:39 +00:00
Krisjanis LejejsandGitHub 464b58af04 Add cloud page UI to the Gallery (#53532)
Add cloud page UI to the design
2026-08-06 17:03:08 +02:00
Bram KragtenandGitHub 29a75209ac Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-06 17:12:08 +03:00
Petar PetrovandGitHub f89b3c23c7 Keep selected option visible in disabled select box (#53535) 2026-08-06 15:02:41 +01:00
Bram KragtenandGitHub 29b631b960 Fix link to different port being handled as internal link (#53530) 2026-08-06 15:43:44 +02:00
renovate[bot]andGitHub dd94a7bac1 Update dependency @rspack/dev-server to v2.2.0 (#53529) 2026-08-06 14:19:01 +01:00
Aidan TimsonandGitHub 34154195c8 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-06 14:53:54 +02:00
0b34fcb559 Show target-state entity icon in scene editor review mode (#52520)
* Show target-state entity icon in scene editor review mode

Render the entity badge in review mode (previously live-only) across both
the device-grouped and standalone entity lists. In review mode the badge
uses the scene's stored target state instead of the entity's live state, so
the icon previews what the scene will set once applied.

* Strip stale entity_picture from the synthesized review-mode state

state-badge hides the icon and renders a background image whenever a state
carries an entity_picture (see its willUpdate). A scene snapshots that URL
with an access token that is stale by the time review mode renders, so an
image-backed entity - for example a robot vacuum's "Map data" camera entity
on its device - showed a blank badge in review mode instead of an icon.

Drop entity_picture / entity_picture_local from the synthesized state so the
entity's own icon resolves. This is not a pre-existing defect: it handles a
case that rendering the badge in review mode (previous commit) introduces.

* Handle null and scalar scene entity values in review mode badges

An entity left without a value in the YAML editor parses as null, which
crashed the review-mode render. The scene config API also returns raw
scenes.yaml content without validation, so hand-edited scenes deliver
boolean states as-is (YAML 1.1 parses unquoted on/off as booleans);
these previously rendered as if the entity had no state at all.

Booleans map to on/off to match how the backend applies them when a
scene is activated (_convert_states in the homeassistant scene
platform). The backend rejects null and numeric states at save, but
review mode renders before save, so the frontend has to tolerate them.

* Extract and memoize the scene target-state synthesis

The editor re-renders on every hass change, and building a fresh state
object per row each time defeated Lit dirty-checking: every state-badge
re-ran willUpdate and every ha-state-icon restarted its async icon
resolution. Memoizing the synthesized objects per config keeps the
references stable so unchanged badges skip all of that.

Moving the synthesis to src/data/scene.ts makes it unit-testable; the
null, boolean, numeric, string, and picture-stripping cases are now
covered by tests.

* Sanitize brightness and rgb_color in the synthesized review-mode state

state-badge does arithmetic on brightness and joins rgb_color, assuming
backend-shaped values. Hand-typed YAML can hold both as strings: a
string rgb_color threw a TypeError that left the badge blank, and a
string brightness computed a brightness(36049%) filter that washed the
icon out to invisible. Coerce numeric-string brightness and drop
malformed values so the badge always renders the target state.

* Borrow the live device_class for review-mode badge icons

Icon resolution keys on device_class, which string-shorthand and
hand-written minimal scene entries do not carry, so a garage cover fell
back to the generic window icon and sensors to the domain default. Only
this identity attribute is borrowed from the live state - merging
stateful attributes like rgb_color would mis-color an off target.

* Reject unusable scene targets and trim the badge-state synthesis

Review-mode badges now render only when the scene holds a usable
target state. Entries with no state to show - null values, dicts
without a state key, arrays, non-scalar states - yield no badge
instead of falling back to the live state, which was
indistinguishable from a real target and, for dicts without a state,
crashed state-badge via stateColorCss on lights.

rgb_color and brightness are dropped from inactive targets: a live
entity never carries them while off, and state-badge applies them
without checking activity, so a scene turning a light off rendered an
active-looking colored icon.

Entity pictures are stripped only for DOMAINS_WITH_DYNAMIC_PICTURE,
matching createHistoricState in the logbook; stable pictures on other
domains are kept.

The brightness/rgb_color type coercion and the live device_class
borrowing are removed: they defended against hand-typed shapes that
state-badge already warns about, and the borrowing made the memoized
synthesis depend on hass state outside its memoize key.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Przemysław Szypowicz <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-06 11:37:39 +00:00
Aidan TimsonandGitHub ea5a1bca99 Add onboarding end-to-end test (#53514)
* Add onboarding end-to-end test

* Reuse onboarding test data

* Fix onboarding E2E server routing

* Test onboarding through authenticated dashboard
2026-08-06 14:04:44 +03:00
7ae5dafc95 Map card scale ruler (#53494)
* Scale ruler option for map card

* Only show ruler for the unit system configured

* Move attribution when ruler is in the same corner

* Match ruler style with the map and HA

* Update map card gallery page with the scale ruler options

* Use "UNIT_KM" to set the ruler's unit system

* Remove position option in favor of a boolean for only the bottom-left position

* Add dark mode theme to scale ruler

* Fix scale ruler style not respecting theme_mode

* Make the map card scale ruler react to config changes

Redraw the control from update() so toggling the option or changing the
unit system takes effect without a reload, and hold the themed colors in
custom properties so the forced light/dark rules no longer duplicate the
token values.

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-06 10:15:39 +00:00
Abílio CostaandGitHub 52cc65cbfc Delay camera cleanup when hidden (#53518) 2026-08-06 09:35:07 +01:00
Paul BotteinandGitHub f36330274d Stack the sidebar when only one column fits (#53515) 2026-08-06 11:26:19 +03:00
renovate[bot]andGitHub a4912d0706 Update dependency globals to v17.9.0 (#53519) 2026-08-06 09:02:18 +01:00
Krisjanis LejejsandGitHub 3900a804f0 Remove cloud demo controls (#53524) 2026-08-06 02:42:29 -04:00
cd5dfdb86f Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-05 19:00:40 +00:00
Bram KragtenandGitHub a602865117 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-05 17:31:21 +02:00
Petar PetrovandGitHub f42a5012a9 Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-05 12:52:52 +02:00
Petar PetrovandGitHub 90f5c7a349 Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-05 12:31:15 +02:00
d03ba15c09 Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-05 11:54:11 +03:00
Petar PetrovandGitHub 7143acc860 Remove orphaned for_you translation key (#53509) 2026-08-05 08:30:11 +01:00
Aidan TimsonandGitHub c9c46d4507 Use typed gallery demo card configs (#53492) 2026-08-05 08:26:51 +01:00
5916775745 Wait for Integrations panel readiness (#53481)
* Wait for Integrations panel readiness

* Consolidate panel readiness tests

* Address panel readiness review feedback

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-05 08:44:16 +03:00
pcan08andGitHub 52ee78d8a2 Add support for climate in target humidity feature (#53502)
* Add target humidity feature to climate entity

* Use target_humidity_step if any instead of fixed step

* Add target humidity for climate in suggestd tile card
2026-08-05 08:30:45 +03:00
146a089044 Type configuration event handlers (#53460)
* Type configuration event handlers

* Fix event handler element typings

Co-authored-by: timmo001 <[email protected]>

* Fix Prettier formatting in zha-options-page.ts and zwave_js-custom-param.ts

Co-authored-by: timmo001 <[email protected]>

* Correction

Co-authored-by: Petar Petrov <[email protected]>

* Correction

Co-authored-by: Petar Petrov <[email protected]>

* Fix TS cast and formatting in dialog-energy-grid-settings

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-05 08:23:14 +03:00
Paul BotteinandGitHub 8c566b43b6 Compress faster by running brotli in parallel (#53495) 2026-08-05 08:19:30 +03:00
1089c5d1c5 Fix missing Internet heading in Home Assistant URL settings (#53498)
fix(config): always show the Internet URL heading

The "Internet" heading above the external URL field is rendered
inside a `hasCloud` conditional, so only users logged in to Home
Assistant Cloud ever see it. Everyone else gets an unlabeled URL
input, while the "Local network" heading below it always renders.
That leaves the local heading as the only label on the card, and it
sits below the external field, so the external field reads as though
it belongs to the local section.

Move the heading out of the conditional so it renders for every
user, leaving `hasCloud` to gate only the "Use Home Assistant Cloud"
toggle. The external section now has the same structure as the local
one: a heading followed by its list item.

The label was shown to non-Cloud users before #22379, inline beside
the field rather than as a heading. That PR converted both labels to
headings, kept the Cloud branch and dropped the non-Cloud one.


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

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-05 08:16:35 +03:00
Petar PetrovandGitHub 2894113033 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 08:02:58 +03:00
Petar PetrovandGitHub b9bbef3bfc Add expand_legend option to energy graph cards (#53476) 2026-08-05 08:02:28 +03:00
Paul BotteinandGitHub 706382cb68 Fix back navigation loop on the cloud page (#53497) 2026-08-05 06:50:34 +02:00
karwostsandGitHub cc17921c22 Fix missing label in solar forecast dialog (#53506) 2026-08-05 06:49:42 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b5ef563b71 Update octokit monorepo (#53505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 06:43:34 +02:00
Petar PetrovandGitHub 1b6e150def Remove dead UV_THREADPOOL_SIZE assignment from gulpfile (#53490)
gulpfile.js set process.env.UV_THREADPOOL_SIZE, but gulp's CLI loads the
gulpfile through liftoff, which does async fs work first. libuv has already
sized its threadpool to the default 4 by then, so the assignment never took
effect. Measured through the gulp CLI, effective threadpool size is 3.4 both
with and without the line, and only reaches 6.9 when the variable comes from
the environment.

Hoisting it earlier cannot fix it either, since the pool is sized before any
line of gulpfile.js runs, and setting it for real measurably changes nothing.
Remove it rather than plumb the variable through the build.
2026-08-04 15:52:42 +02:00
Petar PetrovandGitHub 939b7012e2 Fix wrong month in date picker calendar header (#53453) 2026-08-04 15:37:00 +02:00
Paulus SchoutsenandGitHub 488d3d9b37 Revert "Show the automation state on mobile in the automations table" (#53493) 2026-08-04 15:25:41 +02:00
Petar PetrovandGitHub 08dedc415f Compress faster by running zopfli in a worker pool (#53488)
Compress with zopfli in a worker pool

compress-app gzips every build artifact with zopfli, but @gfx/zopfli is
synchronous WASM: it ran on the main thread, pinned a single core and blocked
the event loop for the whole step, so it dominated the production build.

Replace gulp-zopfli-green with an equivalent gulp transform that runs the same
compressor in a pool of worker_threads sized to availableParallelism(). The
compressed output is byte-identical, and @gfx/zopfli is no longer loaded on the
main thread of every gulp invocation.

On a 12-core machine compress-app drops from 6.98 min to 1.27 min, and the full
production build from 8.87 min to 3.32 min.
2026-08-04 15:11:45 +02:00
karwostsandGitHub 400bf78ce0 Color icons for "on" automations in automation-picker (#53360)
colorize active icons in automation-picker
2026-08-04 15:46:14 +03:00
9b2093125e Fix unreachable remove button on input chips with long labels (#53487)
Truncate long input chip labels

A chip with a long label, like an SSH public key in an add-on config
list option, grew past its container so the trailing remove button
ended up outside the visible area and could not be clicked. Cap the
chip at the container width so the built-in label ellipsis applies,
and show the full value as a native tooltip in the select selector.

Co-authored-by: Przemysław Szypowicz <[email protected]>
2026-08-04 15:42:35 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c0d963747a Update dependency @types/luxon to v3.7.3 (#53484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 13:14:02 +03:00
Paul BotteinandGitHub 1bcd43fb75 Add form divider between actions in interaction editor (#53483) 2026-08-04 11:55:07 +02:00
ce5925388c Add frontend review guidance (#53482)
* Add frontend review guidance

* Simplify frontend review skill routing

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-04 09:00:22 +00:00
088f72fcc4 Fix map loading fully zoomed out when fitted before layout (#53428)
Leaflet computes the zoom level that fits a set of bounds from the current
size of the map container. When ha-map's fit runs before the browser has
laid out the container (a race that hits when the Leaflet chunks are
already cached), the container measures 0x0, the computed zoom collapses
to the minimum, and the map shows the entire world. The later
invalidateSize() from the resize observer fixes the size but keeps the
zoom, so the map stays on the world view.

Defer fitting while the container has no size and run the pending fit
once the resize observer reports a usable size.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-04 06:52:29 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b93f09ffb2 Update dependency js-yaml to v5.2.3 (#53478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 09:46:52 +03:00
fb499093e5 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-04 09:31:42 +03:00
karwostsandGitHub c97716d4ad Fix automation paste with variables (#53441)
* Fix automation paste with variables

* fix script

* fix stuff

* fix append

* fix script
2026-08-04 09:17:05 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b36361ca34 Bump fast-uri from 3.1.4 to 3.1.5 (#53474)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 09:03:25 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
007a7916c0 Bump undici from 6.27.0 to 6.28.0 (#53475)
Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 09:02:51 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
637f6955a8 Update dependency @types/leaflet to v1.9.22 (#53473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 09:02:29 +03:00
b574aaa084 Type feature event handlers (#53461)
* Type feature event handlers

* Address event handler review feedback

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-04 08:28:13 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1dca9b377 Bump postcss from 8.5.19 to 8.5.25 (#53465)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:25:14 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c9963b7f92 Bump ip-address from 10.2.0 to 10.4.0 (#53470)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:24:52 +03:00
karwostsandGitHub 42954152dc Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 08:24:19 +03:00
Petar PetrovandGitHub f5a28977c2 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-03 19:55:41 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9b1f407ff4 Update Node.js to v24.19.0 (#53463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 19:54:38 +02:00
277822e51c Type application event contracts (#53459)
* Type application event contracts

* Remove redundant currentTarget casts

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-03 14:57:11 +00:00
4a2bca6d76 Type component event handlers (#53458)
* Type component event handlers

* Fix event handler types per review feedback

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-03 17:47:02 +03:00
Aidan TimsonandGitHub 8e47b5369e Apply minimum release age to all dependencies (#53464) 2026-08-03 14:26:32 +00:00
Petar PetrovandGitHub 759ab155c7 Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-03 15:06:53 +01:00
karwostsandGitHub 8de939a696 Fix unsaved-mixin race condition (#53435) 2026-08-03 17:04:42 +03:00
Petar PetrovandGitHub 5c3d74502b Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-03 16:30:06 +03:00
Aidan TimsonandGitHub 0bf67b603c Type and Lit skills (#53457)
Document frontend type and Lit guidance
2026-08-03 16:28:49 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
01915f2e5a Update dependency lint-staged to v17.3.0 (#53456)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 14:53:15 +03:00
Aidan TimsonandGitHub cee1c91ed8 Home Assistant event handler types skill (#53452)
* Document Home Assistant event handler types

* Expand event handling guidance

* Prefer bottom event declarations

* Split event guidance into dedicated skill
2026-08-03 13:51:24 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
954662f454 Update formatjs (#53451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 08:35:14 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c07fb2b7ec Update dependency intl-messageformat to v11.2.13 (#53450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 05:33:41 +00:00
Sören BeyeandGitHub 041219a40c Fix js float weirdness in hui-energy-grid-balance-card (#53442) 2026-08-03 08:26:57 +03:00
karwostsandGitHub f28a1c5370 Allow to favorite the manual card (#53445) 2026-08-03 08:23:17 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4099de96c3 Update Playwright (#53446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-02 21:36:40 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
673ca45cde Bump home-assistant/actions/helpers/verify-version from e3fb68ebda13d88a0d695082f471ba2c83d025fb to ab22029681aa532bfe7de5774a9972d67bfbd2c0 (#53440)
Bump home-assistant/actions/helpers/verify-version

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 09:45:26 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4dfc626363 Update Yarn to v4.18.0 (#53432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-02 09:45:09 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1206f0ae5d Bump the codeql-action group across 1 directory with 2 updates (#53439)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.1 to 4.37.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/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

Updates `github/codeql-action/analyze` from 4.37.1 to 4.37.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/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 09:44:33 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f71a938d02 Update dependency jsdom to v30.0.1 (#53431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 13:09:13 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e2cade842e Update dependency @rspack/core to v2.1.7 (#53430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 13:09:11 +02:00
karwostsandGitHub e6df5fd159 Reuse state-display for entities secondary info (#53260)
* Reuse state-content for entities secondary info

* migrate area

* Skip schema calculation when using predefined schema.

* add tooltip, migrate none

* timestamp for weather
2026-07-31 23:07:10 +02:00
0da3ec1151 Wait for calendar and media browser readiness (#53367)
* Wrap dashboard loading spinner in a delayed fade in component

* Expose initial view render completion

* Wait for dashboard initial readiness

* Signal initial Lovelace view readiness

* Aggregate dashboard view readiness

* Wait for generated panel readiness

* Test dashboard initial readiness

* Add fade in delay to correct dashboard loading

* Delay generated panel readiness until content

* Test generated dashboard initial readiness

* Guard component spread against undefined for unknown panels

Co-authored-by: timmo001 <[email protected]>

* Wait for calendar and media browser readiness

* Test calendar and media browser readiness

* Wait for media panel host readiness

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 22:43:56 +02:00
46fdf9c91b Wait for dashboard initial readiness (#53357)
* Wrap dashboard loading spinner in a delayed fade in component

* Expose initial view render completion

* Wait for dashboard initial readiness

* Signal initial Lovelace view readiness

* Aggregate dashboard view readiness

* Wait for generated panel readiness

* Test dashboard initial readiness

* Add fade in delay to correct dashboard loading

* Delay generated panel readiness until content

* Test generated dashboard initial readiness

* Guard component spread against undefined for unknown panels

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-31 22:00:54 +02:00
80a026d9bf Recover from a stale index.html at boot (#53378)
* Recover from a stale index.html at boot

A cached, stale index.html imports the previous build's content-hashed
entry bundles (core.<hash>.js / app.<hash>.js). After an upgrade those
files 404, app.js never runs, <home-assistant> is never defined, and the
launch screen never clears. No bundled JS can recover this, because the
bundle itself failed to load.

Add a tiny, prod-only inline guard in index.html that catches the failed
entry load (capture-phase resource error + unhandledrejection) and does a
single, loop-guarded cache-busting reload, dropping the service worker and
caches on https first. core.ts strips the cache-bust param after a
successful boot.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Share stale-build recovery patterns via a single JSON source

Move the boot guard's chunk-detection regexes into
stale-build-patterns.json and inject them into the inline guard at build
time (entry-html.js), so they stay in sync with the bundled recovery util
(util/recover-stale-build.ts, in the follow-up post-boot recovery) which
reads the same file — no more manually kept-in-sync copies.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-31 21:45:59 +02:00
dc5699fec3 Ask which occurrences to change in the recurring event dialog (#53423)
* Ask which occurrences to change in the recurring event dialog

Deleting or updating a recurring event offered "only this event" and "all
future events" as two long buttons, which a 320px dialog footer squeezed
until every word wrapped onto its own line.

Move the choice into the dialog body as a radio group and leave the footer
with Cancel plus a single confirm action. The dialog also becomes an alert,
so it stays a centered card on small viewports, and the confirm action is
only styled as destructive for the delete flows.

* Apply suggestions from code review

Co-authored-by: Aidan Timson <[email protected]>

* Update src/panels/calendar/confirm-event-dialog-box.ts

Co-authored-by: Aidan Timson <[email protected]>

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Paul Bottein <[email protected]>
2026-07-31 21:42:02 +02:00
3a1d330a2f Offer to stop conflicting managed workflows (#53418)
* Offer to stop conflicting managed workflows

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Fix syntax error: misplaced brace before finally in confirmStopConflict

Co-authored-by: timmo001 <[email protected]>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Format

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-31 16:15:44 +03:00
Petar PetrovandGitHub c8a29ae2f2 Keep progress button labelled while showing its result (#53426)
While the check or error icon covers the button, the button's content was
hidden with `visibility: hidden`, which also removes the label from the
accessibility tree. For the two seconds the result is shown, a screen
reader announced a button with no accessible name.

Fade the content out instead. It looks the same and reserves the same
space, but the label stays readable to assistive technology. The spinner
part keeps `visibility: hidden`, since a finished spinner should leave
the accessibility tree.
2026-07-31 15:50:36 +03:00
b1ccb6355d Wait for settings overview readiness (#53352)
* Wait for settings overview readiness

* Move panel readiness setup to constructor

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Cache bluetooth config entries fetch in settings navigation

* Fix child panel readiness registration race

* Avoid redundant settings navigation renders

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-31 13:22:00 +03:00
Aidan TimsonandGitHub 5f2086765a Add managed translation fetching to dev servers (#53417) 2026-07-31 13:20:50 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
02c51a2789 Update dependency @rspack/core to v2.1.6 (#53420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 11:39:06 +02:00
Paul BotteinandGitHub 706421f3c5 Fix tile name truncated too early (#53416) 2026-07-31 11:52:23 +03:00
Aidan TimsonandGitHub cbc2e34638 Fix profile theme link colour (#53415) 2026-07-31 10:47:45 +02:00
Yosi LevyandGitHub 0bc0b08978 RTL fixes (#53414) 2026-07-31 08:41:52 +01:00
48394db843 Validate dialog-form on submit (#53381)
* Validate dialog-form on submit

* remove whitespace

* put errors on the stack

* Apply suggestion from @MindFreeze

Co-authored-by: Petar Petrov <[email protected]>

* Apply suggestion from @MindFreeze

* Fix botched suggestion apply in nested submit

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 07:14:40 +00:00
83dac08059 Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <[email protected]>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 09:39:47 +03:00
Aidan TimsonandGitHub 3c6b70ded8 Prevent concurrent managed frontend workflows (#53396)
* Shared build and runner for all build,dev,test flows

* Harden managed process lifecycle

* Make build workflows deterministic

* Test build management contracts

* Queue shared generated output work

* Isolate generated inputs for dev servers

* Preserve dev server child failures

* Make generated lock test deterministic

* Keep test navigator configurable

* Block concurrent frontend workflows

* Simplify workflow lock ownership

* Focus workflow locking on managed commands

* Trim workflow lock unit tests

* Consolidate dev server lifecycle handlers
2026-07-31 09:30:49 +03:00
John G.andGitHub 1e414f31dc Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 04:27:25 +00:00
karwostsandGitHub d013051f81 Add a few missing logbook csv fields (#53412) 2026-07-31 06:19:05 +02:00
eb2efbb669 use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 06:08:38 +02:00
Bram KragtenandGitHub 7e4d745b21 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 06:08:18 +02:00
Petar PetrovandGitHub d01f7d98a5 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:26:29 +02:00
renovate[bot]andGitHub 6df6b556bd Update dependency @codemirror/view to v6.43.7 (#53409) 2026-07-30 22:34:36 +02:00
f5c6420fbc Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-30 15:44:28 +00:00
karwostsandGitHub 71ec76185f Fix schedule editor dirty tracking (#53401) 2026-07-30 17:37:18 +02:00
Petar PetrovandGitHub f08ab2f331 Show which energy power statistic is missing (#53404) 2026-07-30 16:41:28 +02:00
Petar PetrovandGitHub 77b0b9b5b8 Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 16:37:16 +02:00
4fcc1adf3a Managed background builds and shared process management (#53374)
* Add managed modern production builds

* Harden managed build process controls

* Update managed process testing guidance

* Harden managed process file operations

* Share generated output process ownership

* Clean stale build state on status

* Address managed build review feedback

Co-authored-by: timmo001 <[email protected]>

* Use single compress task

* 1

Co-authored-by: Petar Petrov <[email protected]>

* Forward SIGHUP to foreground processes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-07-30 13:25:10 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9699851c26 Update dependency jsdom to v30 (#53395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 12:15:24 +02:00
a318593c9f Add live updates to Z-Wave JS node config parameters (#53013)
* 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 <[email protected]>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <[email protected]>

* Add live updates to Z-Wave JS node config parameters

Subscribe to config parameter value updates so the page reflects
changes made outside the UI (Z-Wave JS UI, another browser tab, or
the device itself) and so queued changes to sleeping nodes resolve
to a success message when the node wakes and applies them.

- Manage the subscription across connect/disconnect and device
  navigation, deriving deviceId reactively from the route
- Show success results briefly, then clear them automatically
- Clear stale error results when the parameter changes externally
- Discard fetch responses that arrive after navigating to another node

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address review comments

- Use slice() instead of deprecated substr()
- Clear pending result timeouts when navigating to another node
- Handle subscription promise rejections so an older backend without
  the subscribe command degrades gracefully instead of logging
  unhandled rejections

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 12:14:26 +02:00
749dd180ae Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 12:13:49 +02:00
247780e4c8 Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:39:41 +02:00
Paul BotteinandGitHub 7991090313 Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:03:43 +03:00
Aidan TimsonandGitHub 2efa0bd86d Prefetch home dashboard resources concurrently (#53373)
Load Home dashboard resources concurrently
2026-07-30 10:01:13 +03:00
0fad4f0097 Remove empty items on narrow data table secondary lines (#53382)
* Skip empty cells on the narrow data table secondary line

In narrow mode the main column renders every other visible column on a
secondary line, but the dot separator was inserted based on the column
index instead of whether the cell rendered anything. A row whose extra
columns are all empty showed a secondary line consisting only of dots,
and an empty column between two filled ones produced a double dot.

Filter empty cells out before joining, and return `nothing` instead of
`html`${nothing}`` for missing timestamps so those cells are detectably
empty too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Extract / clarify data table column visibility

* Fix unbound method and add all-empty secondary line test

Co-authored-by: timmo001 <[email protected]>

* Fix prettier formatting in ha-data-table test

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 07:07:43 +02:00
2b90c9a628 Show the automation state on mobile in the automations table (#53375)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 19:48:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f3848df19d Update dependency globals to v17.8.0 (#53387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:26 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e2a91c358d Update Node.js to v24.18.1 (#53386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:22 +02:00
Paul BotteinandGitHub 8793cb58b9 Show integration logo and name in the replace device dialog (#53384) 2026-07-29 19:24:39 +02:00
Bram KragtenandGitHub 2442bf662a Strip empty strings from http config (#53377) 2026-07-29 15:33:02 +02:00
425 changed files with 15353 additions and 5203 deletions
@@ -7,6 +7,8 @@ description: Home Assistant frontend component patterns. Use when implementing o
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
Cross-load `ha-frontend-events` when component work includes event listener typing, custom event dispatch, or event-map declarations.
## Dialogs
Open dialogs through the fire-event pattern:
+105
View File
@@ -0,0 +1,105 @@
---
name: ha-frontend-events
description: Home Assistant frontend event patterns. Use when typing event handlers, using HASSDomEvent types, dispatching with fireEvent, or declaring HASSDomEvents and event maps.
---
# HA Frontend Events
Use this skill when implementing or reviewing event listeners and custom event contracts. Cross-load `ha-frontend-components` when the work also involves dialogs, forms, alerts, shortcuts, tooltips, panels, Lovelace cards, or buttons.
## Event Handling
Use the event types from `src/common/dom/fire_event.ts` instead of plain `Event`, generic `CustomEvent`, or element casts when they express the handler contract:
- Use `HASSDomCurrentTargetEvent<T>` to read the element on which the listener was registered through `ev.currentTarget`.
- Use `HASSDomTargetEvent<T>` only to read the element that originated the event through `ev.target`.
- Use `HASSDomEvent<T>` to read a custom event payload through `ev.detail`.
- Use `ValueChangedEvent<T>` from `src/types.ts` for the standard `value-changed` event.
- Prefer an event type exported by the component being listened to, such as `HaSelectSelectEvent<T, Clearable>` or `HaDropdownSelectEvent<TValue, TData>`, over reconstructing its detail type.
- Use `ActionHandlerEvent` from `src/data/lovelace/action_handler.ts` for Lovelace tap, hold, and double-tap handlers.
Import event and element types with `import type`:
```ts
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../common/dom/fire_event";
import type { HaCheckbox } from "../components/ha-checkbox";
import type { HaEntityPicker } from "../components/entity/ha-entity-picker";
import type { HaRadioGroup } from "../components/radio/ha-radio-group";
import type { ValueChangedEvent } from "../types";
```
Type the handler so the selected property can be read directly. Do not cast `ev.currentTarget` or assign it to a single-use variable:
```ts
private _scopeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>): void {
this._scope = ev.currentTarget.value;
}
private _checkedChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
this._checked = ev.target.checked;
}
private _valueChanged(ev: ValueChangedEvent<string>): void {
this._value = ev.detail.value;
}
private _itemSelected(ev: HASSDomEvent<{ id: string }>): void {
this._selectedId = ev.detail.id;
}
```
Use intersections when a handler needs more than one facet of an event. Keep the native event type when the handler reads native fields such as `key`, modifier keys, `dataTransfer`, or focus relationships:
```ts
private _entityChanged(
ev: ValueChangedEvent<string> & HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.currentTarget.value = ev.detail.value;
}
private _keyDown(
ev: KeyboardEvent & HASSDomCurrentTargetEvent<HTMLInputElement>
): void {
if (ev.key === "Enter") {
this._submit(ev.currentTarget.value);
}
}
```
Dispatch Home Assistant component events with `fireEvent()` instead of constructing `Event` or `CustomEvent` directly. Register the event name and detail type by augmenting `HASSDomEvents`; use `undefined` when an event has no detail. `fireEvent()` constrains event names and supplied detail, and events bubble and cross shadow boundaries by default:
```ts
fireEvent(this, "item-selected", { id: item.id });
fireEvent(this, "refresh-requested");
```
When an event is already registered, derive handler and listener types from its registration rather than repeating the payload shape:
```ts
private _itemSelected(
ev: HASSDomEvent<HASSDomEvents["item-selected"]>
): void {
this._selectedId = ev.detail.id;
}
```
`HASSDomEvents` types `fireEvent()` calls. Augment `HTMLElementEventMap` for typed listeners on HTML elements, or `GlobalEventHandlersEventMap` when the event is handled on global event targets.
In component files, prefer placing global event declarations after the class at the bottom of the file. Preserve the existing placement when editing established files; foundational type, helper, and mixin files commonly keep declarations near the top before their consumers.
```ts
declare global {
interface HASSDomEvents {
"item-selected": { id: string };
"refresh-requested": undefined;
}
interface HTMLElementEventMap {
"item-selected": HASSDomEvent<HASSDomEvents["item-selected"]>;
}
}
```
+39
View File
@@ -0,0 +1,39 @@
---
name: ha-frontend-lit
description: Home Assistant frontend Lit conventions. Use when working with reactive properties, internal state, DOM queries, lifecycle methods, or render-derived state.
---
# HA Frontend Lit
Use this skill when implementing or reviewing Lit component state, DOM access, lifecycle methods, or rendering behavior. Cross-load `ha-frontend-types` for Home Assistant data contracts, assertions, and lifecycle parameter types.
## Reactive Fields
This project currently uses Lit's TypeScript experimental decorators with `useDefineForClassFields: false`. Match existing declarations and do not introduce standard-decorator `accessor` syntax unless the project changes decorator mode.
- Use `@property()` for public reactive API and `@state()` for private reactive state.
- Prefer inferred types for initialized reactive fields when inference preserves the intended type; annotate when widening or an external contract requires it.
## DOM Queries
Prefer Lit's `@query()` or `@queryAll()` decorators for fixed selectors in the component's render root.
- Type the decorated field with the narrowest useful DOM or component interface.
- Keep the field optional when it may be absent at the point of access, including conditional rendering or pre-render lifecycle access.
- Use a definite assignment assertion only when every call site runs after the node is guaranteed to exist.
- The optional second argument to `@query()`, as in `@query("#target", true)`, caches the first query result. Use it only when later renders cannot replace the queried node.
- Use a direct query when the selector is dynamic or the target is outside the component's render root. Before querying a child, consider whether the required value belongs in parent state or data flow.
## Render-Derived State
- Prefer render-local values for inexpensive structures used only by that render.
- Assign a render-local value once when repeated evaluation is non-trivial or a local name improves clarity.
- Keep purely presentational derivations in `render()`. Use stored state or `willUpdate()` when the value must participate in lifecycle work, reflection, CSS, or non-render consumers.
- Use `memoizeOne` for pure, argument-derived transforms when stable input identity avoids meaningful repeated work. Keep inputs explicit and limited, and do not add caching without a credible benefit over computing the value directly.
## References
- [Reactive properties](https://lit.dev/docs/components/properties/)
- [Decorators](https://lit.dev/docs/components/decorators/)
- [Shadow DOM queries](https://lit.dev/docs/components/shadow-dom/#query)
- [Reactive update cycle](https://lit.dev/docs/components/lifecycle/)
+28 -1
View File
@@ -31,6 +31,33 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
## Recurring Review Issues
Scope and public surface:
- Keep changes independently reviewable and limited to the requested area.
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
Stateful and asynchronous UI:
- Review transitions in both directions, not only individual rendered states.
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
Readiness and invalidation:
- Treat readiness as the first displayable terminal result, including stable empty and error states.
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
- Ensure every value read by memoized output participates in its invalidation.
Repository-owned contracts:
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
User experience and accessibility:
- Forms need proper labels, helper text, and validation feedback.
@@ -70,4 +97,4 @@ Configuration and props:
- 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.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
+23 -2
View File
@@ -20,6 +20,7 @@ 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 build # Full production build
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
@@ -28,6 +29,26 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Production Builds
Production builds support foreground and managed background execution:
```bash
yarn build # Full foreground build
yarn build --background # Full managed background build
yarn build --modern # Modern frontend_latest bundle only
yarn build --modern --background # Modern managed background build
yarn build --status
yarn build --logs [--follow]
yarn build --stop
```
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
@@ -41,7 +62,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
## Playwright E2E
@@ -59,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. 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.
+44
View File
@@ -0,0 +1,44 @@
---
name: ha-frontend-types
description: Home Assistant frontend TypeScript conventions. Use when defining or reviewing backend data contracts, optional schemas, shared types, assertions, or Lit lifecycle types.
---
# HA Frontend Types
Use this skill for Home Assistant-specific TypeScript contracts and type choices.
## Home Assistant Data Contracts
Verify data contracts against the source that owns them, such as Home Assistant Core, Supervisor, a WebSocket handler, or an exported component type. Do not shape a type around assumptions made by its current frontend consumers.
- Match required, optional, nullable, and defaulted fields to the producer and runtime contract. Preserve optional configuration fields when omission is supported and has defined behavior.
- Use distinct request and response types when their wire shapes differ.
- When changing a shared contract, check affected consumers, tests, fixtures, and mocks.
## Reuse Home Assistant Contracts
- Reuse the canonical Home Assistant type when one exists. Define shared contract types in the data or API module that owns them.
- Reuse types exported by components and helpers rather than reconstructing their payloads. For event types, follow `ha-frontend-events`.
- When one domain contract is used across modules, define and export it from the module that owns that contract.
Prefer an existing owning contract. Introduce a frontend-specific type when the frontend shape or boundary genuinely differs.
## Assertions
Prefer accurate types and runtime narrowing. Use assertions or TypeScript suppressions at boundaries where the runtime invariant is understood but cannot be expressed cleanly; keep them narrow and explain non-obvious invariants.
## Lit Lifecycle Types
For Lit lifecycle methods that receive changed properties, use `PropertyValues<this>` when the method only needs public reactive properties:
```ts
protected willUpdate(changedProperties: PropertyValues<this>) {
// ...
}
```
Use unparameterized `PropertyValues` when the method inspects private or protected reactive properties, which are not keys of `this`. Do not add assertions solely to retain `PropertyValues<this>`.
## Enforced Baseline
Use `import type` for type-only imports. This is enforced by the repository ESLint configuration.
+4
View File
@@ -11,6 +11,9 @@ inputs:
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
rspack-cache:
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
default: ""
runs:
using: composite
@@ -21,3 +24,4 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
RSPACK_CACHE: ${{ inputs.rspack-cache }}
+71 -4
View File
@@ -97,10 +97,12 @@ jobs:
run: yarn run test
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
# Runs alongside lint and test rather than after them: the build only needs
# the dependency tree, and with the rspack cache it is no longer expensive
# enough to be worth serialising behind the other checks. The
# cancel-on-failure job below stops the run as soon as a check fails, so a
# broken pull request does not finish building.
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -111,12 +113,26 @@ jobs:
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
# nightly.yaml). rspack itself decides what is still valid (version +
# buildDependencies + node_modules snapshot), so the GHA key just restores
# the latest nightly cache; no fingerprint, and no save step (CI never
# writes the shared cache).
- name: Restore rspack cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -132,3 +148,54 @@ jobs:
path: hass_frontend/
if-no-files-found: error
retention-days: 7
# Now that the checks run in parallel, a failing lint or test no longer stops
# the build from finishing on its own, so this watches them and cancels the
# whole run on the first failure.
#
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
# other jobs check out the pull request and run its build scripts — handing
# them that scope would give PR-controlled code (or a compromised dependency)
# write access to Actions. This job never checks out the repository, so the
# elevated token stays away from PR code. It also cannot be a job that
# `needs` the checks: that would only start once they have all finished, which
# is exactly too late to cancel anything.
cancel-on-failure:
name: Cancel run on failure
needs: prepare-dependencies
runs-on: ubuntu-latest
permissions:
actions: write
timeout-minutes: 30
steps:
- name: Cancel the run when a check fails
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
watched='^(Lint and check format|Run tests|Build frontend)$'
while :; do
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
2>/dev/null || true)
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
if [ -n "$failed" ]; then
echo "Cancelling the run, these checks failed:"
printf '%s\n' "$failed"
gh run cancel "$RUN_ID" --repo "$REPO" || true
exit 0
fi
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && $2 != "completed" { print $1 }')
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
echo "All checks finished without failure"
exit 0
fi
sleep 15
done
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"
+5 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --user 1001
defaults:
run:
@@ -137,6 +137,7 @@ jobs:
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -154,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -206,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -260,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --user 1001 --ipc=host
defaults:
run:
+75 -2
View File
@@ -38,11 +38,44 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
# The wheel only builds the app (build-app), which does not merge
# backend translations. Skipping the whole-project backend export (as
# the release does) keeps this off the build's critical path; the full
# translations artifact is produced in parallel by the job below.
SKIP_BACKEND_TRANSLATIONS: "1"
- name: Bump version
run: script/version_bump.js nightly
# Warm the shared compression cache so releases reuse it (see release.yaml).
- name: Restore compression cache
id: compress-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
compress-cache-${{ runner.os }}-
# The nightly writes the rspack persistent cache; CI reads it read-only
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
# + node_modules snapshot), so the cache rolls forward daily and a single
# dependency bump keeps most of it warm instead of dropping the lineage.
- name: Restore rspack cache
id: rspack-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build nightly Python wheels
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
RSPACK_CACHE: readwrite
run: |
pip install build
yarn install
@@ -51,8 +84,23 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
- name: Archive translations
run: tar -czvf translations.tar.gz translations
# Not gated on the restore steps: a transient restore failure (they are
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Save rspack cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -61,6 +109,31 @@ jobs:
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
# The full translations (including the slow backend/core export) are only
# needed for the uploaded artifact, not the wheel, so they are downloaded in
# parallel here instead of blocking the build above.
translations:
name: Translations
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
with:
immutable: false
- name: Download translations
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Archive translations
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+41 -8
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
- name: Setup Node and install
uses: ./.github/actions/setup
@@ -48,15 +48,44 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
# The app fetches core (backend) translations live from HA, so the
# release build does not need Lokalise's backend project.
SKIP_BACKEND_TRANSLATIONS: "1"
# Restore the content-addressed compression cache. Unchanged chunks and
# static assets are then reused instead of re-run through brotli/zopfli.
- name: Restore compression cache
id: compress-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
compress-cache-${{ runner.os }}-
- name: Build and release package
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
run: |
python3 -m pip install build
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release
# The build keeps the cache under its size budget, so saving stays bounded.
# A unique key always writes; restore-keys picks the newest on the next
# run. Not gated on the restore step: a transient restore failure (it is
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -76,19 +105,21 @@ jobs:
GITHUB_REF: ${{ github.ref }}
run: |
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
# Wait for the package to become available on PyPI
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
# Wait for the exact wheel to appear on the simple index (the surface
# the wheels build's pip uses). The JSON API can report a version as
# available before it propagates here, which fails the wheels build.
wheel="home_assistant_frontend-${version}-py3-none-any.whl"
echo "Waiting for $wheel to appear on the PyPI simple index..."
for i in $(seq 1 30); do
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
if [ "$status" = "200" ]; then
echo "Package is available on PyPI!"
if curl -sf "https://pypi.org/simple/home-assistant-frontend/" | grep -qF "$wheel"; then
echo "Package is available on the PyPI simple index!"
break
fi
if [ "$i" = "30" ]; then
echo "Timed out waiting for package to appear on PyPI"
exit 1
fi
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
echo "Not available yet, retrying in 30 seconds... ($i/30)"
sleep 30
done
echo "home-assistant-frontend==$version" > ./requirements.txt
@@ -123,6 +154,8 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
# The landing-page build does not merge backend translations.
SKIP_BACKEND_TRANSLATIONS: "1"
- name: Build landing-page
run: landing-page/script/build_landing_page
- name: Tar folder
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+2
View File
@@ -6,6 +6,8 @@ build/
dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.19.0
-944
View File
File diff suppressed because one or more lines are too long
+1000
View File
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.1.cjs
yarnPath: .yarn/releases/yarn-4.18.0.cjs
+3
View File
@@ -40,6 +40,9 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `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-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
- `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.
+1 -1
View File
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `script/build_frontend`
- Production build: `yarn build`
- Gallery: `cd gallery && script/develop_gallery`
## Frontend development
+56
View File
@@ -0,0 +1,56 @@
// Gulp transform that brotli-compresses files, several at a time.
//
// Drop-in replacement for gulp-brotli. zlib already does the work off the main
// thread, but that plugin wraps it in through2, which waits for each file
// before starting the next, so only one compression is ever in flight. The
// compressed bytes are unchanged; only how many run at once differs.
//
// The real ceiling is libuv's threadpool, which zlib runs on. It sizes itself
// from UV_THREADPOOL_SIZE before any JavaScript runs, so it can only be raised
// from the environment, never from inside the build.
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { promisify } from "node:util";
import { brotliCompress, constants } from "node:zlib";
import { withCache } from "./compress-cache.mjs";
import { ParallelTransform } from "./parallel-transform.mjs";
const EXTENSION = ".br";
const compress = promisify(brotliCompress);
/**
* @param {object} [options]
* @param {boolean} [options.skipLarger] Drop files that compression grows.
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
*/
export default ({ skipLarger = false, params } = {}) => {
// Isolate cache entries by anything that changes the output bytes: the brotli
// quality, and the node major that produced them.
const quality = params?.[constants.BROTLI_PARAM_QUALITY] ?? "default";
const namespace = `brotli-q${quality}-node${process.versions.node.split(".")[0]}`;
return new ParallelTransform(availableParallelism(), async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
const compressed = await withCache(namespace, file.contents, async () => {
const out = await compress(file.contents, { params });
// Dropped rather than passed through, as gulp-brotli did: the
// uncompressed file is already in the output directory.
return skipLarger && out.length >= file.contents.length ? undefined : out;
});
if (compressed === undefined) {
return undefined;
}
file.contents = compressed;
file.path += EXTENSION;
return file;
});
};
+311
View File
@@ -0,0 +1,311 @@
// Manage a Home Assistant frontend production build with an agent-friendly
// interface, matching build-scripts/dev-server.mjs.
//
// node build-scripts/build-manager.mjs [--modern] [mode]
//
// (no mode) Run in the foreground.
// --background Start detached, print the pid, then exit and leave it
// running.
// --status Report whether a managed build is running.
// --stop Stop a managed build.
// --logs [--follow] Print (or follow) the background build log.
//
// --modern Build only the modern frontend_latest bundle.
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
offerToStopProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = workflowLockFile;
const usage = () => {
process.stderr.write(
"Usage: node build-scripts/build-manager.mjs [--modern] " +
"[--background | --status | --stop | --logs [--follow]]\n"
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
modes: [],
follow: false,
modern: false,
unknown: [],
};
for (const arg of argv) {
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else if (arg === "--modern") {
args.modern = true;
} else if (arg === "--follow") {
args.follow = true;
} else {
args.unknown.push(arg);
}
}
return args;
};
const hints = () =>
" Stop: yarn build --stop\n" +
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const stopCommandFor = (owner) =>
owner?.kind === "build"
? "yarn build --stop"
: owner?.kind === "dev"
? `yarn ${devCommand(owner.suite)} --stop`
: undefined;
const acquireBuild = async (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
};
const result = acquireProcessRecord(lockFile, record);
if (result.acquired) {
return { token };
}
reportExisting(result.existing);
return (await offerToStopProcessRecord({
file: lockFile,
owner: result.existing,
ownerDescription: describeOutputOwner(result.existing),
stopCommand: stopCommandFor(result.existing),
}))
? acquireBuild(modern, foreground)
: { existing: result.existing };
};
const updateBuild = (token, child, processGroup) => {
const existing = readBuild();
if (existing?.token !== token) {
throw Error("Frontend build lock ownership was lost during startup.");
}
writeProcessRecord(lockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup,
starting: false,
});
};
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = devCommand(existing.suite);
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
` Stop: yarn ${command} --stop\n` +
` Status: yarn ${command} --status\n` +
` Logs: yarn ${command} --logs\n`
);
return;
}
process.stdout.write(
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
);
};
const runForeground = async (modern) => {
const lock = await acquireBuild(modern, true);
if (!lock.token) {
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = await acquireBuild(modern, false);
if (!lock.token) {
return 1;
}
let child;
try {
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseBuild(lock.token);
throw err;
}
};
const runStatus = () => {
const existing = readBuild();
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
process.stdout.write(
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
);
} else {
if (existing?.kind === "build") {
releaseBuild(existing.token);
}
process.stdout.write("Frontend build not running.\n");
}
return 0;
};
const runStop = async () => {
let existing = readBuild();
if (existing?.kind !== "build") {
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readBuild();
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readBuild();
}
if (
!existing ||
existing.kind !== "build" ||
!isProcessRecordAlive(existing)
) {
if (existing) releaseBuild(existing.token);
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (
!(await terminateProcess({
pid: existing.pid,
processGroup: existing.processGroup,
isStopped: () => !isProcessRecordAlive(existing),
}))
) {
process.stderr.write(
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
);
return 1;
}
releaseBuild(existing.token);
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
return 0;
};
const runLogs = (follow) =>
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
const main = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.unknown.length) {
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
usage();
return 1;
}
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
}
const handlers = {
foreground: () => runForeground(args.modern),
background: () => runBackground(args.modern),
status: runStatus,
stop: runStop,
logs: () => runLogs(args.follow),
};
return handlers[args.mode]();
};
runCli(main);
+10 -1
View File
@@ -277,7 +277,7 @@ module.exports.config = {
};
},
gallery({ isProdBuild, latestBuild }) {
gallery({ isProdBuild, latestBuild, isTestBuild }) {
return {
name: "gallery" + nameSuffix(latestBuild),
entry: {
@@ -287,6 +287,7 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isTestBuild,
defineOverlay: {
__DEMO__: true,
},
@@ -311,7 +312,15 @@ module.exports.config = {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
dashboard: path.resolve(
paths.e2eTestApp_dir,
"src/dashboard-entrypoint.ts"
),
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
onboarding: path.resolve(
paths.e2eTestApp_dir,
"src/onboarding-entrypoint.ts"
),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
+212
View File
@@ -0,0 +1,212 @@
// Content-addressed cache for compression output.
//
// brotli (quality 11) and zopfli are the slowest part of a production build,
// and they redo every file from scratch on every run. But production chunks are
// content-hashed in their filenames, so a chunk that didn't change produces
// byte-identical input here. Keying the compressed output by a hash of the
// input bytes lets an unchanged file skip compression entirely, and lets a
// nightly build warm the cache a release reuses the next day.
//
// Disabled unless COMPRESS_CACHE_DIR points at a directory. Local builds set
// nothing and behave exactly as before. The compressed bytes are unchanged
// either way; only whether they were recomputed differs.
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import {
mkdir,
readFile,
readdir,
rename,
rm,
stat,
utimes,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const rootDir = process.env.COMPRESS_CACHE_DIR;
export const cacheEnabled = Boolean(rootDir);
// Total cache size to keep across builds, least-recently-used first when over.
// The cache is shared between branches (a dev nightly warms it for a release,
// which builds from rc/master), so pruning must NOT drop everything the current
// build didn't touch — that would make each branch evict the other's
// branch-specific files every run. Instead the current build is pinned and the
// rest is kept up to this budget, so nothing is dropped unless the cache is
// genuinely too large. Override with COMPRESS_CACHE_MAX_BYTES.
const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
const maxBytes = () => {
// A valid, explicit budget wins — including 0, which keeps only the current
// build (evict everything else). An unset or invalid value uses the default.
const configured = Number(process.env.COMPRESS_CACHE_MAX_BYTES);
return Number.isFinite(configured) && configured >= 0
? configured
: DEFAULT_MAX_BYTES;
};
// Every cache key touched this build, hits and writes alike, so a prune can pin
// the current dist and never evict a file this build depends on.
const touched = new Set();
// Per-namespace directory, created lazily and only once.
const dirs = new Map();
let tmpCounter = 0;
const sha256 = (contents) =>
createHash("sha256").update(contents).digest("hex");
const namespaceDir = (namespace) => {
let dir = dirs.get(namespace);
if (!dir) {
const dirPath = path.join(rootDir, namespace);
dir = { path: dirPath, ready: undefined };
dirs.set(namespace, dir);
}
return dir;
};
const ensureDir = (dir) => {
dir.ready ??= mkdir(dir.path, { recursive: true });
return dir.ready;
};
// Written via a unique temp file and renamed into place so a concurrent reader
// in the same build never sees a half-written entry (rename is atomic on the
// same filesystem).
const writeAtomic = async (dir, name, contents) => {
await ensureDir(dir);
tmpCounter += 1;
const tmp = path.join(dir.path, `.${process.pid}-${tmpCounter}.tmp`);
const dest = path.join(dir.path, name);
await writeFile(tmp, contents);
try {
await rename(tmp, dest);
} catch (error) {
// Two files with identical contents can miss and write the same entry at
// once. On POSIX the rename just overwrites, but Windows rejects a rename
// onto an existing path. Either way the destination already holds the same
// bytes (content-addressed), so drop our temp and treat it as done.
if (existsSync(dest)) {
await rm(tmp, { force: true });
} else {
throw error;
}
}
};
/**
* Return the cached compression result for `contents`, or run `compute` and
* cache what it returns. `compute` resolves to a Buffer for compressed output,
* or `undefined` when the file should be dropped (brotli skipLarger); both
* outcomes are cached, so a dropped file is not recompressed on the next build.
*
* @param {string} namespace Isolates entries by algorithm, parameters and tool
* version, so a change to any of them can never return a stale result.
* @param {Buffer} contents Uncompressed input bytes, used as the cache key.
* @param {() => Promise<Buffer | undefined>} compute Runs on a cache miss.
* @returns {Promise<Buffer | undefined>}
*/
export const withCache = async (namespace, contents, compute) => {
if (!cacheEnabled) {
return compute();
}
const dir = namespaceDir(namespace);
const hash = sha256(contents);
touched.add(`${namespace}/${hash}`);
const dataPath = path.join(dir.path, hash);
const dropName = `${hash}.drop`;
try {
return await readFile(dataPath);
} catch {
// Miss (or unreadable) — fall through and compute.
}
if (existsSync(path.join(dir.path, dropName))) {
return undefined;
}
const result = await compute();
if (result === undefined) {
await writeAtomic(dir, dropName, "");
} else {
await writeAtomic(dir, hash, result);
}
return result;
};
/**
* Keep the cache under `maxBytes`, evicting least-recently-used entries first.
* Files this build touched are always kept (and their timestamp refreshed, so
* shared files stay warm across branches); the remainder — including another
* branch's entries — is kept up to the budget. No-op when the cache is disabled
* or nothing was compressed, so it never wipes a warm cache on a build that
* skipped compression.
*/
export const pruneCache = async () => {
if (!cacheEnabled || touched.size === 0 || !existsSync(rootDir)) {
return;
}
const now = new Date();
const pinned = [];
const others = [];
// Scan every namespace on disk, not just the ones this build used, so entries
// from an old tool version (a different namespace) are eligible for eviction.
const namespaces = await readdir(rootDir, { withFileTypes: true });
await Promise.all(
namespaces.map(async (ns) => {
if (!ns.isDirectory()) {
return;
}
const nsDir = path.join(rootDir, ns.name);
const entries = await readdir(nsDir);
await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(nsDir, entry);
// Stray temp files from an interrupted write are never valid entries.
if (entry.endsWith(".tmp")) {
await rm(entryPath, { force: true });
return;
}
const hash = entry.endsWith(".drop") ? entry.slice(0, -5) : entry;
const info = await stat(entryPath);
if (touched.has(`${ns.name}/${hash}`)) {
pinned.push({ entryPath, size: info.size });
} else {
others.push({ entryPath, size: info.size, mtimeMs: info.mtimeMs });
}
})
);
})
);
// Pinned files stay and are refreshed so they rank as most-recently-used for
// future prunes; they always count against the budget first.
let kept = 0;
await Promise.all(
pinned.map(async ({ entryPath, size }) => {
kept += size;
// A failed timestamp refresh only affects future LRU ordering, not
// correctness, so it is safe to ignore.
await utimes(entryPath, now, now).catch(() => undefined);
})
);
// Keep the most-recently-used others until the budget is spent; drop the rest.
others.sort((a, b) => b.mtimeMs - a.mtimeMs);
const budget = maxBytes();
const toDelete = [];
for (const entry of others) {
if (kept + entry.size <= budget) {
kept += entry.size;
} else {
toDelete.push(entry.entryPath);
}
}
await Promise.all(toDelete.map((p) => rm(p, { force: true })));
};
File diff suppressed because it is too large Load Diff
+29
View File
@@ -1,5 +1,6 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -17,6 +18,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -36,6 +38,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -47,6 +50,32 @@ gulp.task(
"rspack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
// Don't compress running tests
...(env.isTestBuild() || env.isStatsBuild()
? []
: ["compress-app", "prune-compress-cache"])
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app-modern",
gulp.parallel(
"gen-pages-app-prod-modern",
"gen-service-worker-app-prod-modern"
),
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
+7 -2
View File
@@ -2,9 +2,10 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import brotli from "../brotli.mjs";
import { pruneCache } from "../compress-cache.mjs";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
const filesGlob = "*.{js,json,css,svg,xml}";
const brotliOptions = {
@@ -57,3 +58,7 @@ gulp.task(
compressAppOtherZopfli
)
);
// Keep the compression cache under its size budget (LRU, this build pinned).
// No-op unless COMPRESS_CACHE_DIR is set.
gulp.task("prune-compress-cache", () => pruneCache());
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,6 +14,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -32,6 +34,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
+15 -1
View File
@@ -155,8 +155,22 @@ gulp.task("fetch-lokalise", async function () {
fs.mkdir(inDirBackend, { recursive: true }),
]);
// The backend project only provides entity_component translations, which are
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
// them live from core, so builds that only produce the app (release, release
// landing-page) can skip this second, whole-project export to save time.
const projects = Object.entries(lokaliseProjects).filter(
([project]) =>
!(project === "backend" && process.env.SKIP_BACKEND_TRANSLATIONS)
);
if (projects.length !== Object.keys(lokaliseProjects).length) {
console.log(
"Skipping backend translations download (SKIP_BACKEND_TRANSLATIONS)"
);
}
await Promise.all(
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
projects.map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -12,6 +13,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -31,6 +33,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+32 -3
View File
@@ -58,6 +58,12 @@ const getCommonTemplateVars = () => {
return {
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
hassUrl: process.env.HASS_URL || "",
// Single source for the stale-build recovery patterns, shared with the
// bundled src/util/recover-stale-build.ts and injected into the inline
// boot guard (_bootstrap_recovery.html.template).
staleBuildPatterns: fs.readJsonSync(
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
),
};
};
@@ -107,6 +113,9 @@ const genPagesDevTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Dev entries are unhashed, so the stale-index recovery guard has
// nothing to key off and rebuild churn could cause spurious reloads.
useCacheRecovery: false,
latestEntryJS: entries.map(
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
),
@@ -146,10 +155,15 @@ const genPagesProdTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Recover from a stale index.html that pins deleted hashed entry
// bundles (see _bootstrap_recovery.html.template).
useCacheRecovery: true,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
es5EntryJS: outputES5
? entries.map((entry) => es5Manifest[`${entry}.js`])
: [],
latestCustomPanelJS: latestManifest["custom-panel.js"],
es5CustomPanelJS: es5Manifest["custom-panel.js"],
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
}
);
minifiedHTML.push(
@@ -184,6 +198,17 @@ gulp.task(
)
);
gulp.task(
"gen-pages-app-prod-modern",
genPagesProdTask(
APP_PAGE_ENTRIES,
paths.root_dir,
paths.app_output_root,
paths.app_output_latest,
undefined
)
);
const CAST_PAGE_ENTRIES = {
"faq.html": ["launcher"],
"index.html": ["launcher"],
@@ -278,7 +303,11 @@ gulp.task(
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
const E2E_TEST_APP_PAGE_ENTRIES = {
"index.html": ["main"],
"dashboard.html": ["dashboard"],
"onboarding.html": ["onboarding"],
};
gulp.task(
"gen-pages-e2e-test-app-dev",
+3
View File
@@ -4,6 +4,7 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -164,6 +165,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -195,6 +197,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+45
View File
@@ -0,0 +1,45 @@
import merge from "lodash.merge";
const isMergeableObject = (value) =>
typeof value === "object" && value !== null && !Array.isArray(value);
// Keys that must never be written to, to avoid prototype pollution when the
// overlay comes from an untrusted source (JSON.parse can produce an own
// `__proto__` key from `{"__proto__": ...}`).
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
// Deep-merge `overlay` onto `base`, keeping only keys that already exist as own
// properties of `base`. Overlay keys with no counterpart in the base - e.g.
// translations for source strings that have since been removed from or renamed
// in en.json but still linger in Lokalise - are dropped so we don't ship stale
// keys. `base` is mutated and returned.
export const restrictedMerge = (base, overlay) => {
for (const key of Object.keys(overlay)) {
// Own-property check (not `in`) so inherited keys like `__proto__` or
// `toString` from the overlay are ignored rather than merged.
if (FORBIDDEN_KEYS.has(key) || !Object.hasOwn(base, key)) {
continue;
}
const baseValue = base[key];
const overlayValue = overlay[key];
if (isMergeableObject(baseValue) && isMergeableObject(overlayValue)) {
restrictedMerge(baseValue, overlayValue);
} else if (
!isMergeableObject(baseValue) &&
!isMergeableObject(overlayValue)
) {
base[key] = overlayValue;
}
// Mismatched shapes keep the base (English) value as a safe fallback.
}
return base;
};
// Merge translation `objects` onto `startObj`. When `prune` is set, the result
// is restricted to the key shape of `startObj` (the English master), so keys
// that no longer exist in en.json are not shipped. Otherwise keys are merged
// additively (used when building the English master itself, which starts empty).
export const mergeTranslations = (startObj, objects, prune = false) =>
prune
? objects.reduce(restrictedMerge, startObj)
: merge(startObj, ...objects);
+27 -7
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = (done) => (err, stats) => {
const doneHandler = () => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
doneHandler(resolve)
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
);
});
@@ -160,6 +168,17 @@ gulp.task("rspack-prod-app", () =>
)
);
gulp.task("rspack-prod-app-modern", () =>
prodBuild(
createAppConfig({
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
latestBuild: true,
})
)
);
gulp.task("rspack-dev-server-demo", () =>
runDevServer({
compiler: rspack(
@@ -233,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
createGalleryConfig({
isProdBuild: true,
latestBuild: true,
isTestBuild: env.isTestBuild(),
})
)
);
+10 -3
View File
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
);
});
gulp.task("gen-service-worker-app-prod", () =>
const genServiceWorker = (builds) =>
Promise.all(
Object.entries(SW_MAP).map(async ([outPath, build]) => {
builds.map(async ([outPath, build]) => {
const manifest = JSON.parse(
await readFile(join(outPath, "manifest.json"), "utf-8")
);
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
await symlink(basename(swDest), swOld);
}
})
)
);
gulp.task("gen-service-worker-app-prod", () =>
genServiceWorker(Object.entries(SW_MAP))
);
gulp.task("gen-service-worker-app-prod-modern", () =>
genServiceWorker([[paths.app_output_latest, "modern"]])
);
+9 -6
View File
@@ -1,10 +1,7 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
import rename from "gulp-rename";
import merge from "lodash.merge";
import { createHash } from "node:crypto";
import { mkdir, readFile } from "node:fs/promises";
import { basename, join } from "node:path";
@@ -12,6 +9,7 @@ import { PassThrough, Transform } from "node:stream";
import { finished } from "node:stream/promises";
import env from "../env.cjs";
import paths from "../paths.cjs";
import { mergeTranslations } from "./merge-translations.js";
import "./fetch-nightly-translations.js";
const inFrontendDir = "translations/frontend";
@@ -58,11 +56,12 @@ class CustomJSON extends Transform {
class MergeJSON extends Transform {
_objects = [];
constructor(stem, startObj = {}, reviver = null) {
constructor(stem, startObj = {}, reviver = null, prune = false) {
super({ objectMode: true, allowHalfOpen: false });
this._stem = stem;
this._startObj = structuredClone(startObj);
this._reviver = reviver;
this._prune = prune;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -74,7 +73,11 @@ class MergeJSON extends Transform {
// eslint-disable-next-line @typescript-eslint/naming-convention
async _flush(callback) {
const mergedObj = merge(this._startObj, ...this._objects);
const mergedObj = mergeTranslations(
this._startObj,
this._objects,
this._prune
);
this._outFile.contents = Buffer.from(JSON.stringify(mergedObj));
this._outFile.stem = this._stem;
callback(null, this._outFile);
@@ -259,7 +262,7 @@ const createTranslations = async () => {
}
const mergeStream = gulp
.src(mergeFiles, { allowEmpty: true })
.pipe(new MergeJSON(locale, enMaster, emptyReviver));
.pipe(new MergeJSON(locale, enMaster, emptyReviver, true));
mergesFinished.push(finished(mergeStream));
mergeStream.pipe(hashStream, { end: false });
}
+552
View File
@@ -0,0 +1,552 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
const AGENT_PROVIDERS = [
{
id: "opencode",
env: [
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
"OPENCODE_MODES",
],
processes: ["opencode"],
},
{
id: "claude-code",
env: ["CLAUDECODE"],
processes: ["claude"],
},
{
id: "cursor",
env: ["CURSOR_TRACE_ID"],
processes: [],
},
{
id: "github-copilot",
matchesEnv: (env) =>
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
processes: [],
},
{
id: "generic",
env: ["AGENT", "AI_AGENT"],
processes: [],
},
];
const MAX_ANCESTRY_HOPS = 24;
const readProcessParent = (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
const commandStart = stat.indexOf("(");
if (commandStart === -1 || commandEnd === -1) {
return undefined;
}
const parentPid = Number.parseInt(
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
10
);
return Number.isFinite(parentPid)
? {
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
parentPid,
}
: undefined;
} catch {
return undefined;
}
};
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
if (env.HA_CODING_AGENT === "0") {
return undefined;
}
const envMatch = AGENT_PROVIDERS.find(
(provider) =>
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
);
if (envMatch) {
return envMatch.id;
}
if (env.HA_CODING_AGENT === "1") {
return "unknown";
}
let currentPid = pid;
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
const processInfo = readProcessParent(currentPid);
if (!processInfo) {
return undefined;
}
const processMatch = AGENT_PROVIDERS.find((provider) =>
provider.processes.some((name) => processInfo.command.includes(name))
);
if (processMatch) {
return processMatch.id;
}
if (processInfo.parentPid <= 1) {
return undefined;
}
currentPid = processInfo.parentPid;
}
return undefined;
};
const canPromptForConflict = () =>
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
const formatCommand = (command) =>
!("NO_COLOR" in process.env)
? `\u001b[1;36m${command}\u001b[0m`
: `\`${command}\``;
const confirmStopConflict = async (ownerDescription, stopCommand) => {
const readline = createInterface({
input: process.stdin,
output: process.stderr,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
process.stderr.write(
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
);
const answer = await readline.question("Stop it and continue? [y/N] ", {
signal: controller.signal,
});
return ["y", "yes"].includes(answer.trim().toLowerCase());
} catch (err) {
if (err?.name !== "AbortError") {
throw err;
}
process.stderr.write("\n");
return false;
} finally {
clearTimeout(timeout);
readline.close();
}
};
export const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
if (await predicate()) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(intervalMs);
return poll();
};
return poll();
};
export const isProcessAlive = (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";
}
};
export const processStartTime = (pid) => {
if (!isProcessAlive(pid)) {
return undefined;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
} catch {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
}
};
export const isProcessRecordAlive = ({ pid, startTime }) =>
Boolean(startTime) && processStartTime(pid) === startTime;
export const readProcessRecord = (file) => {
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return data && Number.isInteger(data.pid) ? data : undefined;
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return undefined;
}
throw err;
}
};
export const writeProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(data));
fs.renameSync(temporary, file);
} finally {
removeFileIfExists(temporary);
}
};
export const removeProcessRecord = (file) => {
removeFileIfExists(file);
};
export const acquireProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = fs.openSync(file, "wx");
try {
fs.writeFileSync(fd, JSON.stringify(data));
} finally {
fs.closeSync(fd);
}
return { acquired: true };
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const existing = readProcessRecord(file);
if (existing && isProcessRecordAlive(existing)) {
return { acquired: false, existing };
}
if (!existing && isRecentFile(file)) {
return { acquired: false };
}
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
const current = readProcessRecord(file);
if (
current &&
(current.token !== existing?.token || isProcessRecordAlive(current))
) {
return false;
}
removeProcessRecord(file);
return true;
});
if (!removed.acquired || !removed.value) {
return { acquired: false, existing: readProcessRecord(file) };
}
}
}
return { acquired: false, existing: readProcessRecord(file) };
};
export const releaseProcessRecord = (file, token, onRelease) => {
if (!token) {
return;
}
withExclusiveFileLockSync(`${file}.cleanup`, () => {
const existing = readProcessRecord(file);
if (!existing || existing.token === token) {
onRelease?.();
if (existing) {
removeProcessRecord(file);
}
}
});
};
const removeFileIfExists = (file) => {
try {
fs.rmSync(file);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
};
const isRecentFile = (file) => {
try {
return Date.now() - fs.statSync(file).mtimeMs < 5000;
} catch (err) {
if (err.code === "ENOENT") {
return false;
}
throw err;
}
};
export const withExclusiveFileLockSync = (file, operation) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const owner = readProcessRecord(file);
if (owner && isProcessRecordAlive(owner)) {
return { acquired: false };
}
if (!owner && isRecentFile(file)) {
return { acquired: false };
}
removeFileIfExists(file);
continue;
}
try {
fs.writeFileSync(
fd,
JSON.stringify({
pid: process.pid,
startTime: processStartTime(process.pid),
})
);
return { acquired: true, value: operation() };
} finally {
try {
fs.closeSync(fd);
} finally {
removeFileIfExists(file);
}
}
}
return { acquired: false };
};
export const spawnForeground = ({
cmd,
args,
cwd,
env,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
stdio: "inherit",
});
let settled = false;
const forwardSigint = () =>
signalProcess(child.pid, "SIGINT", processGroup);
const forwardSigterm = () =>
signalProcess(child.pid, "SIGTERM", processGroup);
const forwardSighup = () =>
signalProcess(child.pid, "SIGHUP", processGroup);
const removeSignalHandlers = () => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
process.off("SIGHUP", forwardSighup);
};
if (processGroup) {
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
process.on("SIGHUP", forwardSighup);
}
child.once("spawn", () => {
try {
onSpawn?.(child);
} catch (err) {
settled = true;
signalProcess(child.pid, "SIGTERM", processGroup);
removeSignalHandlers();
reject(err);
}
});
child.once("error", (err) => {
if (!settled) {
settled = true;
removeSignalHandlers();
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
resolve(1);
}
});
child.once("exit", (code) => {
if (!settled) {
settled = true;
removeSignalHandlers();
resolve(code ?? 1);
}
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
let child;
try {
child = spawn(cmd, args, {
cwd,
detached: true,
env,
stdio: ["ignore", fd, fd],
});
} finally {
fs.closeSync(fd);
}
child.once("spawn", () => {
child.unref();
resolve(child);
});
child.once("error", reject);
});
const signalProcess = (pid, signal, processGroup) => {
if (processGroup) {
try {
process.kill(-pid, signal);
return;
} catch {
// Fall back to the process itself.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
};
export const terminateProcess = async ({
pid,
isStopped,
processGroup = true,
graceMs = 10_000,
}) => {
signalProcess(pid, "SIGTERM", processGroup);
if (await waitFor(isStopped, 300, graceMs)) {
return true;
}
signalProcess(pid, "SIGKILL", processGroup);
await sleep(300);
return await isStopped();
};
const sameProcessRecord = (current, expected) =>
Boolean(expected?.token) && current?.token === expected.token;
const stopProcessRecord = async (file, owner) => {
let current = readProcessRecord(file);
if (!current) {
return true;
}
if (!sameProcessRecord(current, owner)) {
return false;
}
if (!isProcessRecordAlive(current)) {
releaseProcessRecord(file, current.token);
return true;
}
const stopped = await terminateProcess({
pid: current.pid,
processGroup: current.processGroup ?? false,
isStopped: () => {
const latest = readProcessRecord(file);
return (
!sameProcessRecord(latest, owner) ||
!latest ||
!isProcessRecordAlive(latest)
);
},
});
current = readProcessRecord(file);
if (current && !sameProcessRecord(current, owner)) {
return false;
}
if (!stopped && current) {
return false;
}
releaseProcessRecord(file, owner.token);
return true;
};
export const offerToStopProcessRecord = async ({
file,
owner,
ownerDescription,
stopCommand,
}) => {
if (
!owner ||
!stopCommand ||
!canPromptForConflict() ||
!(await confirmStopConflict(ownerDescription, stopCommand))
) {
return false;
}
return stopProcessRecord(file, owner);
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
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" });
let settled = false;
tail.once("error", () => {
if (!settled) {
settled = true;
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
}
});
tail.once("exit", (code) => {
if (!settled) {
settled = true;
resolve(code ?? 1);
}
});
});
};
export const runCli = (main) => {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
};
+127
View File
@@ -0,0 +1,127 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
+64
View File
@@ -0,0 +1,64 @@
// Object-mode transform that keeps several files in flight at once.
//
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which serialises asynchronous work down to one file
// at a time. This keeps `limit` files in flight and applies backpressure beyond
// that. Files are emitted in completion order rather than input order.
import { Transform } from "node:stream";
export class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
+89 -3
View File
@@ -1,4 +1,6 @@
const { existsSync } = require("fs");
const fs = require("fs");
const { existsSync } = fs;
const path = require("path");
const rspack = require("@rspack/core");
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs");
// Build-toolchain packages whose version changes the emitted bytes but which
// are loader/compiler machinery, not modules in the build graph — so rspack's
// node_modules snapshot cannot see them. Their versions are folded into the
// persistent cache `version` so a toolchain upgrade invalidates the cache,
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
const TOOLCHAIN_PACKAGES = [
"@rspack/core",
"@babel/core",
"@babel/preset-env",
"babel-plugin-polyfill-corejs3",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
"@babel/runtime",
"babel-loader",
"core-js",
"terser",
"terser-webpack-plugin",
"browserslist",
"caniuse-lite",
];
// Our own build logic — the config, loaders and babel plugins. Their contents
// (not their paths) go into the cache version, so a change invalidates the
// cache the same way `buildDependencies` would, but without tying validity to
// absolute paths — rspack compares buildDependencies by path, which breaks a
// cache reused on another machine/checkout (a different workspace path).
const CONFIG_FILES = [
__filename,
path.join(__dirname, "bundle.cjs"),
path.join(__dirname, "minify-template-literals-loader.cjs"),
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
];
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
// stays valid when reused on a different machine or checkout path.
const cacheVersion = () => {
const parts = [
...TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
),
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
];
return require("crypto")
.createHash("sha256")
.update(parts.join("\n"))
.digest("hex")
.slice(0, 16);
};
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -376,6 +433,33 @@ const createRspackConfig = ({
])
),
},
// Persistent filesystem cache for production builds, opt-in per environment
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
// tests) = no cache.
...(isProdBuild && process.env.RSPACK_CACHE
? {
cache: {
type: "persistent",
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Content-based version (node major + toolchain versions + our own
// build files). Everything is path-independent, so the cache stays
// valid when reused on another machine/checkout. Runtime deps are
// deliberately absent — rspack's node_modules snapshot invalidates
// their modules per-package, so a single unrelated bump keeps the
// rest warm. buildDependencies is intentionally not used: rspack
// compares it by absolute path, which breaks cross-machine reuse.
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
storage: {
type: "filesystem",
directory: path.resolve(paths.root_dir, ".rspack-cache"),
},
// CI reads the nightly-written cache but must not modify it.
readonly: process.env.RSPACK_CACHE === "readonly",
},
}
: {}),
experiments: {
outputModule: true,
},
@@ -405,8 +489,10 @@ const createDemoConfig = ({
const createCastConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
createRspackConfig(
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
+18
View File
@@ -0,0 +1,18 @@
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
// compressing on the main thread blocks the event loop; one instance per worker
// is what makes the work parallel.
import { parentPort } from "node:worker_threads";
import zopfli from "@gfx/zopfli";
parentPort.on("message", ({ contents, options }) => {
zopfli.gzip(contents, options, (error, result) => {
if (error) {
parentPort.postMessage({ error: error.message ?? String(error) });
return;
}
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
// its ArrayBuffer and can be transferred instead of cloned.
parentPort.postMessage({ result }, [result.buffer]);
});
});
+148
View File
@@ -0,0 +1,148 @@
// Gulp transform that gzips files with zopfli across a pool of worker threads.
//
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { createRequire } from "node:module";
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
import { withCache } from "./compress-cache.mjs";
import { ParallelTransform } from "./parallel-transform.mjs";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Cache namespace tied to the zopfli version, since a different version can
// produce different bytes for the same input.
const ZOPFLI_VERSION = createRequire(import.meta.url)(
"@gfx/zopfli/package.json"
).version;
const NAMESPACE = `gzip-zopfli${ZOPFLI_VERSION}`;
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
const poolSize = () => {
const configured = Number(process.env.ZOPFLI_WORKERS);
return Number.isInteger(configured) && configured > 0
? configured
: availableParallelism();
};
// One job per worker at a time, so the worker itself is the job slot.
const createPool = (size) => {
const live = new Set();
const idle = [];
const waiting = [];
const inFlight = new Map();
// A dead worker must leave the pool, or it gets handed a job that never
// completes and the build hangs instead of failing.
const retire = (worker, error) => {
if (!live.delete(worker)) {
return;
}
const index = idle.indexOf(worker);
if (index !== -1) {
idle.splice(index, 1);
}
const job = inFlight.get(worker);
inFlight.delete(worker);
job?.reject(error);
waiting.shift()?.();
};
const spawn = () => {
const worker = new Worker(WORKER_URL);
live.add(worker);
// Idle workers must not hold the process open. Each job refs its worker for
// as long as it runs, so a pending compression always keeps the event loop
// alive.
worker.unref();
worker.on("message", ({ error, result }) => {
const job = inFlight.get(worker);
if (!job) {
return;
}
inFlight.delete(worker);
worker.unref();
idle.push(worker);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
);
}
waiting.shift()?.();
});
worker.on("error", (error) => retire(worker, error));
worker.on("exit", () =>
retire(worker, new Error("zopfli worker exited unexpectedly"))
);
return worker;
};
return (contents) =>
new Promise((resolve, reject) => {
const start = () => {
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
if (!worker) {
waiting.push(start);
return;
}
inFlight.set(worker, { resolve, reject });
worker.ref();
// `contents` is cloned rather than transferred: buffers read by vinyl
// can share a pooled ArrayBuffer with unrelated buffers, and
// transferring would detach those too.
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
};
start();
});
};
// Shared by every transform this module hands out, so the worker count is a
// property of the process rather than of how many streams happen to run.
let pool;
const sharedPool = () => {
if (!pool) {
const size = poolSize();
pool = { size, compress: createPool(size) };
}
return pool;
};
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
*/
export default ({ threshold = 0 } = {}) => {
const { size, compress } = sharedPool();
return new ParallelTransform(size, async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
if (threshold && file.contents.length < threshold) {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await withCache(NAMESPACE, file.contents, () =>
compress(file.contents)
);
file.path += EXTENSION;
return file;
});
};
+2
View File
@@ -58,6 +58,8 @@ const CONFIG_PANEL_COMMANDS = [
"search/related",
"tag/list",
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
];
@customElement("ha-demo")
+4
View File
@@ -8,12 +8,14 @@ import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
import { mockSearch } from "./search";
import { mockSlugify } from "./slugify";
import { mockSystemHealth } from "./system_health";
import { mockTags } from "./tags";
import { mockZone } from "./zone";
@@ -39,4 +41,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
};
+1
View File
@@ -14,6 +14,7 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
@@ -0,0 +1,22 @@
import type {
EntityRegistrySettings,
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../src/data/entity/entity_registry_settings";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
let settings: EntityRegistrySettings = { entity_id_parts: null };
hass.mockWS<typeof fetchEntityRegistrySettings>(
"config/entity_registry/settings/get",
() => settings
);
hass.mockWS<typeof updateEntityRegistrySettings>(
"config/entity_registry/settings/update",
(msg: Partial<EntityRegistrySettings>) => {
settings = { ...settings, ...msg };
return settings;
}
);
};
+9
View File
@@ -0,0 +1,9 @@
import { slugify } from "../../../src/common/string/slugify";
import type { fetchSlug } from "../../../src/data/ws-slugify";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSlugify = (hass: MockHomeAssistant) => {
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
slug: slugify(msg.text),
}));
};
+31 -1
View File
@@ -17,6 +17,9 @@ const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
);
// Applies everywhere, including the files exempted from the history rule below.
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
export default tseslint.config(
js.configs.recommended,
eslintConfigPrettier,
@@ -111,7 +114,16 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"no-restricted-syntax": [
"error",
...restrictedSyntax,
{
selector:
"CallExpression[callee.property.name=/^(push|replace)State$/]",
message:
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
},
],
"wc/no-self-class": "off",
// import-x rules
@@ -222,6 +234,24 @@ export default tseslint.config(
],
},
},
{
// These own history entries themselves: the navigation helpers, the dialog
// stack, the boot paths that run before the app has any state to keep, and
// the tests that fabricate entries to simulate a document load.
files: [
"src/common/navigate.ts",
"src/dialogs/make-dialog-manager.ts",
"src/state/url-sync-mixin.ts",
"src/panels/config/automation/add-automation-element-dialog.ts",
"src/entrypoints/core.ts",
"src/onboarding/**/*.ts",
"cast/**/*.ts",
"test/**/*.ts",
],
rules: {
"no-restricted-syntax": ["error", ...restrictedSyntax],
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
+1
View File
@@ -228,6 +228,7 @@ export default [
"entity-state",
"ha-markdown",
"integration-card",
"cloud-account",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
@@ -0,0 +1,7 @@
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { getCardElementClass } from "../../../src/panels/lovelace/create-element/create-card-element";
export const validateCardConfig = async (config: LovelaceCardConfig) => {
const cardClass = await getCardElementClass(config.type);
new cardClass().setConfig(config);
};
+40 -9
View File
@@ -1,15 +1,21 @@
import { load } from "js-yaml";
import { dump } from "js-yaml";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../src/components/ha-alert";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { validateCardConfig } from "../common/validate-card-config";
export interface DemoCardConfig {
export interface DemoCardConfig<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
heading: string;
config: string;
config: T;
expectConfigError?: boolean;
}
@customElement("demo-card")
@@ -23,12 +29,29 @@ class DemoCard extends LitElement {
@state() private _size?: number;
@state() private _configError?: string;
@query("hui-card", false) private _card?: HuiCard;
private _config = memoizeOne((config: string) => {
const c = (load(config) as any)[0];
return c;
});
private _yamlConfig = memoizeOne((config: LovelaceCardConfig) =>
dump([config]).trim()
);
protected async firstUpdated() {
try {
await validateCardConfig(this.config.config);
} catch (err) {
if (this.config.expectConfigError) {
return;
}
this._configError = err instanceof Error ? err.message : String(err);
return;
}
if (this.config.expectConfigError) {
this._configError = `Expected config error for ${this.config.heading}`;
}
}
render() {
return html`
@@ -40,15 +63,20 @@ class DemoCard extends LitElement {
: ""
}
</h2>
${
this._configError
? html`<ha-alert alert-type="error">${this._configError}</ha-alert>`
: nothing
}
<div class="root">
<hui-card
.config=${this._config(this.config.config)}
.config=${this.config.config}
.hass=${this.hass}
@card-updated=${this._cardUpdated}
></hui-card>
${
this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
? html`<pre>${this._yamlConfig(this.config.config)}</pre>`
: nothing
}
</div>
@@ -81,6 +109,9 @@ class DemoCard extends LitElement {
font-size: 0.5em;
color: var(--primary-text-color);
}
ha-alert {
margin-bottom: 16px;
}
hui-card {
max-width: 400px;
width: 100vw;
@@ -37,6 +37,16 @@ title: Button
<ha-button size="s"> small </ha-button>
```
### Icons in the `xs` size
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
16px glyph next to it adds visual noise without adding information.
Use an icon only when the button needs to be recognized at a glance in a dense layout,
and only when the glyph is a common one users can identify from its silhouette alone,
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
size and should be replaced by the label alone.
### API
This component is based on the webawesome button component.
+13
View File
@@ -56,6 +56,19 @@ export class DemoHaButton extends LitElement {
`
)}
</div>
<div>
${appearances.map(
(appearance) => html`
<ha-button
.appearance=${appearance}
.variant=${variant}
size="xs"
>
${titleCase(`${variant} ${appearance}`)}
</ha-button>
`
)}
</div>
<div>
${appearances.map(
(appearance) => html`
+3
View File
@@ -87,6 +87,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -111,6 +112,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -135,6 +137,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -106,6 +106,17 @@ export class DemoHaSelectBox extends LitElement {
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<label>Disabled with a selected option</label>
<ha-select-box
.value=${"card"}
.options=${fullOptions}
.disabled=${true}
>
</ha-select-box>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
@@ -50,6 +50,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -74,6 +75,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -100,6 +100,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -124,6 +125,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -148,6 +150,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
+30 -30
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AlarmPanelCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -40,52 +42,50 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
},
},
{
heading: "With Title",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_armed
name: My Alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_armed",
name: "My Alarm",
},
},
{
heading: "Code Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_code
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_code",
},
},
{
heading: "Using only Arm_Home State",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
states: ["arm_home"],
},
},
{
heading: "Unavailable",
config: `
- type: alarm-panel
entity: alarm_control_panel.unavailable
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.unavailable",
states: ["arm_home"],
},
},
{
heading: "Invalid Entity",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm1
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm1",
},
},
];
] satisfies DemoCardConfig<AlarmPanelCardConfig>[];
@customElement("demo-lovelace-alarm-panel-card")
class DemoAlarmPanelEntity extends LitElement {
+19 -17
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AreaCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -80,33 +82,33 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Bedroom",
config: `
- type: area
area: bedroom
`,
config: {
type: "area",
area: "bedroom",
},
},
{
heading: "Living Room",
config: `
- type: area
area: living_room
`,
config: {
type: "area",
area: "living_room",
},
},
{
heading: "Office",
config: `
- type: area
area: office
`,
config: {
type: "area",
area: "office",
},
},
{
heading: "Kitchen",
config: `
- type: area
area: kitchen
`,
config: {
type: "area",
area: "kitchen",
},
},
];
] satisfies DemoCardConfig<AreaCardConfig>[];
@customElement("demo-lovelace-area-card")
class DemoArea extends LitElement {
+32 -25
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
ConditionalCardConfig,
EntitiesCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -39,35 +44,37 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Controller",
config: `
- type: entities
entities:
- light.controller_1
- light.controller_2
- type: divider
- light.floor
- light.kitchen
`,
config: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
{ type: "divider" },
"light.floor",
"light.kitchen",
],
},
},
{
heading: "Demo",
config: `
- type: conditional
conditions:
- entity: light.controller_1
state: "on"
- entity: light.controller_2
state_not: "off"
card:
type: entities
entities:
- light.controller_1
- light.controller_2
- light.floor
- light.kitchen
`,
config: {
type: "conditional",
conditions: [
{ entity: "light.controller_1", state: "on" },
{ entity: "light.controller_2", state_not: "off" },
],
card: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
"light.floor",
"light.kitchen",
],
},
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig | ConditionalCardConfig>[];
@customElement("demo-lovelace-conditional-card")
class DemoConditional extends LitElement {
+166 -135
View File
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
} from "../../../../src/panels/lovelace/cards/types";
import type { CallServiceConfig } from "../../../../src/panels/lovelace/entity-rows/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -254,169 +260,194 @@ const ENTITIES = [
},
];
type GalleryEntitiesCardConfig = Omit<EntitiesCardConfig, "entities"> & {
type: EntitiesCardConfig["type"];
entities: (
| EntitiesCardConfig["entities"][number]
| Pick<EntitiesCardEntityConfig, "entity" | "secondary_info">
| Omit<CallServiceConfig, "entity">
)[];
};
const CONFIGS = [
{
heading: "Basic",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
- event.doorbell
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
"event.doorbell",
],
},
},
{
heading: "With enabled state color",
config: `
- type: entities
state_color: true
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
`,
config: {
type: "entities",
state_color: true,
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
],
},
},
{
heading: "Helpers",
config: `
- type: entities
title: Helpers
entities:
- entity: input_boolean.toggle
- entity: input_datetime.date_and_time
- entity: input_number.number
- entity: input_select.dropdown
- entity: input_text.text
- entity: timer.timer
- entity: counter.counter
`,
config: {
type: "entities",
title: "Helpers",
entities: [
{ entity: "input_boolean.toggle" },
{ entity: "input_datetime.date_and_time" },
{ entity: "input_number.number" },
{ entity: "input_select.dropdown" },
{ entity: "input_text.text" },
{ entity: "timer.timer" },
{ entity: "counter.counter" },
],
},
},
{
heading: "With title, toggle-able",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
},
},
{
heading: "With title, toggle = false",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "With title, can't toggle",
config: `
- type: entities
entities:
- device_tracker.demo_paulus
title: Random group
`,
config: {
type: "entities",
entities: ["device_tracker.demo_paulus"],
title: "Random group",
},
},
{
heading: "Unavailable",
config: `
- type: entities
entities:
- scene.unavailable
- device_tracker.unavailable
- cover.unavailable
- lock.unavailable
- light.unavailable
- climate.unavailable
- input_number.unavailable
- input_select.unavailable
- text.unavailable
- event.unavailable
`,
config: {
type: "entities",
entities: [
"scene.unavailable",
"device_tracker.unavailable",
"cover.unavailable",
"lock.unavailable",
"light.unavailable",
"climate.unavailable",
"input_number.unavailable",
"input_select.unavailable",
"text.unavailable",
"event.unavailable",
],
},
},
{
heading: "Custom name, secondary info, custom icon",
config: `
- type: entities
entities:
- entity: scene.romantic_lights
name: ¯\\_(ツ)_/¯
- entity: device_tracker.demo_paulus
secondary_info: entity-id
- entity: cover.kitchen_window
secondary_info: last-changed
- entity: group.kitchen
icon: mdi:home-assistant
- lock.kitchen_door
- entity: light.bed_light
icon: mdi:alarm-light
name: Bed Light Custom Icon
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
{ entity: "scene.romantic_lights", name: "¯\\_(ツ)_/¯" },
{
entity: "device_tracker.demo_paulus",
secondary_info: "entity-id",
},
{
entity: "cover.kitchen_window",
secondary_info: "last-changed",
},
{ entity: "group.kitchen", icon: "mdi:home-assistant" },
"lock.kitchen_door",
{
entity: "light.bed_light",
icon: "mdi:alarm-light",
name: "Bed Light Custom Icon",
},
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "Special rows",
config: `
- type: entities
entities:
- type: perform-action
icon: mdi:power
name: Bed light
action_name: Toggle light
action: light.toggle
data:
entity_id: light.bed_light
- type: section
label: Links
- type: weblink
url: http://google.com/
icon: mdi:google
name: Google
- type: divider
- type: divider
style:
height: 30px
margin: 4px 0
background: center / contain url("/images/divider.png") no-repeat
`,
config: {
type: "entities",
entities: [
{
type: "perform-action",
icon: "mdi:power",
name: "Bed light",
action_name: "Toggle light",
action: "light.toggle",
data: { entity_id: "light.bed_light" },
},
{ type: "section", label: "Links" },
{
type: "weblink",
url: "http://google.com/",
icon: "mdi:google",
name: "Google",
},
{ type: "divider" },
{
type: "divider",
style: {
height: "30px",
margin: "4px 0",
background: 'center / contain url("/images/divider.png") no-repeat',
},
},
],
},
},
];
] satisfies DemoCardConfig<GalleryEntitiesCardConfig>[];
@customElement("demo-lovelace-entities-card")
class DemoEntities extends LitElement {
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { ButtonCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -18,60 +20,64 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: button
entity: light.bed_light
`,
config: {
type: "button",
entity: "light.bed_light",
},
},
{
heading: "With Name (defined in card)",
config: `
- type: button
name: Custom Name
entity: light.bed_light
`,
config: {
type: "button",
name: "Custom Name",
entity: "light.bed_light",
},
},
{
heading: "With Icon",
config: `
- type: button
entity: light.bed_light
icon: mdi:tools
`,
config: {
type: "button",
entity: "light.bed_light",
icon: "mdi:tools",
},
},
{
heading: "With State",
config: `
- type: button
entity: light.bed_light
show_state: true
`,
config: {
type: "button",
entity: "light.bed_light",
show_state: true,
},
},
{
heading: "Custom Tap Action (toggle)",
config: `
- type: button
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "toggle",
},
},
},
{
heading: "Running Service",
config: `
- type: button
entity: light.bed_light
service: light.toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "perform-action",
perform_action: "light.toggle",
},
},
},
{
heading: "Invalid Entity",
config: `
- type: button
entity: sensor.invalid_entity
`,
config: {
type: "button",
entity: "sensor.invalid_entity",
},
},
];
] satisfies DemoCardConfig<ButtonCardConfig>[];
@customElement("demo-lovelace-entity-button-card")
class DemoButtonEntity extends LitElement {
+160 -141
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntityFilterCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -114,184 +119,198 @@ const ENTITIES = [
},
];
const CONFIGS = [
type StateFilterEntityFilterCardConfig = Pick<
EntityFilterCardConfig,
"type" | "entities" | "card" | "show_empty"
> & {
conditions?: never;
state_filter: NonNullable<EntityFilterCardConfig["state_filter"]>;
};
const VALID_CONFIGS = [
{
heading: "Unfiltered entities",
config: `
- type: entities
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
`,
config: {
type: "entities",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
},
},
{
heading: "On and home entities",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
},
},
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["light.bed_light"] }],
},
},
{
heading: 'With "entities" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: entities
title: Custom Title
show_header_toggle: false
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "entities",
title: "Custom Title",
show_header_toggle: false,
},
},
},
{
heading: 'With "glance" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: glance
show_state: true
title: Custom Title
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "glance",
show_state: true,
title: "Custom Title",
},
},
},
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
state_filter: [{ operator: "<", attribute: "battery", value: "30" }],
},
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
config: {
type: "entities",
entities: [
"input_number.min_battery_level",
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
},
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [{ condition: "numeric_state", below: 50 }],
},
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{ condition: "numeric_state", below: "input_number.min_battery_level" },
],
},
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{
condition: "numeric_state",
above: "input_number.min_battery_level",
below: 70,
},
],
},
},
] satisfies DemoCardConfig<
| EntitiesCardConfig
| EntityFilterCardConfig
| StateFilterEntityFilterCardConfig
>[];
const INVALID_CONFIGS = [
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
config: { type: "entity-filter" },
expectConfigError: true,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
config: {
type: "entity-filter",
entities: ["sensor.gas_station_lowest_price"],
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<
| Pick<EntityFilterCardConfig, "type">
| Pick<EntityFilterCardConfig, "type" | "entities">
>[];
const CONFIGS = [...VALID_CONFIGS, ...INVALID_CONFIGS];
@customElement("demo-lovelace-entity-filter-card")
class DemoEntityFilter extends LitElement {
+115 -115
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { GaugeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -30,158 +32,156 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
},
},
{
heading: "Custom unit of measurement",
config: `
- type: gauge
entity: sensor.outside_temperature
unit_of_measurement: C
name: Outside Temperature
`,
config: {
type: "gauge",
entity: "sensor.outside_temperature",
unit: "C",
name: "Outside Temperature",
},
},
{
heading: "Rendering needle",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
needle: true
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
needle: true,
},
},
{
heading: "Rendering needle and severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
needle: true
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
needle: true,
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness Low
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness Low",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_medium
name: Brightness Medium
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_medium",
name: "Brightness Medium",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting min (0) and mx (15) values",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness
min: 0
max: 15
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness",
min: 0,
max: 15,
},
},
{
heading: "Invalid entity",
config: `
- type: gauge
entity: sensor.invalid_entity
`,
config: {
type: "gauge",
entity: "sensor.invalid_entity",
},
},
{
heading: "Non-numeric value",
config: `
- type: gauge
entity: plant.bonsai
`,
config: {
type: "gauge",
entity: "plant.bonsai",
},
},
{
heading: "Unavailable entity",
config: `
- type: gauge
entity: sensor.not_working
`,
config: {
type: "gauge",
entity: "sensor.not_working",
},
},
{
heading: "Lower minimum",
config: `
- type: gauge
entity: sensor.brightness_high
needle: true
severity:
green: 0
yellow: 0.45
red: 0.9
min: -0.05
name: " "
max: 1.9
unit: GBP/h`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
needle: true,
severity: {
green: 0,
yellow: 0.45,
red: 0.9,
},
min: -0.05,
name: " ",
max: 1.9,
unit: "GBP/h",
},
},
{
heading: "A lot of segments",
config: `
- type: gauge
needle: true
name: Percent gauge
entity: sensor.brightness_high
unit: "%"
min: 0
max: 100
segments:
- from: 0
color: "#db4437"
- from: 10
color: "#cc4d39"
- from: 20
color: "#bd563a"
- from: 30
color: "#ad603c"
- from: 40
color: "#9e693d"
- from: 50
color: "#8f723f"
- from: 60
color: "#807b41"
- from: 70
color: "#718442"
- from: 80
color: "#618e44"
- from: 90
color: "#43a047"`,
config: {
type: "gauge",
needle: true,
name: "Percent gauge",
entity: "sensor.brightness_high",
unit: "%",
min: 0,
max: 100,
segments: [
{ from: 0, color: "#db4437" },
{ from: 10, color: "#cc4d39" },
{ from: 20, color: "#bd563a" },
{ from: 30, color: "#ad603c" },
{ from: 40, color: "#9e693d" },
{ from: 50, color: "#8f723f" },
{ from: 60, color: "#807b41" },
{ from: 70, color: "#718442" },
{ from: 80, color: "#618e44" },
{ from: 90, color: "#43a047" },
],
},
},
];
] satisfies DemoCardConfig<GaugeCardConfig>[];
@customElement("demo-lovelace-gauge-card")
class DemoGaugeEntity extends LitElement {
+161 -135
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GlanceCardConfig,
GlanceConfigEntity,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -86,172 +91,193 @@ const ENTITIES = [
},
];
type LegacyNullNameGlanceCardConfig = Omit<GlanceCardConfig, "entities"> & {
type: GlanceCardConfig["type"];
entities: (
| string
| GlanceConfigEntity
| (Omit<GlanceConfigEntity, "name"> & { name: null })
)[];
};
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: glance
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state colors",
config: `
- type: glance
state_color: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
state_color: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "With title",
config: `
- type: glance
title: Custom title
columns: 4
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
title: "Custom title",
columns: 4,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom number of columns",
config: `
- type: glance
columns: 7
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 7,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No entity names",
config: `
- type: glance
columns: 4
show_name: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state labels",
config: `
- type: glance
columns: 4
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No names and no state labels",
config: `
- type: glance
columns: 4
show_name: false
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom name + custom icon",
config: `
- type: glance
columns: 4
entities:
- entity: device_tracker.demo_paulus
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
- entity: media_player.living_room
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "device_tracker.demo_paulus",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
{
entity: "media_player.living_room",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
],
},
},
{
heading: "Selectively hidden name",
config: `
- type: glance
columns: 4
entities:
- device_tracker.demo_paulus
- entity: media_player.living_room
name:
- sun.sun
- entity: cover.kitchen_window
name:
- light.kitchen_lights
- entity: lock.kitchen_door
name:
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
entities: [
"device_tracker.demo_paulus",
{ entity: "media_player.living_room", name: null },
"sun.sun",
{ entity: "cover.kitchen_window", name: null },
"light.kitchen_lights",
{ entity: "lock.kitchen_door", name: null },
"light.ceiling_lights",
],
},
},
{
heading: "Custom tap action",
config: `
- type: glance
columns: 4
entities:
- entity: lock.kitchen_door
name: Custom
tap_action:
type: toggle
- entity: light.ceiling_lights
name: Custom
tap_action:
action: call-service
service: light.turn_on
data:
entity_id: light.ceiling_lights
- entity: sun.sun
name: Regular
- entity: light.kitchen_lights
name: Regular
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "lock.kitchen_door",
name: "Custom",
tap_action: { action: "toggle" },
},
{
entity: "light.ceiling_lights",
name: "Custom",
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
data: { entity_id: "light.ceiling_lights" },
},
},
{ entity: "sun.sun", name: "Regular" },
{ entity: "light.kitchen_lights", name: "Regular" },
],
},
},
];
] satisfies DemoCardConfig<GlanceCardConfig | LegacyNullNameGlanceCardConfig>[];
@customElement("demo-lovelace-glance-card")
class DemoGlanceEntity extends LitElement {
+129 -124
View File
@@ -1,8 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
StackCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -70,159 +75,159 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Default Grid",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Non-square Grid with 2 columns",
config: `
- type: grid
columns: 2
square: false
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Default Grid with title",
config: `
- type: grid
title: Kitchen
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
title: "Kitchen",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Columns 4",
config: `
- type: grid
columns: 4
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 4,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Columns 2",
config: `
- type: grid
columns: 2
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
`,
config: {
type: "grid",
columns: 2,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
],
},
},
{
heading: "Columns 1",
config: `
- type: grid
columns: 1
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Size for single card",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Vertical Stack",
config: `
- type: vertical-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "vertical-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Horizontal Stack",
config: `
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Combination of both",
config: `
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
`,
config: {
type: "vertical-stack",
cards: [
{
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
{
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
},
],
},
},
];
] satisfies DemoCardConfig<GridCardConfig | StackCardConfig>[];
@customElement("demo-lovelace-grid-and-stack-card")
class DemoStack extends LitElement {
+22 -20
View File
@@ -1,42 +1,44 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { IframeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "Without title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
},
},
{
heading: "With title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
title: Weather radar
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
title: "Weather radar",
},
},
{
heading: "Height-Width 3:4",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 75%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "75%",
},
},
{
heading: "Height-Width 1:1",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 100%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "100%",
},
},
];
] satisfies DemoCardConfig<IframeCardConfig>[];
@customElement("demo-lovelace-iframe-card")
class DemoIframe extends LitElement {
+23 -21
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { LightCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -44,40 +46,40 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Switchable Light",
config: `
- type: light
entity: light.bed_light
`,
config: {
type: "light",
entity: "light.bed_light",
},
},
{
heading: "Dimmable Light On",
config: `
- type: light
entity: light.dim_on
`,
config: {
type: "light",
entity: "light.dim_on",
},
},
{
heading: "Dimmable Light Off",
config: `
- type: light
entity: light.dim_off
`,
config: {
type: "light",
entity: "light.dim_off",
},
},
{
heading: "Unavailable",
config: `
- type: light
entity: light.unavailable
`,
config: {
type: "light",
entity: "light.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: light
entity: light.nonexisting
`,
config: {
type: "light",
entity: "light.nonexisting",
},
},
];
] satisfies DemoCardConfig<LightCardConfig>[];
@customElement("demo-lovelace-light-card")
class DemoLightEntity extends LitElement {
+66 -70
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const ENTITIES = [
@@ -86,107 +88,101 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Without title",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- device_tracker.demo_home_boy
- zone.home
`,
config: {
type: "map",
entities: [
{ entity: "device_tracker.demo_paulus" },
"device_tracker.demo_home_boy",
"zone.home",
],
},
},
{
heading: "With title",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- zone.home
title: Where is Paulus?
`,
config: {
type: "map",
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
title: "Where is Paulus?",
},
},
{
heading: "Height-Width 1:2",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- zone.home
aspect_ratio: 50%
`,
config: {
type: "map",
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
aspect_ratio: "50%",
},
},
{
heading: "Default Zoom",
config: `
- type: map
default_zoom: 12
entities:
- entity: device_tracker.demo_paulus
- zone.home
`,
config: {
type: "map",
default_zoom: 12,
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
},
},
{
heading: "Default Zoom too High",
config: `
- type: map
default_zoom: 20
entities:
- entity: device_tracker.demo_paulus
- zone.home
`,
config: {
type: "map",
default_zoom: 20,
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
},
},
{
heading: "Single Marker",
config: `
- type: map
entities:
- device_tracker.demo_paulus
`,
config: {
type: "map",
entities: ["device_tracker.demo_paulus"],
},
},
{
heading: "Single Marker Default Zoom",
config: `
- type: map
default_zoom: 8
entities:
- device_tracker.demo_paulus
`,
config: {
type: "map",
default_zoom: 8,
entities: ["device_tracker.demo_paulus"],
},
},
{
heading: "No Entities",
config: `
- type: map
entities:
- light.bed_light
`,
config: {
type: "map",
entities: ["light.bed_light"],
},
},
{
heading: "No Entities, Default Zoom",
config: `
- type: map
default_zoom: 8
entities:
- light.bed_light
`,
config: {
type: "map",
default_zoom: 8,
entities: ["light.bed_light"],
},
},
{
heading: "Geo Location Entities",
config: `
- type: map
geo_location_sources:
- bushfire_demo
`,
config: {
type: "map",
geo_location_sources: ["bushfire_demo"],
},
},
{
heading: "Geo Location Entities with Home Zone",
config: `
- type: map
geo_location_sources:
- bushfire_demo
entities:
- zone.bushfire
`,
config: {
type: "map",
geo_location_sources: ["bushfire_demo"],
entities: ["zone.bushfire"],
},
},
];
{
heading: "Scale ruler",
config: {
type: "map",
scale_ruler: true,
entities: ["zone.home"],
},
},
] satisfies DemoCardConfig<MapCardConfig>[];
@customElement("demo-lovelace-map-card")
class DemoMap extends LitElement {
+10 -6
View File
@@ -1,17 +1,18 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MarkdownCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "markdown-it demo",
config: `
- type: markdown
content: |
# h1 Heading 8-)
config: {
type: "markdown",
content: `# h1 Heading 8-)
## h2 Heading
@@ -278,9 +279,12 @@ const CONFIGS = [
<ha-alert alert-type="success">This is a success alert — check it out!</ha-alert>
<ha-alert title="Test alert">This is an alert with a title</ha-alert>
`,
`
.replace(/^ {4}/gm, "")
.replace(/\n+$/, "\n"),
},
},
];
] satisfies DemoCardConfig<MarkdownCardConfig>[];
@customElement("demo-lovelace-markdown-card")
class DemoMarkdown extends LitElement {
@@ -1,162 +1,165 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
MediaControlCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Paused Music",
config: `
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "media-control",
entity: "media_player.music_paused",
},
},
{
heading: "Playing Music",
config: `
- type: media-control
entity: media_player.music_playing
`,
config: {
type: "media-control",
entity: "media_player.music_playing",
},
},
{
heading: "Playing Stream",
config: `
- type: media-control
entity: media_player.stream_playing
`,
config: {
type: "media-control",
entity: "media_player.stream_playing",
},
},
{
heading: "Paused Stream",
config: `
- type: media-control
entity: media_player.stream_paused
`,
config: {
type: "media-control",
entity: "media_player.stream_paused",
},
},
{
heading: 'Playing Stream (with "previous" support)',
config: `
- type: media-control
entity: media_player.stream_playing_previous
`,
config: {
type: "media-control",
entity: "media_player.stream_playing_previous",
},
},
{
heading: "Playing non-skip TV Show",
config: `
- type: media-control
entity: media_player.tv_playing
`,
config: {
type: "media-control",
entity: "media_player.tv_playing",
},
},
{
heading: "Screen Casting",
config: `
- type: media-control
entity: media_player.android_cast
`,
config: {
type: "media-control",
entity: "media_player.android_cast",
},
},
{
heading: "Digital Picture Frame",
config: `
- type: media-control
entity: media_player.image_display
`,
config: {
type: "media-control",
entity: "media_player.image_display",
},
},
{
heading: "Sonos Idle",
config: `
- type: media-control
entity: media_player.sonos_idle
`,
config: {
type: "media-control",
entity: "media_player.sonos_idle",
},
},
{
heading: "Idle waiting for Browse Media",
config: `
- type: media-control
entity: media_player.idle_browse_media
`,
config: {
type: "media-control",
entity: "media_player.idle_browse_media",
},
},
{
heading: "Player Off",
config: `
- type: media-control
entity: media_player.theater_off
`,
config: {
type: "media-control",
entity: "media_player.theater_off",
},
},
{
heading: "Player On",
config: `
- type: media-control
entity: media_player.theater_on
`,
config: {
type: "media-control",
entity: "media_player.theater_on",
},
},
{
heading: "Player Off (cannot be switched on)",
config: `
- type: media-control
entity: media_player.theater_off_static
`,
config: {
type: "media-control",
entity: "media_player.theater_off_static",
},
},
{
heading: "Player On (cannot be switched off)",
config: `
- type: media-control
entity: media_player.theater_on_static
`,
config: {
type: "media-control",
entity: "media_player.theater_on_static",
},
},
{
heading: "Player Idle",
config: `
- type: media-control
entity: media_player.idle
`,
config: {
type: "media-control",
entity: "media_player.idle",
},
},
{
heading: "Player Playing",
config: `
- type: media-control
entity: media_player.playing
`,
config: {
type: "media-control",
entity: "media_player.playing",
},
},
{
heading: "Player Unavailable",
config: `
- type: media-control
entity: media_player.unavailable
`,
config: {
type: "media-control",
entity: "media_player.unavailable",
},
},
{
heading: "Player Unknown",
config: `
- type: media-control
entity: media_player.unknown
`,
config: {
type: "media-control",
entity: "media_player.unknown",
},
},
{
heading: "Receiver On (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_on
`,
config: {
type: "media-control",
entity: "media_player.receiver_on",
},
},
{
heading: "Receiver Off (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_off
`,
config: {
type: "media-control",
entity: "media_player.receiver_off",
},
},
{
heading: "Grid Full Size",
config: `
- type: grid
columns: 1
cards:
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "media-control", entity: "media_player.music_paused" }],
},
},
];
] satisfies DemoCardConfig<MediaControlCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-media-control-card")
class DemoHuiMediaControlCard extends LitElement {
+46 -45
View File
@@ -1,59 +1,60 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { EntitiesCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Media Players",
config: `
- type: entities
entities:
- entity: media_player.music_paused
name: Paused Music
- entity: media_player.music_playing
name: Playing Music
- entity: media_player.stream_playing
name: Playing Stream
- entity: media_player.stream_paused
name: Paused Stream
- entity: media_player.stream_playing_previous
name: Playing Stream (with "previous" support)
- entity: media_player.tv_playing
name: Playing non-skip TV Show
- entity: media_player.android_cast
name: Screen casting
- entity: media_player.image_display
name: Digital Picture Frame
- entity: media_player.sonos_idle
name: Sonos Idle
- entity: media_player.idle_browse_media
name: Idle waiting for Browse Media
- entity: media_player.theater_off
name: Player Off
- entity: media_player.theater_on
name: Player On
- entity: media_player.theater_off_static
name: Player Off (cannot be switched on)
- entity: media_player.theater_on_static
name: Player On (cannot be switched off)
- entity: media_player.idle
name: Player Idle
- entity: media_player.playing
name: Player Playing
- entity: media_player.unavailable
name: Player Unavailable
- entity: media_player.unknown
name: Player Unknown
- entity: media_player.receiver_on
name: Receiver On (selectable sources)
- entity: media_player.receiver_off
name: Receiver Off (selectable sources)
`,
config: {
type: "entities",
entities: [
{ entity: "media_player.music_paused", name: "Paused Music" },
{ entity: "media_player.music_playing", name: "Playing Music" },
{ entity: "media_player.stream_playing", name: "Playing Stream" },
{ entity: "media_player.stream_paused", name: "Paused Stream" },
{
entity: "media_player.stream_playing_previous",
name: 'Playing Stream (with "previous" support)',
},
{ entity: "media_player.tv_playing", name: "Playing non-skip TV Show" },
{ entity: "media_player.android_cast", name: "Screen casting" },
{ entity: "media_player.image_display", name: "Digital Picture Frame" },
{ entity: "media_player.sonos_idle", name: "Sonos Idle" },
{
entity: "media_player.idle_browse_media",
name: "Idle waiting for Browse Media",
},
{ entity: "media_player.theater_off", name: "Player Off" },
{ entity: "media_player.theater_on", name: "Player On" },
{
entity: "media_player.theater_off_static",
name: "Player Off (cannot be switched on)",
},
{
entity: "media_player.theater_on_static",
name: "Player On (cannot be switched off)",
},
{ entity: "media_player.idle", name: "Player Idle" },
{ entity: "media_player.playing", name: "Player Playing" },
{ entity: "media_player.unavailable", name: "Player Unavailable" },
{ entity: "media_player.unknown", name: "Player Unknown" },
{
entity: "media_player.receiver_on",
name: "Receiver On (selectable sources)",
},
{
entity: "media_player.receiver_off",
name: "Receiver Off (selectable sources)",
},
],
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig>[];
@customElement("demo-lovelace-media-player-row")
export class DemoLovelaceMediaPlayerRow extends LitElement {
+15 -13
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PictureCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -19,26 +21,26 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Image URL",
config: `
- type: picture
image: /images/living_room.png
`,
config: {
type: "picture",
image: "/images/living_room.png",
},
},
{
heading: "Person entity",
config: `
- type: picture
image_entity: person.paulus
`,
config: {
type: "picture",
image_entity: "person.paulus",
},
},
{
heading: "Error: Image required",
config: `
- type: picture
entity: person.paulus
`,
config: {
type: "picture",
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<PictureCardConfig>[];
@customElement("demo-lovelace-picture-card")
class DemoPicture extends LitElement {
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureElementsCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
ImageElementConfig,
LovelaceElementConfig,
} from "../../../../src/panels/lovelace/elements/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -60,116 +66,141 @@ const ENTITIES = [
},
];
type LegacyImageElementConfig = Omit<
ImageElementConfig,
"state_filter" | "state_image"
> & {
state_filter?: Record<string, string>;
state_image?: Record<string, string>;
};
type GalleryPictureElementsCardConfig = Omit<
PictureElementsCardConfig,
"elements"
> & {
type: PictureElementsCardConfig["type"];
elements: (LovelaceElementConfig | LegacyImageElementConfig)[];
};
const CONFIGS = [
{
heading: "Card with few elements",
config: `
- type: picture-elements
image: /images/floorplan.png
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Card with header",
config: `
- type: picture-elements
image: /images/floorplan.png
title: My House
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
title: "My House",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Person entity",
config: `
- type: picture-elements
image_entity: person.paulus
elements:
- type: state-icon
entity: sensor.battery
style:
top: 8%
left: 8%
`,
config: {
type: "picture-elements",
image_entity: "person.paulus",
elements: [
{
type: "state-icon",
entity: "sensor.battery",
style: { top: "8%", left: "8%" },
},
],
},
},
];
] satisfies DemoCardConfig<GalleryPictureElementsCardConfig>[];
@customElement("demo-lovelace-picture-elements-card")
class DemoPictureElements extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureEntityCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -33,75 +35,73 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "State on",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
tap_action: { action: "toggle" },
},
},
{
heading: "State off",
config: `
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
tap_action: { action: "toggle" },
},
},
{
heading: "Entity unavailable",
config: `
- type: picture-entity
image: /images/living_room.png
entity: light.non_existing
`,
config: {
type: "picture-entity",
image: "/images/living_room.png",
entity: "light.non_existing",
},
},
{
heading: "Camera entity",
config: `
- type: picture-entity
entity: camera.demo_camera
`,
config: {
type: "picture-entity",
entity: "camera.demo_camera",
},
},
{
heading: "Person entity",
config: `
- type: picture-entity
entity: person.paulus
`,
config: {
type: "picture-entity",
entity: "person.paulus",
},
},
{
heading: "Hidden name",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
},
},
{
heading: "Hidden state",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_state: false,
},
},
{
heading: "Both hidden",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
show_state: false,
},
},
];
] satisfies DemoCardConfig<PictureEntityCardConfig>[];
@customElement("demo-lovelace-picture-entity-card")
class DemoPictureEntity extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureGlanceCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -58,110 +60,110 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "No title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "Person entity",
config: `
- type: picture-glance
image_entity: person.paulus
entities:
- sensor.battery
`,
config: {
type: "picture-glance",
image_entity: "person.paulus",
entities: ["sensor.battery"],
},
},
{
heading: "Custom icon",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- entity: switch.decorative_lights
icon: mdi:power
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
{ entity: "switch.decorative_lights", icon: "mdi:power" },
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Custom tap action",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entity: light.ceiling_lights
tap_action:
action: toggle
entities:
- entity: switch.decorative_lights
icon: mdi:power
tap_action:
action: toggle
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entity: "light.ceiling_lights",
tap_action: { action: "toggle" },
entities: [
{
entity: "switch.decorative_lights",
icon: "mdi:power",
tap_action: { action: "toggle" },
},
"binary_sensor.basement_floor_wet",
],
},
},
];
] satisfies DemoCardConfig<PictureGlanceCardConfig>[];
@customElement("demo-lovelace-picture-glance-card")
class DemoPictureGlance extends LitElement {
+16 -14
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PlantStatusCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { createPlantEntities } from "../../data/plants";
@@ -9,27 +11,27 @@ import { mockIcons } from "../../../../demo/src/stubs/icons";
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: plant-status
entity: plant.lemon_tree
`,
config: {
type: "plant-status",
entity: "plant.lemon_tree",
},
},
{
heading: "Problem (too bright) + low battery",
config: `
- type: plant-status
entity: plant.apple_tree
`,
config: {
type: "plant-status",
entity: "plant.apple_tree",
},
},
{
heading: "With picture + multiple problems",
config: `
- type: plant-status
entity: plant.sunflowers
name: Sunflowers Name Overwrite
`,
config: {
type: "plant-status",
entity: "plant.sunflowers",
name: "Sunflowers Name Overwrite",
},
},
];
] satisfies DemoCardConfig<PlantStatusCardConfig>[];
@customElement("demo-lovelace-plant-card")
export class DemoPlantEntity extends LitElement {
+108 -95
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ThermostatCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -123,120 +125,131 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Range example",
config: `
- type: thermostat
entity: climate.ecobee
`,
config: {
type: "thermostat",
entity: "climate.ecobee",
},
},
{
heading: "Single temp example",
config: `
- type: thermostat
entity: climate.nest
`,
config: {
type: "thermostat",
entity: "climate.nest",
},
},
{
heading: "Feature example",
config: `
- type: thermostat
entity: climate.overkiz_radiator
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- auto
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
- type: climate-preset-modes
style: dropdown
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
`,
config: {
type: "thermostat",
entity: "climate.overkiz_radiator",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off", "auto"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
{
type: "climate-preset-modes",
style: "dropdown",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
],
},
},
{
heading: "Preset only example",
config: `
- type: thermostat
entity: climate.overkiz_towel_dryer
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
`,
config: {
type: "thermostat",
entity: "climate.overkiz_towel_dryer",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
},
],
},
},
{
heading: "Fan only example",
config: `
- type: thermostat
entity: climate.sensibo
features:
- type: climate-hvac-modes
hvac_modes:
- fan_only
- 'off'
- type: climate-fan-modes
style: icons
fan_modes:
- low
- high
- type: climate-swing-modes
style: icons
swing_modes:
- 'both'
- 'rangefull'
- 'off'
swing_horizontal_modes:
- 'both'
- 'rangefull'
- 'off'
`,
config: {
type: "thermostat",
entity: "climate.sensibo",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["fan_only", "off"],
},
{
type: "climate-fan-modes",
style: "icons",
fan_modes: ["low", "high"],
},
{
type: "climate-swing-modes",
style: "icons",
swing_modes: ["both", "rangefull", "off"],
},
{
type: "climate-swing-horizontal-modes",
style: "icons",
swing_horizontal_modes: ["both", "rangefull", "off"],
},
],
},
},
{
heading: "Unavailable",
config: `
- type: thermostat
entity: climate.unavailable
`,
config: {
type: "thermostat",
entity: "climate.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: thermostat
entity: climate.nonexisting
`,
config: {
type: "thermostat",
entity: "climate.nonexisting",
},
},
];
] satisfies DemoCardConfig<ThermostatCardConfig>[];
@customElement("demo-lovelace-thermostat-card")
class DemoThermostatEntity extends LitElement {
+114 -123
View File
@@ -1,6 +1,7 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
@@ -11,6 +12,7 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -166,190 +168,179 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: tile
entity: switch.tv_outlet
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
},
},
{
heading: "Vertical example",
config: `
- type: tile
entity: switch.tv_outlet
vertical: true
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
vertical: true,
},
},
{
heading: "Custom color",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
},
},
{
heading: "Whole tile tap action",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
tap_action:
action: toggle
icon_tap_action:
action: none
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
tap_action: {
action: "toggle",
},
icon_tap_action: {
action: "none",
},
},
},
{
heading: "Unknown entity",
config: `
- type: tile
entity: light.unknown
`,
config: {
type: "tile",
entity: "light.unknown",
},
},
{
heading: "Unavailable entity",
config: `
- type: tile
entity: light.unavailable
`,
config: {
type: "tile",
entity: "light.unavailable",
},
},
{
heading: "Climate",
config: `
- type: tile
entity: climate.thermostat
`,
config: {
type: "tile",
entity: "climate.thermostat",
},
},
{
heading: "Person",
config: `
- type: tile
entity: person.paulus
`,
config: {
type: "tile",
entity: "person.paulus",
},
},
{
heading: "Light brightness feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "light-brightness"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color temperature feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "color-temp"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color-temp" }],
},
},
{
heading: "Lock commands feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-commands"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-commands" }],
},
},
{
heading: "Lock open door feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-open-door"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-open-door" }],
},
},
{
heading: "Media player volume slider feature",
config: `
- type: tile
entity: media_player.living_room
features:
- type: "media-player-volume-slider"
`,
config: {
type: "tile",
entity: "media_player.living_room",
features: [{ type: "media-player-volume-slider" }],
},
},
{
heading: "Vacuum commands feature",
config: `
- type: tile
entity: vacuum.first_floor_vacuum
features:
- type: "vacuum-commands"
commands:
- start_pause
- stop
- return_home
`,
config: {
type: "tile",
entity: "vacuum.first_floor_vacuum",
features: [
{
type: "vacuum-commands",
commands: ["start_pause", "stop", "return_home"],
},
],
},
},
{
heading: "Cover open close feature",
config: `
- type: tile
entity: cover.kitchen_shutter
features:
- type: "cover-open-close"
`,
config: {
type: "tile",
entity: "cover.kitchen_shutter",
features: [{ type: "cover-open-close" }],
},
},
{
heading: "Cover tilt feature",
config: `
- type: tile
entity: cover.pergola_roof
features:
- type: "cover-tilt"
`,
config: {
type: "tile",
entity: "cover.pergola_roof",
features: [{ type: "cover-tilt" }],
},
},
{
heading: "Number buttons feature",
config: `
- type: tile
entity: input_number.counter
features:
- type: numeric-input
style: buttons
`,
config: {
type: "tile",
entity: "input_number.counter",
features: [{ type: "numeric-input", style: "buttons" }],
},
},
{
heading: "Dual thermostat feature",
config: `
- type: tile
entity: climate.dual_thermostat
features:
- type: target-temperature
`,
config: {
type: "tile",
entity: "climate.dual_thermostat",
features: [{ type: "target-temperature" }],
},
},
{
heading: "Fan direction feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-direction
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-direction" }],
},
},
{
heading: "Fan speed feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-speed
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan oscillate feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-oscillate
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-oscillate" }],
},
},
];
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+12 -10
View File
@@ -4,6 +4,8 @@ import { customElement, query } from "lit/decorators";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { TodoListCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
const ENTITIES = [
@@ -27,20 +29,20 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "List example",
config: `
- type: todo-list
entity: todo.shopping_list
`,
config: {
type: "todo-list",
entity: "todo.shopping_list",
},
},
{
heading: "List with title example",
config: `
- type: todo-list
title: Shopping List
entity: todo.read_only
`,
config: {
type: "todo-list",
title: "Shopping List",
entity: "todo.read_only",
},
},
];
] satisfies DemoCardConfig<TodoListCardConfig>[];
@customElement("demo-lovelace-todo-list-card")
class DemoTodoListEntity extends LitElement {
@@ -0,0 +1,8 @@
---
title: Cloud account
---
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
mocked cloud data. The controls at the top flip the mocked subscription, remote,
backup, onboarding, and feature state so every UI state can be previewed here
without a real cloud account.
+568
View File
@@ -0,0 +1,568 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
import type { HaSwitch } from "../../../../src/components/ha-switch";
import type { BackupConfig } from "../../../../src/data/backup";
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
import type {
CloudStatusLoggedIn,
RemoteCertificateStatus,
SubscriptionInfo,
} from "../../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
import type { Webhook } from "../../../../src/data/webhook";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
import type { HomeAssistant } from "../../../../src/types";
// This demo renders the self-contained cloud account cards (overview +
// onboarding) directly, driven by mocked data, with controls to flip the
// subscription, remote, backup, onboarding, and feature state so every UI state
// can be previewed. It intentionally does NOT render the <cloud-account> panel
// wrapper, which adds a hass-subpage toolbar/back button and route links that
// have no home in the gallery. See src/panels/config/cloud/account.
// The five PaymentSubscriptionState values.
type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups run, but only to the local agent (no cloud copy).
// "none": no automatic backups at all.
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
const DEFAULT_SCENARIO: CloudDemoScenario = {
account: "active",
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
// card shows by default with streaming as the remaining step.
onboarded: false,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: false,
webhooks: true,
};
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "canceled", label: "Canceled" },
{ value: "expired", label: "Expired" },
{ value: "unknown", label: "Unknown" },
];
const REMOTE_STATUS_OPTIONS: {
value: RemoteCertificateStatus;
label: string;
}[] = [
{ value: "ready", label: "Ready" },
{ value: "generating", label: "Preparing" },
{ value: "loading", label: "Loading" },
{ value: "loaded", label: "Loaded" },
{ value: "error", label: "Error" },
];
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
{ value: "fresh", label: "Recent" },
{ value: "stale", label: "Old" },
{ value: "failed", label: "Failed" },
{ value: "local", label: "Local only" },
{ value: "none", label: "None" },
];
const TOGGLES: [keyof CloudDemoScenario, string][] = [
["onboarded", "Onboarded"],
["postponed", "Onboarding postponed"],
["remote", "Remote access"],
["alexa", "Alexa linked"],
["google", "Google linked"],
["webrtc", "Cameras (WebRTC)"],
["webhooks", "Has webhooks"],
];
const CLOUD_AGENT = "cloud.cloud";
const emptyFilter = () => ({
include_domains: [],
include_entities: [],
exclude_domains: [],
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,
},
];
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: scenario.account },
});
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
const active =
scenario.account !== "canceled" && scenario.account !== "expired";
const cloudhooks = scenario.webhooks
? 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,
},
])
)
: {};
return {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: scenario.google,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: scenario.alexa,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
remote_connected: scenario.remote,
remote_certificate: {
common_name: "demo-instance.ui.nabu.casa",
expire_date: "2099-01-01T00:00:00+00:00",
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
alternative_names: ["demo-instance.ui.nabu.casa"],
},
remote_certificate_status: scenario.remoteStatus,
http_use_ssl: false,
active_subscription: active,
onboarding_postponed: scenario.postponed,
onboarding_completed: scenario.onboarded,
prefs: {
google_enabled: scenario.google,
alexa_enabled: scenario.alexa,
remote_enabled: scenario.remote,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: scenario.webrtc,
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
onboarding_postponed_until: scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null,
},
};
};
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
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();
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
const cloudEnabled =
scenario.backup === "fresh" ||
scenario.backup === "stale" ||
scenario.backup === "failed";
let configured = true;
let lastCompleted: string | null = null;
let lastAttempted: string | null = null;
let next: string | null = null;
switch (scenario.backup) {
case "fresh":
case "local":
lastCompleted = recent;
lastAttempted = recent;
next = future;
break;
case "stale":
lastCompleted = old;
lastAttempted = old;
next = overdue;
break;
case "failed":
lastCompleted = old;
lastAttempted = recent;
next = future;
break;
case "none":
configured = false;
break;
}
return {
automatic_backups_configured: configured,
last_attempted_automatic_backup: lastAttempted,
last_completed_automatic_backup: lastCompleted,
next_automatic_backup: next,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"],
include_addons: [],
include_all_addons: true,
include_database: true,
include_folders: [],
name: null,
password: null,
},
retention: { copies: 3, days: null },
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {
"backup.local": { protected: true, retention: null },
"cloud.cloud": { protected: true, retention: null },
},
};
};
@customElement("demo-misc-cloud-account")
export class DemoMiscCloudAccount
extends LitElement
implements ProvideHassElement
{
@state() private hass!: HomeAssistant;
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
@state() private _cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription!: SubscriptionInfo;
@state() private _backupConfig!: BackupConfig;
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.updateHass({
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"cloud",
"backup",
"webhook",
],
},
});
this._registerMocks(hass);
this._applyScenario();
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._showDialog);
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
// The overview and onboarding dialog contain real <a href="/config/..">
// links to panel routes that do not exist in the gallery. Keep them inert.
this.addEventListener("click", this._neutralizeNavigation);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._showDialog);
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
this.removeEventListener("click", this._neutralizeNavigation);
}
protected render() {
if (!this.hass) {
return nothing;
}
const showOnboarding =
this._cloudStatus.active_subscription &&
!this._cloudStatus.onboarding_completed &&
!this._cloudStatus.onboarding_postponed &&
!onboardingComplete(this._cloudStatus, this._backupConfig);
return html`
<div class="options">
<div class="selects">
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
${this._select("Backups", "backup", BACKUP_OPTIONS)}
</div>
<div class="switches">
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
</div>
</div>
<div class="preview">
${
showOnboarding
? html`
<cloud-account-onboarding
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.backupConfig=${this._backupConfig}
></cloud-account-onboarding>
`
: nothing
}
<cloud-account-overview
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.subscription=${this._subscription}
.backupConfig=${this._backupConfig}
.webhooks=${demoWebhooks}
></cloud-account-overview>
</div>
`;
}
private _select(
label: string,
field: keyof CloudDemoScenario,
options: { value: string; label: string }[]
) {
return html`
<ha-select
.label=${label}
.value=${String(this._scenario[field])}
.options=${options}
data-field=${field}
@selected=${this._selectChanged}
></ha-select>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
private _selectChanged(ev: HaSelectSelectEvent) {
const field = (ev.currentTarget as HTMLElement).dataset
.field as keyof CloudDemoScenario;
this._setField(field, ev.detail.value as string);
}
private _switchChanged(ev: Event) {
const target = ev.target as HaSwitch;
this._setField(
target.dataset.field as keyof CloudDemoScenario,
target.checked
);
}
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
if (this._scenario[field] === value) {
return;
}
this._scenario = { ...this._scenario, [field]: value };
this._applyScenario();
}
private _applyScenario() {
this._cloudStatus = buildCloudStatus(this._scenario);
this._subscription = buildSubscription(this._scenario);
this._backupConfig = buildBackupConfig(this._scenario);
}
private _openOnboarding = () => {
showCloudOnboardingDialog(this, {
cloudStatus: this._cloudStatus,
backupConfig: this._backupConfig,
onChanged: () => this._refreshFromMocks(),
});
};
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
ev.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
private _refreshFromMocks = () => {
this._cloudStatus = {
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
};
this._backupConfig = { ...this._backupConfig };
const cloudBackup =
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
this._scenario = {
...this._scenario,
onboarded: this._cloudStatus.onboarding_completed,
postponed: this._cloudStatus.onboarding_postponed,
remote: this._cloudStatus.prefs.remote_enabled,
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
backup: !this._backupConfig.automatic_backups_configured
? "none"
: cloudBackup
? this._scenario.backup === "fresh" ||
this._scenario.backup === "stale" ||
this._scenario.backup === "failed"
? this._scenario.backup
: "fresh"
: "local",
};
};
private _neutralizeNavigation = (ev: MouseEvent) => {
const anchor = ev
.composedPath()
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
if (anchor?.getAttribute("href")?.startsWith("/")) {
ev.preventDefault();
}
};
private _registerMocks(hass: MockHomeAssistant) {
hass.mockWS("cloud/status", () => ({
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
}));
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
this._cloudStatus.onboarding_postponed = true;
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
});
hass.mockWS("cloud/remote/connect", () => {
this._cloudStatus.remote_connected = true;
this._cloudStatus.prefs.remote_enabled = true;
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
this._cloudStatus.remote_connected = false;
this._cloudStatus.prefs.remote_enabled = false;
return null;
});
hass.mockWS("backup/config/info", () => ({
config: { ...this._backupConfig },
}));
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
this._backupConfig.create_backup = {
...this._backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
this._backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
return null;
});
}
static styles = css`
.options {
max-width: 600px;
margin: 16px auto 0;
padding: 0 16px 16px;
border-bottom: 1px solid var(--divider-color);
display: flex;
flex-direction: column;
gap: 16px;
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.selects ha-select {
min-width: 160px;
flex: 1;
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.preview {
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: var(--ha-space-6, 24px);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-cloud-account": DemoMiscCloudAccount;
}
}
@@ -238,6 +238,7 @@ const createDeviceRegistryEntries = (
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
-3
View File
@@ -1,4 +1 @@
import { availableParallelism } from "node:os";
import "./build-scripts/gulp/index.mjs";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
+25 -26
View File
@@ -7,7 +7,7 @@
"name": "home-assistant-frontend",
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"build": "node build-scripts/build-manager.mjs",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
@@ -49,16 +49,16 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@codemirror/view": "6.43.8",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.5.2",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.3.14",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@fullcalendar/core": "6.1.21",
@@ -93,7 +93,7 @@
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
"core-js": "3.50.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.4.0",
@@ -103,19 +103,18 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"hls.js": "1.6.17",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.2",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.3",
"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.7",
"marked": "18.0.9",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -145,26 +144,27 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.0",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.21",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.2",
"@types/luxon": "3.7.4",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
@@ -174,7 +174,7 @@
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.0",
"eslint": "10.8.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -186,17 +186,16 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.9.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.1.1",
"jsdom": "30.0.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.2.0",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -211,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"typescript-eslint": "8.66.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -224,12 +223,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "[email protected]7.1",
"packageManager": "[email protected]8.0",
"volta": {
"node": "24.18.0"
"node": "24.19.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.6"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+2 -2
View File
@@ -8,10 +8,10 @@
":prConcurrentLimit10",
":semanticCommitsDisabled",
"group:monorepos",
"group:recommended",
"security:minimumReleaseAgeNpm"
"group:recommended"
],
"enabledManagers": ["npm", "nvm", "custom.regex"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["yarnDedupeHighest"],
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
+37 -3
View File
@@ -61,10 +61,44 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./script/develop &
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
# keep the script running while serving
wait
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
+4 -2
View File
@@ -1,8 +1,9 @@
import type { ReactiveElement } from "lit";
import { getHistoryState, updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
history.replaceState({ scrollPosition: value }, "");
updateHistoryState({ scrollPosition: value });
}, 300);
export function restoreScroll(selector: string) {
@@ -39,7 +40,8 @@ export function restoreScroll(selector: string) {
newDescriptor = {
get(this: ReactiveElement) {
return (
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
this[`__${String(propertyKey)}`] ||
getHistoryState()?.scrollPosition
);
},
set(this: ReactiveElement, value) {
@@ -2,10 +2,31 @@ import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { HomeAssistant } from "../../../types";
/**
* Return the effective area id of a device: a child device without an area of
* its own inherits its parent's area (mirrors core's
* async_get_effective_area_id). Nesting is single-level, so no recursion.
*/
export const getDeviceAreaId = (
device: DeviceRegistryEntry,
devices: HomeAssistant["devices"]
): string | undefined => {
if (device.area_id) {
return device.area_id;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id ?? undefined;
}
return undefined;
};
export const getDeviceArea = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
// Required so every caller resolves a child device's effective area
// consistently, see getDeviceAreaId.
devices: HomeAssistant["devices"]
): AreaRegistryEntry | undefined => {
const areaId = device.area_id;
const areaId = getDeviceAreaId(device, devices);
return areaId ? areas[areaId] : undefined;
};
@@ -8,6 +8,7 @@ import type {
} from "../../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
@@ -46,7 +47,11 @@ export const getEntityAreaId = (
if (!entry) return undefined;
const deviceId = entry.device_id;
const device = deviceId ? devices[deviceId] : undefined;
return entry.area_id || device?.area_id || undefined;
return (
entry.area_id ||
(device ? getDeviceAreaId(device, devices) : undefined) ||
undefined
);
};
export const getEntityEntryContext = (
@@ -60,7 +65,8 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const areaId = entry?.area_id || device?.area_id;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? floors[floorId] : undefined;
+1 -1
View File
@@ -52,7 +52,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
case "timer":
return compareState === "active";
case "camera":
return compareState === "streaming";
return ["streaming", "recording"].includes(compareState);
}
return true;
+78 -41
View File
@@ -1,6 +1,7 @@
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
import { currentPath } from "./url/current-path";
declare global {
// for fire event
@@ -11,12 +12,38 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: any;
data?: Record<string, unknown>;
}
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* State of the current history entry. Always read through this, the app writes
* to the main window and a panel running in an iframe has its own history.
*/
export const getHistoryState = (): any => mainWindow.history.state;
/**
* Merge into the current history entry's state, keeping what is already there.
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
* they must never be replaced wholesale.
*/
export const updateHistoryState = (patch: Record<string, unknown>) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, ...patch },
""
);
};
/**
* Rewrite the URL of the current history entry without navigating and without
* touching its state. For query parameter cleanup.
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
};
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
@@ -24,10 +51,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
updateHistoryState({ refreshUrl: path });
};
/**
@@ -56,6 +80,17 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
) => {
const state = typeof data === "object" ? data : undefined;
if (from === undefined) {
return state ?? null;
}
return { ...state, from };
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
@@ -63,37 +98,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
if (__DEMO__) {
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
if (__DEMO__ && !path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
const { history } = mainWindow;
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
history.replaceState(buildHistoryState(data, from), "", path);
} else {
history.pushState(
buildHistoryState(options?.data, currentPath()),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -101,8 +131,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
* `location.assign`, which leaves /auth/authorize right behind the requested
* page, and going back there would bounce the user out of the app.
*/
export const canGoBack = (): boolean =>
mainWindow.history.state?.from !== undefined;
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -110,14 +149,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
mainWindow.history.back();
return;
}
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
await navigate(fallbackPath || "/", { replace: true });
};
+10
View File
@@ -0,0 +1,10 @@
import { mainWindow } from "../dom/get_main_window";
/**
* The path of the page currently shown by the app. The demo routes with the
* hash instead of the pathname, see navigate().
*/
export const currentPath = (): string =>
__DEMO__
? mainWindow.location.hash.substring(1)
: mainWindow.location.pathname;
+13 -3
View File
@@ -11,13 +11,23 @@
export const preserveUnchangedRecord = <T>(
previous: Record<string, T> | undefined,
next: Record<string, T>,
equal: (a: T, b: T) => boolean
equal: (a: T, b: T) => boolean,
compareOrder = false
): Record<string, T> => {
if (!previous) {
return next;
}
let changed = Object.keys(previous).length !== Object.keys(next).length;
for (const key of Object.keys(next)) {
const previousKeys = Object.keys(previous);
const nextKeys = Object.keys(next);
let changed = previousKeys.length !== nextKeys.length;
if (!changed && compareOrder) {
changed = previousKeys.some((key, index) => key !== nextKeys[index]);
}
for (const key of nextKeys) {
const previousItem = previous[key];
if (previousItem !== undefined && equal(previousItem, next[key])) {
next[key] = previousItem;
+6 -1
View File
@@ -127,10 +127,15 @@ export class HaProgressButton extends LitElement {
--mdc-icon-size: 16px;
}
/* Fade the content out rather than hiding it, so the button keeps its
accessible name while the result icon covers it. */
ha-button.result::part(start),
ha-button.result::part(end),
ha-button.result::part(label),
ha-button.result::part(caret),
ha-button.result::part(caret) {
opacity: 0;
}
ha-button.result::part(spinner) {
visibility: hidden;
}
+37 -4
View File
@@ -18,6 +18,37 @@ interface MinMaxFrame {
maxY: number;
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Frame sizes that divide the clock evenly. Frames are placed on absolute time
// rather than relative to the window, so charts that follow "now" keep picking
// the same points every redraw instead of redrawing with a different shape.
const FRAME_SIZES = [
[1, 2, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500],
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * SECOND),
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * MINUTE),
[1, 2, 3, 4, 6, 8, 12].map((n) => n * HOUR),
[DAY],
].flat();
// Always rounds down, so no chart ends up with fewer frames than it asked for.
function snapFrameSize(step: number): number {
if (step >= DAY) {
return Math.floor(step / DAY) * DAY;
}
let snapped = FRAME_SIZES[0];
for (const size of FRAME_SIZES) {
if (size > step) {
break;
}
snapped = size;
}
return snapped;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -35,11 +66,13 @@ export function downSampleLineData<
}
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
if (!Number.isFinite(step) || step <= 0) {
const rawStep = Math.ceil((max - min) / Math.floor(maxDetails));
if (!Number.isFinite(rawStep) || rawStep <= 0) {
// a degenerate frame size would put every point in a single frame
return data;
}
// snapped after the guard above, which relies on the unsnapped value
const step = snapFrameSize(rawStep);
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
@@ -52,7 +85,7 @@ export function downSampleLineData<
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
@@ -90,7 +123,7 @@ export function downSampleLineData<
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
+11 -4
View File
@@ -26,7 +26,10 @@ import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { transform } from "../../common/decorators/transform";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
@@ -1216,7 +1219,9 @@ export class HaChartBase extends LitElement {
}
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(ev: PointerEvent) {
private _legendPointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
@@ -1246,7 +1251,9 @@ export class HaChartBase extends LitElement {
}
}
private _toggleDataset(ev: MouseEvent) {
private _toggleDataset(
ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
ev.stopPropagation();
if (!this.chart) {
return;
@@ -1268,7 +1275,7 @@ export class HaChartBase extends LitElement {
this._handleDatasetToggle(id);
}
private _labelClick(ev: MouseEvent) {
private _labelClick(ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>) {
ev.stopPropagation();
if (!this.chart) {
return;
+8 -7
View File
@@ -2,13 +2,10 @@ import { customElement, property, state } from "lit/decorators";
import { LitElement, html, css } from "lit";
import type { EChartsType } from "echarts/core";
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
import type {
CallbackDataParams,
ECElementEvent,
} from "echarts/types/src/util/types";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
@@ -121,11 +118,15 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (ev: CustomEvent) => {
private _handleChartSankeyRoam = (
ev: HASSDomEvent<HASSDomEvents["chart-sankeyroam"]>
) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
private _handleChartClick = (
ev: HASSDomEvent<HASSDomEvents["chart-click"]>
) => {
const detail = ev.detail;
// Only handle node clicks (not links)
if (detail.dataType !== "node") {

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