Compare commits

...

36 Commits

Author SHA1 Message Date
Paul Bottein 3fbc8ae758 Rename stored key 2026-07-27 17:31:01 +02:00
Paul Bottein e18cc7ec3a Add favorite cards to the card picker 2026-07-27 17:17:10 +02:00
Josef Zweck 7620f21033 Add search functionality to media browser (#53258)
* Add search functionality to media browser

* prettier

* add debounced search, clear properly

* abort in flight search, remove dead code

* prettier

* filter

* prettier

* Hide media type filter on root media sources page

* Only show media type filter for searchable sources

* Apply media type filter only to backend search, not local browse

* Move media type filter inside the search bar

* Add client-side filters for media browser results

* Center search spinner in media browser

* Revert "Add client-side filters for media browser results"

This reverts commit 3e1fc71fbd.

* Revert "Move media type filter inside the search bar"

This reverts commit 19dcb92ad1.

* Revert "Apply media type filter only to backend search, not local browse"

This reverts commit 9b4f86878d.

* Revert "Only show media type filter for searchable sources"

This reverts commit 6dca0ce24e.

* Revert "Hide media type filter on root media sources page"

This reverts commit 62bbe34b71.

* Revert "prettier"

This reverts commit d64f90865c.

* Revert "filter"

This reverts commit 56dc5450b2.

* Reapply "filter"

This reverts commit a18edb79e4.

* Reapply "prettier"

This reverts commit 224b8823ea.

* Reapply "Hide media type filter on root media sources page"

This reverts commit 9e447e2d68.

* Reapply "Only show media type filter for searchable sources"

This reverts commit f29bd8e434.

* Reapply "Apply media type filter only to backend search, not local browse"

This reverts commit e5c021a814.

* Reapply "Move media type filter inside the search bar"

This reverts commit c83d7b77a6.

* Reapply "Add client-side filters for media browser results"

This reverts commit 436e32eeea.

* Remove local filters panel from media browser

Removes the 'Filters' collapsible panel that included:
- Local text search filter
- Local media class filter chips
- Keeps media type dropdown filter in search bar

* Fix eslint: use const instead of let for notShown variable

* Fix prettier formatting

* remove debounced search

* remove char limit

* Search button, localize

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:46 +00:00
Maarten Lakerveld 425a914d34 Sankey Energy: dont double count smallconsumer ancestor/children in other (#53324)
dont double count smallconsumer ancestor/children in other

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:32 +00:00
Copilot f413e71d54 Fix Prettier formatting in dialog-energy-battery-settings.ts (#53325)
* Initial plan

* Fix Prettier formatting in dialog-energy-battery-settings.ts

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-27 12:34:03 +03:00
Paulus Schoutsen 28045f6cd6 Hide the devices tile on home overview when the other devices view is empty (#53275)
* Show entities without a device in the home other devices view

The home overview counts area-less entities to decide whether to show
the devices tile, but the other devices view dropped any entity without
a device registry entry. Area-less entities that also have no device
(e.g. template or helper entities) thus produced a devices tile leading
to an empty view.

Group them under an "Others" section instead, matching how the home
area view handles entities without a device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Hide the devices tile instead of showing entities without a device

Reverse the fix direction: entities without a device registry entry
should not be rendered by the other devices view. Instead, the home
overview should not show the devices tile when that view has nothing to
render.

Extract the filtering and device grouping into a shared
getOtherDevicesEntities helper used by both the other devices view and
the overview, so the devices tile is only shown when the view actually
has content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Stop at the first matching entity when checking for other devices

The overview only needs to know whether the other devices view has any
content, so short-circuit on the first matching entity instead of
computing the full device grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Inline the devices check in the overview strategy

Drop the extracted helper and keep the other devices view strategy
untouched. The overview inlines the check for area-less primary
entities that belong to a device, keeping the diff to the tile
condition only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 11:11:46 +03:00
Simon Lamon 34f3283850 Handle 0 not as undefined in choose selector (#53264)
* Handle 0 not as undefined in new choose selector

* Also handle null
2026-07-27 08:15:04 +03:00
Petar Petrov d02c4924c4 Fix history graph rendering as a spike when shown by a visibility condition (#53314)
Fix chart down sampling collapsing the series when the chart is hidden
2026-07-27 06:54:44 +02:00
Paulus Schoutsen e71bb867be Show Assist pipelines in the demo voice assistants panel (#53318)
* Show Assist pipelines in the demo voice assistants panel

The demo already mocked assist_pipeline/pipeline/list, but assist_pipeline
was not in the demo's loaded components, so assist-pref never rendered and
the panel only showed the cloud Alexa/Google cards.

Add assist_pipeline to the demo component list and rename the second mocked
pipeline from Local to ChatGPT, keeping Home Assistant Cloud preferred.

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

* Keep the Local pipeline in the demo assist mocks

Restore the Local pipeline alongside Home Assistant Cloud and ChatGPT, listed
above ChatGPT. Home Assistant Cloud stays preferred.

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

* Vary pipeline languages in the demo assist mocks

Set the Local pipeline to Dutch and the ChatGPT pipeline to Spanish, so the
demo shows assistants in different languages. Home Assistant Cloud stays
English and preferred.

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

* Drop the added comment in the demo component override

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 06:53:52 +02:00
renovate[bot] 60163efd56 Update dependency fs-extra to v11.4.0 (#53317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-27 06:52:57 +02:00
Adrian Campos 351859c3d0 Fix typo in no_triggers message (#53319) 2026-07-27 06:52:41 +02:00
Petar Petrov 5f0f1f4867 Add battery capacity option for weighted state of charge (#52355)
* Add battery capacity option for weighted state of charge

* Add spacing between state of charge and capacity inputs
2026-07-27 06:02:28 +02:00
Paulus Schoutsen 18de8b13d3 Add a home page demo to the demo (#53311)
* Fix mocked entity states and demo config switching

Three small demo/mock fixes:

- Mocked entities dropped unit_of_measurement and state_class when
  writing to the state machine, so demo sensors rendered without a unit
  and the mocked history returned a single state for them
- The energy consumption sensor entity id had a stray tab, which fails
  entity id validation in cards that render it
- Switching demos saved the dashboard config before the new registries
  and entities reached the dashboard, so dashboard strategies generated
  against the previous data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

* Add a home page demo to the demo

Adds the home page as the second demo, generated by the real home
dashboard strategies instead of a copy, so the demo shows what Home
Assistant builds from a home: floors with their areas, area cards with
temperature, and the summary views for lights, climate, security, media
players, maintenance, weather and energy.

The demo config provides the floors, areas and entities the strategies
need, including the temperature and humidity sensors of each area, and a
lock and a sprinkler as favorites.

Switching demos is a tile in the favorites section, so the demo looks
like a dashboard instead of carrying a card that no real home page would
have. Its feature row has the button to move to the next demo.

Demo navigation is fixed alongside it: navigate resolves paths like the
browser does for pushState and keeps query parameters in the URL query
instead of inside the hash, so panels can read them, and the route now
follows popstate, which is the only event a traversal fires when the
query changes too.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 17:41:30 +02:00
karwosts 39e70ff315 Show empty form errors (#53313) 2026-07-26 15:19:22 +00:00
Paulus Schoutsen 14fd9114d6 Allow opening a specific demo via URL and document demo navigation for agents (#53281)
* Allow opening a specific demo via URL and document demo navigation for agents

Add a demo query parameter so a specific demo configuration can be
opened directly, e.g. /?demo=arsaboo. Add demo/AGENTS.md documenting the
demo URL scheme (demo selection and hash-based page routing), linked
from the root agent guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Merge demo slugs into the demoConfigs registry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Key demoConfigs by slug

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Add demos index of slugs and drop confProm temporary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Track selected demo by slug instead of index

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Restore original setDemoConfig flow and drop slug lowercasing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 17:15:10 +02:00
renovate[bot] 406a0b2bad Update dependency lint-staged to v17.2.0 (#53312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 16:26:33 +02:00
Paulus Schoutsen 4c86785d21 Fix mocked entity states and demo config switching (#53300)
Three small demo/mock fixes:

- Mocked entities dropped unit_of_measurement and state_class when
  writing to the state machine, so demo sensors rendered without a unit
  and the mocked history returned a single state for them
- The energy consumption sensor entity id had a stray tab, which fails
  entity id validation in cards that render it
- Switching demos saved the dashboard config before the new registries
  and entities reached the dashboard, so dashboard strategies generated
  against the previous data


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 16:25:37 +02:00
renovate[bot] 2aeb522eed Update dependency color-name to v2.1.1 (#53297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 09:18:43 +00:00
Paulus Schoutsen bef1c0def6 Recompute sections view column count when the config changes (#53301)
* Recompute sections view column count when the config changes

The sections view element is reused when navigating between two sections
views, so a view with a different max_columns kept the column count of the
previous view until a resize made the ResizeObserver fire again.

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

* Measure the host in the resize callback instead of the observer entries

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

* Clamp the column count to the config instead of caching it in the resize callback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 09:34:56 +03:00
dependabot[bot] f7e5bdb2a0 Bump the codeql-action group across 1 directory with 2 updates (#53303)
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.0 to 4.37.1
- [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/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1
- [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/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:29:35 +03:00
dependabot[bot] de59237cfb Bump home-assistant/actions/helpers/verify-version from f4ca6f671bd429efb108c0f2fa0ae8af0215986c to e3fb68ebda13d88a0d695082f471ba2c83d025fb (#53304)
Bump home-assistant/actions/helpers/verify-version

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:29:13 +03:00
dependabot[bot] f778572b89 Bump release-drafter/release-drafter from 7.5.1 to 7.6.0 (#53305)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.5.1 to 7.6.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e...eada3c96a64734dd381cfbda23511034e328ddb0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:28:55 +03:00
dependabot[bot] d2c753b589 Bump actions/setup-python from 6.3.0 to 7.0.0 (#53306)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:28:35 +03:00
dependabot[bot] 8fbb38a25d Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#53307)
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.0 to 1.14.1.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/cef221092ed1bacb1cc03d23a2d87d1d172e277b...ba38be9e461d3875417946c167d0b5f3d385a247)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:57 +03:00
dependabot[bot] 2f6c1388e8 Bump actions/checkout from 7.0.0 to 7.0.1 (#53308)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:39 +03:00
dependabot[bot] ced5ffdd18 Bump actions/labeler from 6.2.0 to 7.0.0 (#53309)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.2.0 to 7.0.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/b8dd2d9be0f68b860e7dae5dae7d772984eacd6d...bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:15 +03:00
Paulus Schoutsen 5a6a16ed27 Fix gallery type errors by letting registry mocks take the demo area and floor shapes (#53302)
Let gallery mocks use the demo area and floor shapes

The gallery passed full AreaRegistryEntry and FloorRegistryEntry objects to
mockAreaRegistry and mockFloorRegistry. Those data parameters were dropped
when the registries became demo-config-owned, which broke the gallery build.

Instead of restoring the verbose registry-entry parameters, have the mocks
accept the same DemoArea and DemoFloor shapes the demo configs use and seed
the registry through setDemoAreas/setDemoFloors. The gallery fixtures now
only spell out the fields they care about, matching how the other registry
mocks take their initial data.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 18:15:30 -04:00
Aidan Timson 3d1ed43edc Automatically activate frontend updates, support accessible toast actions, publish global dirty state (#53124)
* Support accessible toast actions

* Publish global dirty state changes

* Automatically activate frontend updates

* Add baseline tests for update dependencies

* Cover update dependency behaviour

* Render primary toast actions as filled

* Test notification toast lifecycle

* Document dirty state provider ownership

* Publish dirty state from editor updates

* Aggregate connected dirty state providers

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-25 11:18:34 +02:00
Paulus Schoutsen d682830a3e Register the hidden default dashboard panels in the demo (#53278)
Register the light, climate, security and maintenance summary panels in
the demo, hidden from the sidebar like the home panel. This makes them
routable so strategy-generated dashboards can navigate to them.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:15:05 +02:00
dependabot[bot] 863aad24fc Bump js-yaml from 5.2.1 to 5.2.2 (#53292)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.1 to 5.2.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 11:13:12 +02:00
Paulus Schoutsen 3284633ac9 Let demo configs provide areas and floors (#53280)
Make areas and floors part of the individual demo config data instead
of a single global registry:

- DemoConfig gains optional floors and areas, applied on initial load
  and when switching demos, so each demo brings (and clears) its own
  registries
- Areas can set a temperature and humidity entity
- Mock entities can be assigned to an area via EntityInput.area_id,
  which flows into the mocked entity registry used by area filtering


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:03:27 +02:00
renovate[bot] 8dd54e0aa8 Update dependency lint-staged to v17.1.1 (#53290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:06 +02:00
renovate[bot] 90a79d458f Update dependency tar to v7.5.21 (#53287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:04 +02:00
Joakim Plate 34dba7ea5a Normalize number entity row input (#53284)
hui-number-entity-row.ts bound the raw stateObj.state string to the
non-slider ha-input, so a state like "50.0" showed decimals on the
device page. state-card-number.ts (used by the more-info dialog)
already normalizes via Number(state).toString(); apply the same here
so both views render consistently.
2026-07-24 18:25:56 +02:00
renovate[bot] e76e7d1064 Unpin dependency glob resolution (#53282)
* Pin dependency glob to 10.5.0

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-24 15:39:16 +00:00
Paulus Schoutsen b6675159ae Mock the usage prediction command in the demo (#53279)
Mock usage_prediction/common_control and load the usage_prediction
component so common-controls sections in strategy dashboards render
suggested entities in the demo. Entities that don't exist in the
currently loaded demo config are filtered out by the section strategy.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 17:20:29 +02:00
88 changed files with 3930 additions and 539 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
ACTIONLINT_VERSION: 1.7.12
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run actionlint
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: dev
persist-credentials: false
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
persist-credentials: false
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare dependencies
@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
@@ -82,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
@@ -104,7 +104,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
+3 -3
View File
@@ -27,17 +27,17 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.unique_deploy_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: dev
persist-credentials: false
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+9 -9
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -45,7 +45,7 @@ jobs:
shell: bash
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -170,7 +170,7 @@ jobs:
- 2
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -224,7 +224,7 @@ jobs:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -278,7 +278,7 @@ jobs:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -320,7 +320,7 @@ jobs:
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
@@ -24,7 +24,7 @@ jobs:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -26,17 +26,17 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
- name: Setup Node and install
uses: ./.github/actions/setup
@@ -56,7 +56,7 @@ jobs:
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
skip-existing: true
@@ -111,7 +111,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
@@ -42,7 +42,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+2
View File
@@ -2,6 +2,8 @@
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
```bash
+46
View File
@@ -0,0 +1,46 @@
# Demo Agent Instructions
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running the demo
Run from the repository root:
```bash
yarn dev:demo # dev server on http://localhost:8090
yarn dev:demo --background # detached; also supports --status/--stop/--logs
```
## Opening a specific demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
## Opening a specific page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
Example — the water tab of the energy dashboard of the second demo:
```
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+36 -13
View File
@@ -1,6 +1,9 @@
import { navigate } from "../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -12,33 +15,53 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
hass.mockTheme(null, getDemoTheme(theme));
};
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./sections").then((mod) => mod.demoSections),
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
() => import("./kernehed").then((mod) => mod.demoKernehed),
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
sections: () => import("./sections").then((mod) => mod.demoSections),
home: () => import("./home").then((mod) => mod.demoHome),
arsaboo: () => import("./arsaboo").then((mod) => mod.demoArsaboo),
teachingbirds: () =>
import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
kernehed: () => import("./kernehed").then((mod) => mod.demoKernehed),
jimpower: () => import("./jimpower").then((mod) => mod.demoJimpower),
};
export const demos = Object.keys(demoConfigs);
const initialDemo = () => {
const slug = new URLSearchParams(window.location.search).get("demo");
return slug && demos.includes(slug) ? slug : demos[0];
};
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfigIndex = 0;
export let selectedDemo = initialDemo();
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();
demoConfigs[selectedDemo]();
export const setDemoConfig = async (
hass: MockHomeAssistant,
lovelace: Lovelace,
index: number
demo: string
) => {
const confProm = demoConfigs[index]();
const confProm = demoConfigs[demo]();
const config = await confProm;
selectedDemoConfigIndex = index;
selectedDemo = demo;
selectedDemoConfig = confProm;
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
// Let the new registries and entities reach the dashboard before saving the
// config, so dashboard strategies generate against them
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
await lovelace.saveConfig(config.lovelace(hass.localize));
// The view of the previous demo might not exist in the new one
navigate(`/${hass.panelUrl}?demo=${demo}`, { replace: true });
applyDemoTheme(hass, config.theme);
};
+63
View File
@@ -0,0 +1,63 @@
import type { DemoArea } from "../../stubs/area_registry";
import type { DemoFloor } from "../../stubs/floor_registry";
export const demoFloorsHome: DemoFloor[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
},
{
floor_id: "upstairs",
name: "Upstairs",
level: 1,
},
];
export const demoAreasHome: DemoArea[] = [
{
area_id: "living_room",
name: "Living room",
floor_id: "ground",
icon: "mdi:sofa",
temperature_entity_id: "sensor.living_room_temperature",
humidity_entity_id: "sensor.living_room_humidity",
},
{
area_id: "kitchen",
name: "Kitchen",
floor_id: "ground",
icon: "mdi:fridge",
temperature_entity_id: "sensor.kitchen_temperature",
humidity_entity_id: "sensor.kitchen_humidity",
},
{
area_id: "entrance",
name: "Entrance",
floor_id: "ground",
icon: "mdi:door",
},
{
area_id: "bedroom",
name: "Bedroom",
floor_id: "upstairs",
icon: "mdi:bed",
temperature_entity_id: "sensor.bedroom_temperature",
humidity_entity_id: "sensor.bedroom_humidity",
},
{
area_id: "office",
name: "Office",
floor_id: "upstairs",
icon: "mdi:desk",
temperature_entity_id: "sensor.office_temperature",
humidity_entity_id: "sensor.office_humidity",
},
{
area_id: "garden",
name: "Garden",
icon: "mdi:tree",
temperature_entity_id: "sensor.garden_temperature",
humidity_entity_id: "sensor.garden_humidity",
},
];
+465
View File
@@ -0,0 +1,465 @@
import type { DemoConfig } from "../types";
export const demoEntitiesHome: DemoConfig["entities"] = () => [
// The devices tile on the overview uses zone.home, which always exists in
// a real installation.
{
entity_id: "zone.home",
state: "1",
attributes: {
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
},
},
{
entity_id: "light.living_room_floor_lamp",
state: "on",
area_id: "living_room",
attributes: {
min_color_temp_kelvin: 2000,
max_color_temp_kelvin: 6535,
supported_color_modes: ["color_temp", "xy"],
color_mode: "color_temp",
brightness: 178,
color_temp_kelvin: 2583,
icon: "mdi:floor-lamp",
friendly_name: "Floor lamp",
supported_features: 44,
},
},
{
entity_id: "light.living_room_spotlights",
state: "on",
area_id: "living_room",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 126,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Spotlights",
supported_features: 32,
},
},
{
entity_id: "climate.living_room",
state: "heat",
area_id: "living_room",
attributes: {
hvac_modes: ["auto", "heat", "off"],
min_temp: 7,
max_temp: 35,
current_temperature: 21.5,
temperature: 21,
friendly_name: "Thermostat",
supported_features: 385,
},
},
{
entity_id: "cover.living_room_blinds",
state: "open",
area_id: "living_room",
attributes: {
current_position: 100,
device_class: "blind",
friendly_name: "Blinds",
supported_features: 15,
},
},
{
entity_id: "binary_sensor.living_room_window",
state: "off",
area_id: "living_room",
attributes: {
device_class: "window",
friendly_name: "Window",
},
},
{
entity_id: "media_player.living_room_speaker",
state: "playing",
area_id: "living_room",
attributes: {
device_class: "speaker",
volume_level: 0.28,
is_volume_muted: false,
media_content_type: "music",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
new Date().getTime() - 23000
).toISOString(),
media_title: "I Wasn't Born To Follow",
media_artist: "The Byrds",
media_album_name: "The Notorious Byrd Brothers",
shuffle: false,
friendly_name: "Living room speaker",
supported_features: 64063,
},
},
{
entity_id: "sensor.living_room_temperature",
state: "21.5",
area_id: "living_room",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.living_room_humidity",
state: "45",
area_id: "living_room",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "light.kitchen_spotlights",
state: "on",
area_id: "kitchen",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 255,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Spotlights",
supported_features: 32,
},
},
{
entity_id: "light.kitchen_worktop",
state: "off",
area_id: "kitchen",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:light-flood-down",
friendly_name: "Worktop",
supported_features: 32,
},
},
{
entity_id: "switch.coffee_machine",
state: "on",
area_id: "kitchen",
attributes: {
icon: "mdi:coffee-maker",
friendly_name: "Coffee machine",
},
},
{
entity_id: "binary_sensor.kitchen_motion",
state: "off",
area_id: "kitchen",
attributes: {
device_class: "motion",
friendly_name: "Motion",
},
},
{
entity_id: "media_player.kitchen_speaker",
state: "idle",
area_id: "kitchen",
attributes: {
device_class: "speaker",
volume_level: 0.18,
is_volume_muted: false,
friendly_name: "Kitchen speaker",
supported_features: 64063,
},
},
{
entity_id: "sensor.kitchen_temperature",
state: "22.1",
area_id: "kitchen",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.kitchen_humidity",
state: "48",
area_id: "kitchen",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "lock.front_door",
state: "locked",
area_id: "entrance",
attributes: {
friendly_name: "Front door",
supported_features: 1,
},
},
{
entity_id: "binary_sensor.front_door",
state: "off",
area_id: "entrance",
attributes: {
device_class: "door",
friendly_name: "Front door",
},
},
{
entity_id: "alarm_control_panel.home_alarm",
state: "disarmed",
area_id: "entrance",
attributes: {
changed_by: null,
code_arm_required: false,
friendly_name: "Home alarm",
supported_features: 3,
},
},
{
entity_id: "light.entrance_ceiling",
state: "off",
area_id: "entrance",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
friendly_name: "Ceiling light",
supported_features: 32,
},
},
{
entity_id: "sensor.front_door_lock_battery",
state: "88",
area_id: "entrance",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "battery",
friendly_name: "Front door lock battery",
},
},
{
entity_id: "light.bedroom_ceiling",
state: "off",
area_id: "bedroom",
attributes: {
supported_color_modes: ["color_temp"],
color_mode: null,
brightness: null,
friendly_name: "Ceiling light",
supported_features: 44,
},
},
{
entity_id: "light.bedside_lamp",
state: "off",
area_id: "bedroom",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:lamp",
friendly_name: "Bedside lamp",
supported_features: 32,
},
},
{
entity_id: "cover.bedroom_shutter",
state: "open",
area_id: "bedroom",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Shutter",
supported_features: 15,
},
},
{
entity_id: "media_player.bedroom_speaker",
state: "off",
area_id: "bedroom",
attributes: {
device_class: "speaker",
friendly_name: "Bedroom speaker",
supported_features: 64063,
},
},
{
entity_id: "binary_sensor.bedroom_motion",
state: "off",
area_id: "bedroom",
attributes: {
device_class: "motion",
friendly_name: "Motion",
},
},
{
entity_id: "sensor.bedroom_motion_battery",
state: "15",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "battery",
friendly_name: "Motion sensor battery",
},
},
{
entity_id: "sensor.bedroom_temperature",
state: "19.6",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.bedroom_humidity",
state: "52",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "light.office_desk_lamp",
state: "on",
area_id: "office",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 200,
icon: "mdi:desk-lamp",
friendly_name: "Desk lamp",
supported_features: 32,
},
},
{
entity_id: "fan.office_ceiling_fan",
state: "off",
area_id: "office",
attributes: {
percentage: 0,
percentage_step: 25,
friendly_name: "Ceiling fan",
supported_features: 1,
},
},
{
entity_id: "binary_sensor.office_window",
state: "off",
area_id: "office",
attributes: {
device_class: "window",
friendly_name: "Window",
},
},
{
entity_id: "sensor.office_temperature",
state: "21.8",
area_id: "office",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.office_humidity",
state: "50",
area_id: "office",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "switch.garden_sprinkler",
state: "off",
area_id: "garden",
attributes: {
icon: "mdi:sprinkler-variant",
friendly_name: "Sprinkler",
},
},
{
entity_id: "light.garden_path",
state: "off",
area_id: "garden",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:outdoor-lamp",
friendly_name: "Path lights",
supported_features: 32,
},
},
{
entity_id: "binary_sensor.garden_gate",
state: "off",
area_id: "garden",
attributes: {
device_class: "opening",
friendly_name: "Gate",
},
},
{
entity_id: "sensor.garden_temperature",
state: "14.2",
area_id: "garden",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.garden_humidity",
state: "68",
area_id: "garden",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "weather.home",
state: "partlycloudy",
area_id: "garden",
attributes: {
temperature: 14.2,
temperature_unit: "°C",
humidity: 68,
pressure: 1012,
pressure_unit: "hPa",
wind_speed: 11.2,
wind_speed_unit: "km/h",
friendly_name: "Home",
},
},
];
+17
View File
@@ -0,0 +1,17 @@
import type { DemoConfig } from "../types";
import { demoAreasHome, demoFloorsHome } from "./areas";
import { demoEntitiesHome } from "./entities";
import { demoLovelaceHome } from "./lovelace";
export const demoHome: DemoConfig = {
authorName: "Home Assistant",
authorUrl: "https://www.home-assistant.io",
name: "Home page",
description:
"The page you land on when you open Home Assistant, automatically built from the areas in your home and the devices in them.",
lovelace: demoLovelaceHome,
entities: demoEntitiesHome,
floors: demoFloorsHome,
areas: demoAreasHome,
theme: { theme: "default", dark: false },
};
+9
View File
@@ -0,0 +1,9 @@
import "./strategies";
import type { DemoConfig } from "../types";
export const demoLovelaceHome: DemoConfig["lovelace"] = () => ({
strategy: {
type: "custom:demo-home",
favorite_entities: ["lock.front_door", "switch.garden_sprinkler"],
},
});
+109
View File
@@ -0,0 +1,109 @@
import { ReactiveElement } from "lit";
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
import { isStrategyView } from "../../../../src/data/lovelace/config/view";
import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
import type { HomeAssistant } from "../../../../src/types";
export interface DemoHomeDashboardStrategyConfig extends Omit<
HomeDashboardStrategyConfig,
"type"
> {
type: "custom:demo-home";
}
interface DemoHomeOverviewViewStrategyConfig extends Omit<
HomeOverviewViewStrategyConfig,
"type"
> {
type: "custom:demo-home-overview";
}
class DemoHomeDashboardStrategy extends ReactiveElement {
static registryDependencies = HomeDashboardStrategy.registryDependencies;
static async generate(
config: DemoHomeDashboardStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceConfig> {
const generated = await HomeDashboardStrategy.generate(
{ ...config, type: "home" },
hass
);
// Swap the overview view for the demo version, which adds the demo card.
return {
...generated,
views: generated.views.map((view) =>
isStrategyView(view) && view.strategy.type === "home-overview"
? {
...view,
strategy: { ...view.strategy, type: "custom:demo-home-overview" },
}
: view
),
};
}
}
class DemoHomeOverviewViewStrategy extends ReactiveElement {
static registryDependencies = HomeOverviewViewStrategy.registryDependencies;
static async generate(
config: DemoHomeOverviewViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view = await HomeOverviewViewStrategy.generate(
{ ...config, type: "home-overview" },
hass
);
// Expand the favorites section so the demo card can be added to it
const sections = await Promise.all(
(view.sections || []).map(async (section) => {
if (
!isStrategySection(section) ||
section.strategy.type !== "common-controls"
) {
return section;
}
// The demo card takes up the space of two tiles
const limit = (section.strategy.limit as number | undefined) ?? 8;
const favorites = await generateLovelaceSectionStrategy(
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
hass
);
const [heading, ...cards] = favorites.cards || [];
return {
...favorites,
// Place the demo card first so the tiles fill the rows next to it
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
};
})
);
return {
...view,
sections: sections,
};
}
}
customElements.define(
"ll-strategy-dashboard-demo-home",
DemoHomeDashboardStrategy
);
customElements.define(
"ll-strategy-view-demo-home-overview",
DemoHomeOverviewViewStrategy
);
declare global {
interface HTMLElementTagNameMap {
"ll-strategy-dashboard-demo-home": DemoHomeDashboardStrategy;
"ll-strategy-view-demo-home-overview": DemoHomeOverviewViewStrategy;
}
}
+6 -2
View File
@@ -1,8 +1,10 @@
import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
import type { DemoArea } from "../stubs/area_registry";
import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
@@ -13,7 +15,9 @@ export interface DemoConfig {
authorUrl: string;
description?:
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
}
+5 -9
View File
@@ -13,9 +13,9 @@ import type {
LovelaceCard,
} from "../../../src/panels/lovelace/types";
import {
demoConfigs,
demos,
selectedDemo,
selectedDemoConfig,
selectedDemoConfigIndex,
} from "../configs/demo-configs";
@customElement("ha-demo-card")
@@ -112,16 +112,12 @@ export class HADemoCard extends LitElement implements LovelaceCard {
}
private _nextConfig() {
this._updateConfig(
selectedDemoConfigIndex < demoConfigs.length - 1
? selectedDemoConfigIndex + 1
: 0
);
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
}
private async _updateConfig(index: number) {
private async _updateConfig(demo: string) {
this._switching = true;
fireEvent(this, "set-demo-config" as any, { index });
fireEvent(this, "set-demo-config" as any, { demo });
}
static get styles(): CSSResultGroup {
+103
View File
@@ -0,0 +1,103 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-card";
import "../../../src/components/ha-control-button";
import "../../../src/components/ha-control-button-group";
import "../../../src/components/tile/ha-tile-container";
import "../../../src/components/tile/ha-tile-icon";
import "../../../src/components/tile/ha-tile-info";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { tileCardStyle } from "../../../src/panels/lovelace/cards/tile/tile-card-style";
import type {
LovelaceCard,
LovelaceGridOptions,
} from "../../../src/panels/lovelace/types";
import {
demos,
selectedDemo,
selectedDemoConfig,
} from "../configs/demo-configs";
@customElement("ha-demo-next-card")
export class HADemoNextCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: MockHomeAssistant;
@state() private _switching = false;
public getCardSize() {
return 2;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 6,
rows: 2,
min_columns: 6,
min_rows: 2,
};
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public setConfig(_config: LovelaceCardConfig) {}
protected render() {
return html`
<ha-card>
<ha-tile-container>
<ha-tile-icon slot="icon" icon="mdi:home-assistant"></ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">
${until(
selectedDemoConfig.then((conf) => conf.name),
nothing
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.page-demo.cards.demo.interactive_demo"
)}
</span>
</ha-tile-info>
<ha-control-button-group slot="features">
<ha-control-button
.disabled=${this._switching}
@click=${this._nextConfig}
>
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
</ha-control-button>
</ha-control-button-group>
</ha-tile-container>
</ha-card>
`;
}
private _nextConfig() {
this._switching = true;
fireEvent(this, "set-demo-config" as any, {
demo: demos[(demos.indexOf(selectedDemo) + 1) % demos.length],
});
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--primary-color);
}
ha-control-button-group {
--control-button-group-spacing: 0;
--control-button-group-thickness: var(--feature-height, 42px);
padding: 0 var(--ha-space-3) var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-demo-next-card": HADemoNextCard;
}
}
+16 -5
View File
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -14,7 +14,7 @@ import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry } from "./stubs/floor_registry";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
@@ -29,6 +29,7 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
@@ -74,11 +75,18 @@ export class HaDemo extends HomeAssistantAppEl {
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried.
// mocked backup/config/info and webhook/list are queried. usage_prediction
// is needed for common-controls sections in strategy dashboards.
hass.updateHass({
config: {
...hass.config,
components: [...(hass.config?.components ?? []), "backup", "webhook"],
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
],
},
});
@@ -122,6 +130,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -169,9 +178,11 @@ export class HaDemo extends HomeAssistantAppEl {
hass.addEntities(energyEntities());
// Once config is loaded AND localize, set entities and apply theme.
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
}
+39 -7
View File
@@ -1,14 +1,46 @@
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoArea {
area_id: string;
name: string;
floor_id?: string;
icon?: string;
temperature_entity_id?: string;
humidity_entity_id?: string;
}
let areas: AreaRegistryEntry[] = [];
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: AreaRegistryEntry[] = []
data: DemoArea[] = []
) => {
hass.mockWS("config/area_registry/list", () => data);
const areas = {};
data.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas });
hass.mockWS("config/area_registry/list", () => areas);
setDemoAreas(hass, data);
};
/** Set the areas of the currently loaded demo config. */
export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
});
hass.updateHass({ areas: areasById });
};
+18 -4
View File
@@ -19,17 +19,31 @@ const pipelines: AssistPipeline[] = [
{
id: "01local",
name: "Local",
language: "en",
language: "nl",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
conversation_language: "nl",
stt_engine: "stt.faster_whisper",
stt_language: "en",
stt_language: "nl",
tts_engine: "tts.piper",
tts_language: "en",
tts_language: "nl",
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
},
{
id: "01chatgpt",
name: "ChatGPT",
language: "es",
conversation_engine: "conversation.chatgpt",
conversation_language: "es",
stt_engine: "cloud",
stt_language: "es-ES",
tts_engine: "cloud",
tts_language: "es-ES",
tts_voice: "ElviraNeural",
wake_word_entity: null,
wake_word_id: null,
},
];
export const mockAssist = (hass: MockHomeAssistant) => {
+1 -1
View File
@@ -85,7 +85,7 @@ export const energyEntities = () =>
},
},
"sensor.energy_consumption_tarif_1": {
entity_id: "sensor.energy_consumption_tarif_1 ",
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
+35 -2
View File
@@ -1,7 +1,40 @@
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoFloor {
floor_id: string;
name: string;
level?: number;
icon?: string;
}
let floors: FloorRegistryEntry[] = [];
export const mockFloorRegistry = (
hass: MockHomeAssistant,
data: FloorRegistryEntry[] = []
) => hass.mockWS("config/floor_registry/list", () => data);
data: DemoFloor[] = []
) => {
hass.mockWS("config/floor_registry/list", () => floors);
setDemoFloors(hass, data);
};
/** Set the floors of the currently loaded demo config. */
export const setDemoFloors = (
hass: MockHomeAssistant,
demoFloors: DemoFloor[] = []
) => {
floors = demoFloors.map((floor) => ({
floor_id: floor.floor_id,
name: floor.name,
level: floor.level ?? null,
icon: floor.icon ?? null,
aliases: [],
created_at: 0,
modified_at: 0,
}));
const floorsById: Record<string, FloorRegistryEntry> = {};
floors.forEach((floor) => {
floorsById[floor.floor_id] = floor;
});
hass.updateHass({ floors: floorsById });
};
+5 -4
View File
@@ -2,12 +2,13 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
selectedDemo,
selectedDemoConfig,
selectedDemoConfigIndex,
setDemoConfig,
} from "../configs/demo-configs";
import "../custom-cards/cast-demo-row";
import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mapEntities } from "./entities";
export const mockLovelace = (
@@ -45,11 +46,11 @@ customElements.whenDefined("hui-root").then(() => {
HUIRoot.prototype.firstUpdated = function (changedProperties) {
oldFirstUpdated.call(this, changedProperties);
this.addEventListener("set-demo-config", async (ev) => {
const index = (ev as CustomEvent).detail.index;
const demo = (ev as CustomEvent).detail.demo;
try {
await setDemoConfig(this.hass, this.lovelace!, index);
await setDemoConfig(this.hass, this.lovelace!, demo);
} catch (_err: any) {
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
alert("Failed to switch config :-(");
}
});
+19
View File
@@ -0,0 +1,19 @@
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
// Entities that don't exist in the currently loaded demo config are
// filtered out by the common-controls section strategy.
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
entities: [
"light.living_room_floor_lamp",
"climate.living_room",
"cover.living_room_blinds",
"media_player.living_room_speaker",
"light.kitchen_spotlights",
"switch.coffee_machine",
"light.bedside_lamp",
"alarm_control_panel.home_alarm",
],
}));
};
+5 -28
View File
@@ -2,7 +2,10 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
@@ -10,7 +13,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
+10 -38
View File
@@ -1,21 +1,25 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import {
mockFloorRegistry,
type DemoFloor,
} from "../../../../demo/src/stubs/floor_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import {
showDialog,
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
area_id: "backyard",
floor_id: "ground",
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: "first",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: "ground",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
const FLOORS: FloorRegistryEntry[] = [
const FLOORS: DemoFloor[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
icon: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "first",
name: "First floor",
level: 1,
icon: "mdi:numeric-1",
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "second",
name: "Second floor",
level: 2,
icon: "mdi:numeric-2",
aliases: [],
created_at: 0,
modified_at: 0,
},
];
+6 -7
View File
@@ -91,7 +91,7 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
"cropperjs": "1.6.2",
@@ -108,7 +108,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"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",
@@ -183,7 +183,7 @@
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.6",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
@@ -196,7 +196,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.1.0",
"lint-staged": "17.2.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -207,7 +207,7 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
"tar": "7.5.20",
"tar": "7.5.21",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
@@ -226,8 +226,7 @@
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
"@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": "yarn@4.17.1",
"volta": {
@@ -0,0 +1,120 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
const scrollParent = (element: Element): HTMLElement | undefined => {
let node = element.parentElement;
while (node) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") {
return node;
}
node = node.parentElement;
}
return undefined;
};
/**
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
* Point the ref at the element that grows, not at the scroller. Turns native
* anchoring off on that scroller, so growth elsewhere in it is no longer
* compensated either.
*/
export class PreserveScrollPositionController implements ReactiveController {
private _target: Ref<HTMLElement>;
private _element?: HTMLElement;
private _scroller?: HTMLElement;
private _observer?: ResizeObserver;
private _height = 0;
constructor(
host: ReactiveControllerHost & LitElement,
target: Ref<HTMLElement>
) {
this._target = target;
host.addController(this);
}
hostConnected() {
this._sync();
}
hostUpdated() {
this._sync();
}
hostDisconnected() {
this._detach();
}
private _sync() {
const element = this._target.value;
if (element === this._element) {
return;
}
this._detach();
this._element = element;
if (element) {
this._height = element.getBoundingClientRect().height;
this._observer = new ResizeObserver((entries) =>
this._compensate(entries)
);
this._observer.observe(element);
}
}
private _detach() {
this._observer?.disconnect();
this._observer = undefined;
this._element = undefined;
if (this._scroller) {
this._scroller.style.removeProperty("overflow-anchor");
this._scroller = undefined;
}
}
private _resolveScroller(): HTMLElement | undefined {
if (!this._scroller && this._element) {
this._scroller = scrollParent(this._element);
if (this._scroller) {
// Chrome and Firefox would otherwise anchor on top of this controller
// and correct twice.
this._scroller.style.overflowAnchor = "none";
}
}
return this._scroller;
}
private _compensate(entries: ResizeObserverEntry[]) {
const element = this._element;
if (!element) {
return;
}
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
const delta = height - this._height;
this._height = height;
if (!delta) {
return;
}
const scroller = this._resolveScroller();
if (
scroller &&
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
) {
scroller.scrollTop += delta;
}
}
}
+11 -18
View File
@@ -64,22 +64,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (__DEMO__) {
if (path.includes("#")) {
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
return true;
if (!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(
@@ -87,10 +80,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
? { root: true }
: (options?.data ?? null),
"",
`${mainWindow.location.pathname}#${path}`
path
);
} else {
mainWindow.location.hash = path;
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
+4
View File
@@ -36,6 +36,10 @@ 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) {
// a degenerate frame size would put every point in a single frame
return data;
}
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
+4 -1
View File
@@ -51,6 +51,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const DEFAULT_CHART_WIDTH = 500;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -1048,7 +1049,9 @@ export class HaChartBase extends LitElement {
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
this.clientWidth * window.devicePixelRatio,
// 0 while inside a hidden container, e.g. a section with a visibility condition
(this.clientWidth || DEFAULT_CHART_WIDTH) *
window.devicePixelRatio,
minX,
maxX
),
@@ -141,7 +141,7 @@ export class HaChooseSelector extends LitElement {
}
private _value(choice?: string): any {
if (!this.value) {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object"
+22 -2
View File
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
@@ -1,7 +1,13 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import { mdiArrowUpRight, mdiKeyboard, mdiPlay, mdiPlus } from "@mdi/js";
import {
mdiArrowUpRight,
mdiFilterVariant,
mdiKeyboard,
mdiPlay,
mdiPlus,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -14,6 +20,7 @@ import {
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
import { UNAVAILABLE } from "../../data/entity/entity";
@@ -32,6 +39,7 @@ import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
@@ -43,9 +51,14 @@ import "../entity/ha-entity-picker";
import "../ha-alert";
import "../ha-button";
import "../ha-card";
import "../ha-dropdown";
import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "../input/ha-input-search";
import type { HaInputSearch } from "../input/ha-input-search";
import "./ha-media-browser-thumbnail";
import "../ha-spinner";
import "../ha-svg-icon";
@@ -74,6 +87,8 @@ export interface MediaPlayerItemId {
media_content_type?: string | undefined;
}
type MediaClass = MediaPlayerItem["media_class"];
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_expand: true,
can_play: false,
@@ -122,6 +137,16 @@ export class HaMediaPlayerBrowse extends LitElement {
@state() private _currentItem?: MediaPlayerItem;
@state() private _searchQuery = "";
@state() private _searchResults?: MediaPlayerItem[];
@state() private _searching = false;
@state() private _mediaClassFilter: MediaClass[] = [];
private _searchRequestId = 0;
@query(".header") private _header?: HTMLDivElement;
@query(".content") private _content?: HTMLDivElement;
@@ -197,6 +222,10 @@ export class HaMediaPlayerBrowse extends LitElement {
const oldParentItem = this._parentItem;
this._currentItem = undefined;
this._parentItem = undefined;
this._abortSearch();
this._searchQuery = "";
this._searchResults = undefined;
this._mediaClassFilter = [];
const currentId = navigateIds[navigateIds.length - 1];
const parentId =
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
@@ -328,11 +357,14 @@ export class HaMediaPlayerBrowse extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_scrolled")) {
if (changedProps.has("_scrolled") || changedProps.has("_currentItem")) {
// Re-measure across frames rather than once: the search input sizes
// asynchronously, so a single measurement can be too small and the
// content would clip under the header.
this._animateHeaderHeight();
} else if (changedProps.has("_currentItem")) {
this._setHeaderHeight();
}
if (changedProps.has("_currentItem")) {
// This fixes a race condition for resizing of the cards using the grid layout
if (this._observed) {
return;
@@ -365,10 +397,15 @@ export class HaMediaPlayerBrowse extends LitElement {
const currentItem = this._currentItem;
const isSearching = this._searchResults !== undefined;
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
let children = currentItem.children || [];
let children = isSearching
? this._searchResults!
: currentItem.children || [];
const notShown = isSearching ? 0 : currentItem.not_shown || 0;
const canPlayChildren = new Set<string>();
// Filter children based on accept property if provided
@@ -399,6 +436,26 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
// Search is available on non-root pages that opt in via can_search, aside
// from the manual-entry and TTS pseudo-sources.
const showSearch =
this.navigateIds.length > 1 &&
!isManualMediaSourceContentId(currentItem.media_content_id) &&
!isTTSMediaSource(currentItem.media_content_id) &&
currentItem.can_search;
// The backend reports which media classes are worth filtering by for this
// item; without them we still allow searching, just without the filter.
const mediaClassFilterOptions =
showSearch && currentItem.search_media_classes
? [...currentItem.search_media_classes].sort((a, b) =>
caseInsensitiveStringCompare(
this._localizeMediaClass(a),
this._localizeMediaClass(b),
this.hass.locale.language
)
)
: [];
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
@@ -406,94 +463,109 @@ export class HaMediaPlayerBrowse extends LitElement {
return html`
${
currentItem.can_play
currentItem.can_play || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !currentItem.can_play,
})}"
@transitionend=${this._setHeaderHeight}
>
<div class="header-content">
${
currentItem.thumbnail
? html`
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${
showSearch
? this._renderSearchRow(
currentItem,
mediaClassFilterOptions
)
: nothing
}
${
currentItem.can_play
? html`<div class="header-content">
${
currentItem.thumbnail
? html`
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${
this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
? html`
<ha-button
class="fab"
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
></ha-svg-icon>
</ha-button>
`
: ""
}
</div>
`
: nothing
}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${
this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
subtitle
? html`
<ha-button
class="fab"
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
></ha-svg-icon>
</ha-button>
<h2 class="subtitle">
${subtitle}
</h2>
`
: ""
}
</div>
`
: nothing
}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${
subtitle
? html`
<h2 class="subtitle">${subtitle}</h2>
`
: ""
}
</div>
${
currentItem.can_play &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-button>
`
: ""
}
</div>
</div>
${
currentItem.can_play &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${
this.action === "play"
? mdiPlay
: mdiPlus
}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-button>
`
: ""
}
</div>
</div>`
: nothing
}
</div>
`
: ""
@@ -504,12 +576,10 @@ export class HaMediaPlayerBrowse extends LitElement {
@touchmove=${this._scroll}
>
${
this._error
this._searching
? html`
<div class="container">
<ha-alert alert-type="error">
${this._renderError(this._error)}
</ha-alert>
<ha-spinner></ha-spinner>
</div>
`
: isManualMediaSourceContentId(currentItem.media_content_id)
@@ -532,29 +602,33 @@ export class HaMediaPlayerBrowse extends LitElement {
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
: !children.length && !notShown
? html`
<div class="container no-items">
${
currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
isSearching
? this.hass.localize(
"ui.components.media-browser.search.no_results"
)
: currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)
}
</div>
`
@@ -584,17 +658,17 @@ export class HaMediaPlayerBrowse extends LitElement {
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
not_shown: !!notShown,
})}"
></lit-virtualizer>
${
currentItem.not_shown
notShown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
{ count: notShown }
)}
</div>
</div>
@@ -614,7 +688,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.renderItem=${this._renderListItem}
></lit-virtualizer>
${
currentItem.not_shown
notShown
? html`
<ha-list-item
noninteractive
@@ -628,7 +702,7 @@ export class HaMediaPlayerBrowse extends LitElement {
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
{ count: notShown }
)}
</span>
</ha-list-item>
@@ -644,6 +718,188 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
}
private _renderSearchRow(
currentItem: MediaPlayerItem,
mediaClassFilterOptions: MediaClass[]
): TemplateResult {
return html`
<div class="search-row">
${
currentItem.can_search
? html`
<ha-input-search
class="search-input"
appearance="outlined"
.value=${this._searchQuery}
.placeholder=${this.hass.localize(
"ui.components.media-browser.search.search_placeholder",
{ name: currentItem.title }
)}
@input=${this._handleSearchInput}
@keydown=${this._handleSearchKeydown}
></ha-input-search>
${
mediaClassFilterOptions.length
? this._renderMediaClassFilter(mediaClassFilterOptions)
: nothing
}
<ha-button
class="search-button"
appearance="filled"
.disabled=${!this._searchQuery.trim()}
@click=${this._search}
>
${this.hass.localize("ui.common.search")}
</ha-button>
`
: nothing
}
</div>
`;
}
private _renderMediaClassFilter(
mediaClassFilterOptions: MediaClass[]
): TemplateResult {
const selectedCount = this._mediaClassFilter.length;
return html`
<div class="media-class-filter">
<ha-dropdown
placement="bottom-end"
@wa-select=${this._toggleMediaClassFilter}
>
<ha-icon-button
slot="trigger"
class="filter-button ${classMap({ active: selectedCount > 0 })}"
.path=${mdiFilterVariant}
.label=${this.hass.localize(
"ui.components.media-browser.filter_media_type"
)}
></ha-icon-button>
${mediaClassFilterOptions.map((mediaClass) => {
const selected = this._mediaClassFilter.includes(mediaClass);
return html`
<ha-dropdown-item
.value=${mediaClass}
.action=${selected ? "remove" : "add"}
type="checkbox"
.checked=${selected}
>
${this._localizeMediaClass(mediaClass)}
</ha-dropdown-item>
`;
})}
</ha-dropdown>
${
selectedCount
? html`<div class="filter-badge">${selectedCount}</div>`
: nothing
}
</div>
`;
}
private _localizeMediaClass(mediaClass: MediaClass): string {
return (
this.hass.localize(`ui.components.media-browser.class.${mediaClass}`) ||
mediaClass
);
}
private _toggleMediaClassFilter(ev: HaDropdownSelectEvent<MediaClass>): void {
ev.preventDefault(); // keep the dropdown open for multi-select
const value = ev.detail.item.value;
const action = (ev.detail.item as { action?: "add" | "remove" }).action;
this._mediaClassFilter =
action === "add"
? [...this._mediaClassFilter, value]
: this._mediaClassFilter.filter((mediaClass) => mediaClass !== value);
// Only refine results already on screen; before a search has run, the
// filter is just staged for the next Enter/search-button submission.
if (this._searchResults !== undefined) {
this._search();
}
}
private _handleSearchInput(ev: InputEvent): void {
const value = (ev.target as HaInputSearch).value ?? "";
this._searchQuery = value;
// Searching is explicit (Enter or the search button). Emptying the field —
// e.g. via the clear button — returns to the browse view.
if (!value) {
this._clearSearch();
}
}
private _handleSearchKeydown(ev: KeyboardEvent): void {
if (ev.key === "Enter") {
ev.preventDefault();
this._search();
}
}
private _abortSearch(): void {
// Invalidate any in-flight search so a late response is ignored, and clear
// the loading state.
this._searchRequestId++;
this._searching = false;
}
private _clearSearch(): void {
this._abortSearch();
this._searchQuery = "";
this._searchResults = undefined;
}
private async _search(): Promise<void> {
const searchQuery = this._searchQuery.trim();
if (!searchQuery) {
// Nothing to search; drop any stale results but keep the input.
this._abortSearch();
this._searchResults = undefined;
return;
}
const navigateId = this.navigateIds[this.navigateIds.length - 1];
const requestId = ++this._searchRequestId;
this._searching = true;
// Clear previous results so stale data isn't shown while searching or on error
this._searchResults = undefined;
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
}
this._searchResults = result;
} catch (err) {
// Ignore errors from superseded searches
if (requestId !== this._searchRequestId) {
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.media-browser.media_browsing_error"
),
text: err instanceof Error ? err.message : String(err),
});
} finally {
// Only the most recent search controls the loading state
if (requestId === this._searchRequestId) {
this._searching = false;
}
}
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
@@ -1009,7 +1265,8 @@ export class HaMediaPlayerBrowse extends LitElement {
.header {
display: flex;
justify-content: space-between;
flex-direction: column;
gap: var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
background-color: var(--card-background-color);
position: absolute;
@@ -1019,6 +1276,56 @@ export class HaMediaPlayerBrowse extends LitElement {
z-index: 3;
padding: 16px;
}
.header.search-only {
padding: 8px 16px;
}
.search-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.search-input {
flex: 1;
--ha-input-padding-top: 0;
--ha-input-padding-bottom: 0;
}
.search-button {
flex: none;
}
:host([narrow]) .search-row {
box-sizing: border-box;
padding: 8px 16px;
}
.media-class-filter {
position: relative;
flex: none;
}
.filter-button {
--ha-icon-button-size: 40px;
color: var(--secondary-text-color);
}
.filter-button.active {
color: var(--primary-color);
}
.filter-badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
pointer-events: none;
}
.header_button {
position: relative;
right: -8px;
+10
View File
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
* boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
declare global {
interface Window {
isDirtyState?: boolean;
}
interface HASSDomEvents {
"dirty-state-changed": { isDirty: boolean };
}
}
+1
View File
@@ -173,6 +173,7 @@ export interface BatterySourceTypeEnergyPreference {
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
capacity?: number; // usable capacity in kWh, used to weight the combined SOC
name?: string;
}
export interface GasSourceTypeEnergyPreference {
+1
View File
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
dashboard_favorite_card_types?: string[];
}
export interface SidebarFrontendUserData {
+3
View File
@@ -186,6 +186,9 @@ export interface MediaPlayerItem {
can_play: boolean;
can_expand: boolean;
can_search: boolean;
search_media_classes?:
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
| null;
thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[];
+17
View File
@@ -24,6 +24,23 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
searchQuery: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_source/search_media",
media_content_id: mediaContentId,
search_query: searchQuery,
media_filter_classes: mediaFilterClasses,
});
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
export const isManualMediaSourceContentId = (mediaContentId: string) =>
+1 -1
View File
@@ -108,7 +108,7 @@ class StepFlowForm extends LitElement {
: nothing
}
${
step.data_schema.length
step.data_schema.length || this._errors
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!changedProperties.has("_params") || !this._params) {
return;
}
+32
View File
@@ -16,6 +16,38 @@ export const demoPanels: Panels = {
config: null,
url_path: "home",
},
light: {
component_name: "light",
icon: "mdi:lamps",
title: "light",
default_visible: false,
config: null,
url_path: "light",
},
climate: {
component_name: "climate",
icon: "mdi:home-thermometer",
title: "climate",
default_visible: false,
config: null,
url_path: "climate",
},
security: {
component_name: "security",
icon: "mdi:security",
title: "security",
default_visible: false,
config: null,
url_path: "security",
},
maintenance: {
component_name: "maintenance",
icon: "mdi:wrench",
title: "maintenance",
default_visible: false,
config: null,
url_path: "maintenance",
},
"dev-state": {
component_name: "dev-state",
icon: null,
+5
View File
@@ -27,6 +27,8 @@ export class MockBaseEntity {
public state: string;
public areaId?: string;
public baseAttributes: EntityAttributes;
public attributes: EntityAttributes;
@@ -47,6 +49,7 @@ export class MockBaseEntity {
this.domain = domain;
this.objectId = objectId;
this.state = input.state;
this.areaId = input.area_id;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
@@ -127,6 +130,8 @@ export class MockBaseEntity {
"entity_picture",
"assumed_state",
"device_class",
"state_class",
"unit_of_measurement",
"supported_features",
]) {
if (key in attrs) {
+4 -1
View File
@@ -10,7 +10,10 @@ export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
export type EntityInput = Pick<
HassEntity,
"entity_id" | "state" | "attributes"
>;
> & {
/** Area the entity is assigned to in the mocked entity registry */
area_id?: string;
};
/**
* The hass mock object interface, kept intentionally loose
+1
View File
@@ -299,6 +299,7 @@ export const provideHass = (
icon: undefined,
platform: "demo",
labels: [],
area_id: ent.areaId,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
+1 -5
View File
@@ -173,11 +173,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
window.addEventListener("location-changed", () => updateRoute());
// Handle history changes
if (useHash) {
window.addEventListener("hashchange", () => updateRoute());
} else {
window.addEventListener("popstate", () => updateRoute());
}
window.addEventListener("popstate", () => updateRoute());
// Handle clicking on links
window.addEventListener("click", (ev) => {
+50 -26
View File
@@ -13,8 +13,13 @@ export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
announceMessage?:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
action?: ToastActionParams;
secondaryAction?: ToastActionParams;
dismiss?: () => void;
duration?: number;
dismissable?: boolean;
@@ -23,8 +28,10 @@ export interface ShowToastParams {
export interface ToastActionParams {
action: () => void;
primary?: boolean;
text:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
@customElement("notification-manager")
@@ -93,31 +100,22 @@ class NotificationManager extends LitElement {
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${
this._parameters?.action
? html`
<ha-button
appearance="plain"
size="s"
slot="action"
@click=${this._buttonClicked}
>
${
typeof this._parameters?.action.text !== "string"
? this.hass.localize(
this._parameters?.action.text.translationKey,
this._parameters?.action.text.args
)
: this._parameters?.action.text
}
</ha-button>
`
: nothing
}
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
@@ -134,11 +132,37 @@ class NotificationManager extends LitElement {
`;
}
private _renderAction(
action: ToastActionParams | undefined,
secondary: boolean
) {
if (!action) {
return nothing;
}
return html`
<ha-button
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
>
${
typeof action.text !== "string"
? this.hass.localize(action.text.translationKey, action.text.args)
: action.text
}
</ha-button>
`;
}
private _buttonClicked() {
this._toast?.hide("action");
if (this._parameters?.action) {
this._parameters?.action.action();
}
this._parameters?.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked() {
+35 -1
View File
@@ -1,7 +1,8 @@
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { LitElement } from "lit";
import type { LitElement, PropertyValues } from "lit";
import { state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
@@ -17,6 +18,18 @@ export type CompareStrategy<State> =
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
const connectedDirtyStateProviders = new Map<object, boolean>();
const publishGlobalDirtyState = (): void => {
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
Boolean
);
if (isDirty !== window.isDirtyState) {
window.isDirtyState = isDirty;
fireEvent(window, "dirty-state-changed", { isDirty });
}
};
/**
* Mixin that provides dirty-state tracking via Lit context.
*
@@ -26,6 +39,9 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* Connected providers contribute to the global dirty state. It remains dirty
* while any provider has unsaved changes.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
@@ -118,6 +134,24 @@ export const DirtyStateProviderMixin =
this._dirtyStateContext = this._buildContextValue();
}
public connectedCallback(): void {
super.connectedCallback();
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
public disconnectedCallback(): void {
connectedDirtyStateProviders.delete(this);
publishGlobalDirtyState();
super.disconnectedCallback();
}
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
const slice = this._dirtySlices.get(key);
if (!slice) {
@@ -203,6 +203,7 @@ class DialogBackupOnboarding
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_step") && this._step === "key") {
this._save();
}
@@ -248,6 +248,24 @@ export class DialogEnergyBatterySettings
@value-changed=${this._statisticSocChanged}
></ha-statistic-picker>
<ha-input
.value=${
this._source.capacity != null ? String(this._source.capacity) : ""
}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.capacity"
)}
.hint=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.capacity_helper"
)}
type="number"
step="any"
min="0"
@input=${this._capacityChanged}
>
<span slot="end">kWh</span>
</ha-input>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@@ -346,6 +364,18 @@ export class DialogEnergyBatterySettings
});
}
private _capacityChanged(ev: InputEvent) {
const rawValue = (ev.target as HaInput).value;
const value = rawValue ? parseFloat(rawValue) : NaN;
this._source = {
...this._source!,
capacity: Number.isFinite(value) && value > 0 ? value : undefined,
};
if (this._source.capacity === undefined) {
delete this._source.capacity;
}
}
private async _save() {
try {
const source: BatterySourceTypeEnergyPreference = {
@@ -366,6 +396,10 @@ export class DialogEnergyBatterySettings
source.stat_soc = this._source!.stat_soc;
}
if (this._source!.capacity != null) {
source.capacity = this._source!.capacity;
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
@@ -384,7 +418,11 @@ export class DialogEnergyBatterySettings
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
ha-input {
margin-bottom: var(--ha-space-4);
--ha-input-padding-bottom: 0;
}
ha-input:last-of-type {
margin-bottom: 0;
}
`,
@@ -17,6 +17,7 @@ export type SankeyDeviceNode = Node & { parent?: string };
interface SmallConsumer {
id: string;
includedInStat: string | undefined;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
@@ -96,8 +97,15 @@ export const buildSankeyDeviceNodes = (
const deviceNodes: SankeyDeviceNode[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
const smallConsumerStats = new Set<string>();
let untrackedConsumption = initialUntracked;
// Parent chain in stat_consumption space, used to detect nested small consumers
const deviceParents = new Map<string, string | undefined>();
devices.forEach((device) => {
deviceParents.set(device.stat_consumption, device.included_in_stat);
});
devices.forEach((device, idx) => {
const id = getId(device);
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
@@ -115,11 +123,13 @@ export const buildSankeyDeviceNodes = (
}
smallConsumersByParent.get(parentKey)!.push({
id,
includedInStat: device.included_in_stat,
name: device.name,
value,
effectiveParent,
idx,
});
smallConsumerStats.add(device.stat_consumption);
return;
}
@@ -142,7 +152,21 @@ export const buildSankeyDeviceNodes = (
});
// Process small consumers - show a lone one directly, group clusters as "Other"
smallConsumersByParent.forEach((consumers, parentKey) => {
smallConsumersByParent.forEach((allConsumers, parentKey) => {
// A small consumer whose included_in_stat chain reaches another small
// consumer is already counted inside that ancestor's value - drop it so
// totals don't double-count nested devices.
const consumers = allConsumers.filter((consumer) => {
let ancestor = consumer.includedInStat;
for (let hops = 0; ancestor && hops < devices.length; hops++) {
if (smallConsumerStats.has(ancestor)) {
return false;
}
ancestor = deviceParents.get(ancestor);
}
return true;
});
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) {
return;
@@ -210,16 +210,37 @@ class HuiEnergyDistrubutionCard
// card's data when the selected period extends to now. For historical
// periods (yesterday, last week, ...) fall back to the generic icon.
if (periodIncludesNow(this._data)) {
const socValues = types
.battery!.map((source) =>
source.stat_soc
const socBatteries = types
.battery!.map((source) => ({
soc: source.stat_soc
? Number(this.hass.states[source.stat_soc]?.state)
: NaN
)
.filter((value) => Number.isFinite(value));
if (socValues.length) {
averageBatterySoc =
socValues.reduce((sum, value) => sum + value, 0) / socValues.length;
: NaN,
capacity: source.capacity,
}))
.filter((battery) => Number.isFinite(battery.soc));
if (socBatteries.length) {
// Weight each battery's SOC by its capacity so the combined value
// reflects the total stored energy. Batteries without a configured
// capacity assume the mean of the configured ones; when none are
// configured this falls back to an equally weighted (simple) average.
const configuredCapacities = socBatteries
.map((battery) => battery.capacity)
.filter((capacity) => capacity != null && capacity > 0) as number[];
const meanCapacity = configuredCapacities.length
? configuredCapacities.reduce((sum, value) => sum + value, 0) /
configuredCapacities.length
: 1;
let weightSum = 0;
let weightedSocSum = 0;
socBatteries.forEach((battery) => {
const capacity =
battery.capacity != null && battery.capacity > 0
? battery.capacity
: meanCapacity;
weightSum += capacity;
weightedSocSum += battery.soc * capacity;
});
averageBatterySoc = weightedSocSum / weightSum;
batteryIconPath = batteryLevelIconPath(averageBatterySoc);
}
}
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!changedProps.has("_badgeConfig")) {
return;
}
@@ -1,19 +1,27 @@
import { mdiSort, mdiStar, mdiStarOutline } from "@mdi/js";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { PreserveScrollPositionController } from "../../../../common/controllers/preserve-scroll-position-controller";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-ripple";
import "../../../../components/ha-spinner";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
import { saveFrontendUserData } from "../../../../data/frontend";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { CustomCardEntry } from "../../../../data/lovelace_custom_cards";
@@ -24,6 +32,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import {
calcUnusedEntities,
computeUsedEntities,
@@ -33,12 +42,18 @@ import type { LovelaceCard } from "../../types";
import { getCardStubConfig } from "../get-card-stub-config";
import { coreCards, energyCards } from "../lovelace-cards";
import type { Card, CardPickTarget } from "../types";
import { showReorderFavoriteCardsDialog } from "./show-reorder-favorite-cards-dialog";
interface CardElement {
card: Card;
element: TemplateResult;
}
const cardKey = (card: Card): string =>
card.isCustom ? `${CUSTOM_TYPE_PREFIX}${card.type}` : card.type;
const SPINNER = html`<div class="spinner"><ha-spinner></ha-spinner></div>`;
@customElement("hui-card-picker")
export class HuiCardPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -56,6 +71,8 @@ export class HuiCardPicker extends LitElement {
@state() private _cards: CardElement[] = [];
@state() private _favorites: string[] = [];
public lovelace?: LovelaceConfig;
public cardPicked?: (cardConf: LovelaceCardConfig) => void;
@@ -64,10 +81,23 @@ export class HuiCardPicker extends LitElement {
@query("ha-input-search") private _searchInput?: HaInputSearch;
@query("#content") private _content?: HTMLElement;
private _unusedEntities?: string[];
private _usedEntities?: string[];
private _suggestedCards: CardElement[] = [];
private _favoriteElements = new Map<string, CardElement>();
private _topSection = createRef<HTMLElement>();
public preserveScrollPosition = new PreserveScrollPositionController(
this,
this._topSection
);
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
@@ -99,18 +129,10 @@ export class HuiCardPicker extends LitElement {
}
);
private _suggestedCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) => cardElement.card.isSuggested
)
);
private _customCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
cardElement.card.isCustom && !cardElement.card.isSuggested
(cardElement: CardElement) => cardElement.card.isCustom
)
);
@@ -118,9 +140,7 @@ export class HuiCardPicker extends LitElement {
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
!cardElement.card.isSuggested &&
!cardElement.card.isCustom &&
!cardElement.card.isEnergy
!cardElement.card.isCustom && !cardElement.card.isEnergy
)
);
@@ -131,6 +151,34 @@ export class HuiCardPicker extends LitElement {
)
);
private _favoriteCards(): CardElement[] {
return this._favorites
.map((key) => this._favoriteElement(key))
.filter((cardElement): cardElement is CardElement => !!cardElement);
}
// A preview is a live DOM node, so every section a card shows up in needs
// its own element.
private _toCardElement(card: Card): CardElement {
return {
card,
element: html`${until(this._renderCardElement(card), SPINNER)}`,
};
}
private _favoriteElement(key: string): CardElement | undefined {
let cardElement = this._favoriteElements.get(key);
if (!cardElement) {
const card = this._cards.find((item) => cardKey(item.card) === key)?.card;
if (!card) {
return undefined;
}
cardElement = this._toCardElement(card);
this._favoriteElements.set(key, cardElement);
}
return cardElement;
}
protected render() {
if (
!this.hass ||
@@ -141,7 +189,8 @@ export class HuiCardPicker extends LitElement {
return nothing;
}
const suggestedCards = this._suggestedCards(this._cards);
const favoriteCards = this._favoriteCards();
const suggestedCards = favoriteCards.length > 0 ? [] : this._suggestedCards;
const othersCards = this._otherCards(this._cards);
const energyCardsItems = this._energyCards(this._cards);
const customCardsItems = this._customCards(this._cards);
@@ -160,53 +209,106 @@ export class HuiCardPicker extends LitElement {
this._filter
? html`<div class="cards-container">
${this._filterCards(this._cards, this._filter).map(
(cardElement: CardElement) => cardElement.element
(cardElement: CardElement) => this._renderCard(cardElement)
)}
</div>`
: html`
${
suggestedCards.length > 0
? html` <ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}
</div>
<div class="cards-container">
${this._renderClipboardCard()}
${suggestedCards.map(
(cardElement: CardElement) => cardElement.element
)}
</div>
</ha-expansion-panel>`
: nothing
}
<div ${ref(this._topSection)}>
${
favoriteCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.favorite_cards`
)}</span
>
${
favoriteCards.length > 1
? html`<ha-icon-button
class="reorder-favorites"
.path=${mdiSort}
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.reorder_favorites`
)}
@click=${this._reorderFavorites}
></ha-icon-button>`
: nothing
}
</div>
<div class="cards-container">
${this._renderClipboardCard(this._clipboard, this.hass!.locale)}
${repeat(
favoriteCards,
(cardElement: CardElement) =>
cardKey(cardElement.card),
(cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
${
suggestedCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}</span
>
</div>
<div class="cards-container">
${
favoriteCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${suggestedCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
</div>
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}</span
>
</div>
<div class="cards-container">
${
suggestedCards.length === 0
? this._renderClipboardCard()
favoriteCards.length === 0 && suggestedCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${othersCards.map(
(cardElement: CardElement) => cardElement.element
${othersCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
<ha-expansion-panel>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}</span
>
</div>
<div class="cards-container">
${energyCardsItems.map(
(cardElement: CardElement) => cardElement.element
${energyCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -215,13 +317,15 @@ export class HuiCardPicker extends LitElement {
? html`
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}</span
>
</div>
<div class="cards-container">
${customCardsItems.map(
(cardElement: CardElement) => cardElement.element
${customCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -254,16 +358,12 @@ export class HuiCardPicker extends LitElement {
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass) {
return true;
}
if (oldHass.locale !== this.hass!.locale) {
return true;
}
return false;
return !oldHass || oldHass.locale !== this.hass!.locale;
}
protected firstUpdated(): void {
@@ -283,16 +383,15 @@ export class HuiCardPicker extends LitElement {
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._favorites = this.hass.userData?.dashboard_favorite_card_types ?? [];
this._loadCards();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_filter")) {
const div = this.shadowRoot!.getElementById("content");
if (div) {
div.scrollTo({ behavior: "auto", top: 0 });
}
this._content?.scrollTo({ behavior: "auto", top: 0 });
}
}
@@ -308,19 +407,9 @@ export class HuiCardPicker extends LitElement {
...card,
}));
cards = cards.sort((a, b) => {
if (a.isSuggested && !b.isSuggested) {
return -1;
}
if (!a.isSuggested && b.isSuggested) {
return 1;
}
return stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
);
});
cards = cards.sort((a, b) =>
stringCompare(a.name || a.type, b.name || b.type, this.hass?.language)
);
cards = cards.concat(
energyCards.map((card: Card) => ({
@@ -354,48 +443,126 @@ export class HuiCardPicker extends LitElement {
)
);
}
this._cards = cards.map((card: Card) => ({
card: card,
element: html`${until(
this._renderCardElement(card),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`,
}));
this._cards = cards.map((card: Card) => this._toCardElement(card));
this._suggestedCards = cards
.filter((card: Card) => card.isSuggested)
.map((card: Card) => this._toCardElement(card));
}
private _renderClipboardCard() {
if (!this._clipboard) {
return nothing;
}
private _renderClipboardCard = memoizeOne(
(clipboard: LovelaceCardConfig | undefined, _locale: unknown) => {
if (!clipboard) {
return nothing;
}
return html` ${until(
this._renderCardElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
return html`<div class="card" tabindex="0">
${until(
this._renderCardElement(
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
type: clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
{
type: clipboard.type,
}
)}`,
},
clipboard
),
SPINNER
)}
<ha-ripple></ha-ripple>
</div>`;
}
);
private _renderCard(cardElement: CardElement): TemplateResult {
const key = cardKey(cardElement.card);
const favorite = this._favorites.includes(key);
return html`
<div class="card" tabindex="0">
${cardElement.element}
<ha-icon-button
class="favorite ${classMap({ selected: favorite })}"
.path=${favorite ? mdiStar : mdiStarOutline}
.label=${this.hass!.localize(
favorite
? "ui.panel.lovelace.editor.card.generic.remove_favorite"
: "ui.panel.lovelace.editor.card.generic.add_favorite"
)}
data-card=${key}
@click=${this._toggleFavorite}
@pointerdown=${stopPropagation}
></ha-icon-button>
<ha-ripple></ha-ripple>
</div>
`;
}
private _setFavorite(key: string, value: boolean): void {
const favorites = this._favorites.filter((favorite) => favorite !== key);
this._favorites = value ? [...favorites, key] : favorites;
}
private async _toggleFavorite(ev: Event): Promise<void> {
ev.stopPropagation();
const key = (ev.currentTarget as HTMLElement).dataset.card!;
const adding = !this._favorites.includes(key);
this._setFavorite(key, adding);
try {
await this._persistFavorites();
} catch (_err: any) {
this._setFavorite(key, !adding);
this._showSaveError();
}
}
private _reorderFavorites(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
showReorderFavoriteCardsDialog(this, {
favorites: this._favoriteCards().map((cardElement) => ({
key: cardKey(cardElement.card),
name: cardElement.card.name || cardElement.card.type,
})),
saveFavorites: (favorites) => this._saveFavorites(favorites),
});
}
private async _saveFavorites(favorites: string[]): Promise<void> {
const previous = this._favorites;
this._favorites = favorites;
try {
await this._persistFavorites();
} catch (_err: any) {
this._favorites = previous;
this._showSaveError();
}
}
private _persistFavorites(): Promise<void> {
return saveFrontendUserData(this.hass!.connection, "core", {
...this.hass!.userData,
dashboard_favorite_card_types: this._favorites,
});
}
private _showSaveError(): void {
showToast(this, {
message: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.favorite_save_failed"
),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`;
});
}
private _handleSearchChange(ev: Event) {
@@ -477,32 +644,29 @@ export class HuiCardPicker extends LitElement {
}
return html`
<div class="card" tabindex="0">
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
<ha-ripple></ha-ripple>
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
`;
}
@@ -531,6 +695,10 @@ export class HuiCardPicker extends LitElement {
}
.cards-container-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-width: 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
padding: var(--ha-space-3) var(--ha-space-2);
@@ -578,11 +746,55 @@ export class HuiCardPicker extends LitElement {
font-weight: var(--ha-font-weight-bold);
letter-spacing: -0.012em;
line-height: var(--ha-line-height-condensed);
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-3) var(--ha-space-11);
display: block;
text-align: center;
}
.cards-container-header .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorder-favorites {
flex: none;
margin-inline-start: auto;
/* Keeps the hit area without growing the header row */
margin-block: calc(-1 * var(--ha-space-1));
--ha-icon-button-size: var(--ha-space-8);
--mdc-icon-size: var(--ha-space-5);
color: var(--secondary-text-color);
}
.favorite {
position: absolute;
top: var(--ha-space-1);
inset-inline-end: var(--ha-space-1);
z-index: 2;
color: var(--secondary-text-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
--ha-icon-button-size: var(--ha-space-10);
}
.card:hover .favorite,
.card:focus-within .favorite,
.favorite.selected {
opacity: 1;
}
.favorite.selected {
color: var(--primary-color);
}
@media (hover: none) {
.favorite {
opacity: 1;
}
}
.preview {
pointer-events: none;
margin: var(--ha-space-5);
@@ -602,6 +814,8 @@ export class HuiCardPicker extends LitElement {
}
.spinner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
@@ -622,23 +836,6 @@ export class HuiCardPicker extends LitElement {
max-width: none;
grid-column: 1 / -1;
}
.icon {
position: absolute;
top: var(--ha-space-2);
right: var(--ha-space-2);
inset-inline-start: var(--ha-space-2);
inset-inline-end: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
--mdc-icon-size: var(--ha-space-4);
line-height: var(--ha-space-4);
box-sizing: border-box;
color: var(--text-primary-color);
padding: var(--ha-space-1);
}
.icon.custom {
background: var(--warning-color);
}
`,
];
}
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
return;
}
@@ -0,0 +1,136 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
FavoriteCardItem,
ReorderFavoriteCardsDialogParams,
} from "./show-reorder-favorite-cards-dialog";
@customElement("hui-dialog-reorder-favorite-cards")
export class HuiDialogReorderFavoriteCards extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ReorderFavoriteCardsDialogParams;
@state() private _open = false;
@state() private _favorites: FavoriteCardItem[] = [];
public async showDialog(
params: ReorderFavoriteCardsDialogParams
): Promise<void> {
this._params = params;
this._favorites = params.favorites;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._favorites = [];
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.reorder_favorites"
)}
@closed=${this._dialogClosed}
>
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="favorites">
${repeat(
this._favorites,
(favorite) => favorite.key,
(favorite) => html`
<div class="favorite">
<div class="handle">
<ha-svg-icon
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="name">${favorite.name}</span>
</div>
`
)}
</div>
</ha-sortable>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const favorites = [...this._favorites];
const [moved] = favorites.splice(oldIndex, 1);
favorites.splice(newIndex, 0, moved);
this._favorites = favorites;
this._params!.saveFavorites(favorites.map((favorite) => favorite.key));
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.favorites {
display: flex;
flex-direction: column;
}
.favorite {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-height: var(--ha-space-12);
}
.handle {
display: flex;
align-items: center;
cursor: grab;
padding: var(--ha-space-2);
color: var(--secondary-text-color);
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-reorder-favorite-cards": HuiDialogReorderFavoriteCards;
}
}
@@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface FavoriteCardItem {
key: string;
name: string;
}
export interface ReorderFavoriteCardsDialogParams {
favorites: FavoriteCardItem[];
saveFavorites: (favorites: string[]) => void;
}
export const importReorderFavoriteCardsDialog = () =>
import("./hui-dialog-reorder-favorite-cards");
export const showReorderFavoriteCardsDialog = (
element: HTMLElement,
reorderFavoriteCardsDialogParams: ReorderFavoriteCardsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-reorder-favorite-cards",
dialogImport: importReorderFavoriteCardsDialog,
dialogParams: reorderFavoriteCardsDialogParams,
});
};
@@ -76,6 +76,7 @@ export class HuiDialogEditSection
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const sectionConfig = {
...this._config,
@@ -94,6 +94,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const viewConfig = {
...this._config,
@@ -43,6 +43,7 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -44,6 +44,7 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -107,7 +107,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
.value=${stateObj.state}
.value=${Number(stateObj.state).toString()}
type="number"
@change=${this._selectedValueChanged}
>
+1
View File
@@ -104,6 +104,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
if (
!this._saving &&
@@ -2,6 +2,7 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import {
findEntities,
generateEntityFilter,
@@ -107,7 +108,24 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const entitiesWithoutAreas = findEntities(allEntities, otherDevicesFilters);
const primaryFilter = generateEntityFilter(hass, {
entity_category: "none",
});
// Only show the devices tile if the other devices view has content: it
// only renders area-less primary entities that belong to a device.
const hasOtherDevices = allEntities.some(
(entityId) =>
otherDevicesFilters.some((filter) => filter(entityId)) &&
primaryFilter(entityId) &&
!!getEntityContext(
hass.states[entityId],
hass.entities,
hass.devices,
hass.areas,
hass.floors
).device
);
const floorsSections: LovelaceSectionConfig[] = [];
for (const floorStructure of home.floors) {
@@ -140,13 +158,13 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
}
if (home.areas.length > 0 || entitiesWithoutAreas.length > 0) {
if (home.areas.length > 0 || hasOtherDevices) {
const cards: LovelaceCardConfig[] = [];
for (const areaId of home.areas) {
cards.push(computeAreaCard(areaId, hass));
}
if (entitiesWithoutAreas.length > 0) {
if (hasOtherDevices) {
cards.push({
type: "tile",
entity: "zone.home", // zone entity to represent unassigned area as it always exists
@@ -101,8 +101,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const columns = Math.floor(
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
);
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
return clamp(columns, 1, maxColumns);
return Math.max(columns, 1);
},
});
@@ -155,7 +154,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _updateMaxColumnCount(): void {
const maxColumnCount = this._columnsController.value ?? 1;
// The column count is clamped here instead of in the resize callback, so
// that it follows the config of the view the element is currently used for
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
const maxColumnCount = clamp(
this._columnsController.value ?? 1,
1,
maxColumns
);
if (maxColumnCount !== this._maxColumns) {
this._maxColumns = maxColumnCount;
+16 -3
View File
@@ -1193,6 +1193,11 @@
"play": "Play",
"play-media": "Play media",
"pick-media": "Pick media",
"search": {
"search_placeholder": "Search {name}",
"no_results": "No results found"
},
"filter_media_type": "Filter by media type",
"no_items": "No items",
"not_shown": "{count} {count, plural,\n one {item}\n other {items}\n} incompatible with the currently selected player {count, plural,\n one {is}\n other {are}\n} hidden",
"choose_player": "Choose player",
@@ -2510,8 +2515,8 @@
"triggered": "Triggered {name}",
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload",
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
"update_now": "Update now",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
"onboarding_survey": {
@@ -4244,6 +4249,8 @@
"display_name": "[%key:ui::panel::config::energy::device_consumption::dialog::display_name%]",
"state_of_charge": "Battery state of charge sensor",
"state_of_charge_helper": "Sensor reporting battery state of charge as %.",
"capacity": "Battery capacity",
"capacity_helper": "Usable battery capacity in kWh. Used to weight the combined state of charge when you have multiple batteries of different sizes.",
"power": "Battery power",
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery.",
"sensor_type": "Type of power measurement",
@@ -5669,7 +5676,7 @@
},
"trigger": {
"label": "Triggered by",
"no_triggers": "There are no triggers with ID's set in this automation. Edit a trigger and give it a Trigger ID name.",
"no_triggers": "There are no triggers with IDs set in this automation. Edit a trigger and give it a Trigger ID name.",
"id": "Trigger",
"description": {
"picker": "Tests if the automation has been triggered by a specific trigger.",
@@ -10056,6 +10063,11 @@
"core_cards": "Core cards",
"energy_cards": "Energy cards",
"custom_cards": "Community cards",
"favorite_cards": "Favorite cards",
"add_favorite": "Add to favorites",
"remove_favorite": "Remove from favorites",
"reorder_favorites": "Reorder favorites",
"favorite_save_failed": "Failed to save favorite cards",
"round_temperature": "Round temperature",
"features": "Features",
"actions": "Actions",
@@ -11252,6 +11264,7 @@
"cards": {
"demo": {
"demo_by": "by {name}",
"interactive_demo": "Interactive demo",
"next_demo": "Next demo",
"introduction": "Welcome home! You've reached the Home Assistant demo where we showcase the best UIs created by our community.",
"learn_more": "Learn more about Home Assistant"
+94 -16
View File
@@ -1,5 +1,8 @@
import { showToast } from "./toast";
const UPDATE_DELAY = 60_000;
const UPDATE_TOAST_ID = "frontend-update-available";
export const supportsServiceWorker = () =>
"serviceWorker" in navigator &&
(location.protocol === "https:" || location.hostname === "localhost");
@@ -23,6 +26,92 @@ export const registerServiceWorker = async (
return;
}
let pendingWorker: ServiceWorker | undefined;
let updateDeadline = 0;
let updateInterval = 0;
const hideUpdateToast = () => {
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: "",
duration: 0,
});
};
const clearUpdateCountdown = () => {
clearInterval(updateInterval);
};
const activateUpdate = () => {
if (!pendingWorker) {
return;
}
clearUpdateCountdown();
hideUpdateToast();
pendingWorker.postMessage({ type: "skipWaiting" });
pendingWorker = undefined;
};
const showUpdateToast = () => {
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
if (seconds <= 0) {
activateUpdate();
return;
}
const announceSeconds =
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: announceSeconds },
},
action: {
action: activateUpdate,
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
action: () => {
pendingWorker = undefined;
clearUpdateCountdown();
},
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
};
const startUpdateCountdown = () => {
updateDeadline = Date.now() + UPDATE_DELAY;
showUpdateToast();
updateInterval = window.setInterval(showUpdateToast, 1000);
};
window.addEventListener("dirty-state-changed", () => {
if (!pendingWorker) {
return;
}
if (window.isDirtyState) {
clearUpdateCountdown();
hideUpdateToast();
return;
}
startUpdateCountdown();
});
const updateReady = (worker: ServiceWorker) => {
clearUpdateCountdown();
pendingWorker = worker;
if (!window.isDirtyState) {
startUpdateCountdown();
}
};
reg.addEventListener("updatefound", () => {
const installingWorker = reg.installing;
@@ -37,22 +126,11 @@ export const registerServiceWorker = async (
) {
return;
}
// Notify users a new frontend is available.
showToast(rootEl, {
message: {
translationKey: "ui.notification_toast.new_version_available",
},
action: {
// We tell the service worker to call skipWaiting, which activates
// the new service worker. Above we listen for `controllerchange`
// so we reload the page once a new service worker activates.
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
text: { translationKey: "ui.notification_toast.reload" },
},
duration: -1,
dismissable: false,
});
updateReady(installingWorker);
});
});
if (reg.waiting && navigator.serviceWorker.controller) {
updateReady(reg.waiting);
}
};
+16
View File
@@ -32,6 +32,22 @@ describe("downSampleLineData", () => {
expect(downSampleLineData(points, 100)).toBe(points);
});
it("returns input unchanged when maxDetails is zero", () => {
const points = generatePoints(11, 720);
expect(downSampleLineData(points, 0)).toBe(points);
expect(
downSampleLineData(points, 0, points[0][0], points[points.length - 1][0])
).toBe(points);
});
it("returns input unchanged when all points share the same x", () => {
const points: [number, number][] = Array.from({ length: 20 }, (_, i) => [
FIXED_EPOCH_MS,
i,
]);
expect(downSampleLineData(points, 5)).toBe(points);
});
it("skips points with non-finite coordinates", () => {
const points = generatePoints(2, 200);
points[10] = [points[10][0], NaN];
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaToast } from "../../src/components/ha-toast";
import "../../src/components/ha-toast";
let toast: HaToast | undefined;
const mountToast = async (properties: Partial<HaToast> = {}) => {
toast = document.createElement("ha-toast");
Object.assign(toast, properties);
document.body.append(toast);
await toast.updateComplete;
return toast;
};
afterEach(() => {
toast?.remove();
toast = undefined;
vi.useRealTimers();
});
const showToast = async (element: HaToast) => {
vi.useFakeTimers();
const shown = element.show();
await element.updateComplete;
Object.defineProperty(
element.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await shown;
};
describe("ha-toast", () => {
it("renders its message and bottom offset", async () => {
const element = await mountToast({
labelText: "Configuration saved",
bottomOffset: 24,
});
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
"Configuration saved"
);
expect(
(
element.shadowRoot?.querySelector(".toast") as HTMLElement
).style.getPropertyValue("--ha-toast-bottom-offset")
).toBe("24px");
});
it("renders assigned action and dismiss content", async () => {
toast = document.createElement("ha-toast");
const action = document.createElement("button");
action.slot = "action";
const dismiss = document.createElement("button");
dismiss.slot = "dismiss";
toast.append(action, dismiss);
document.body.append(toast);
await toast.updateComplete;
toast.requestUpdate();
await toast.updateComplete;
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="action"]')
?.assignedElements()
).toEqual([action]);
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
?.assignedElements()
).toEqual([dismiss]);
expect(
toast.shadowRoot
?.querySelector(".actions")
?.classList.contains("has-action")
).toBe(true);
});
it("keeps changing visual text separate from live-region text", async () => {
const element = await mountToast({
labelText: "Updating in 59 seconds",
announceText: "Updating in 60 seconds",
});
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
expect(visibleMessage.closest('[role="status"]')).toBeNull();
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
expect(liveRegion.getAttribute("role")).toBe("status");
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
});
it("falls back to announcing the visible message", async () => {
const element = await mountToast({ labelText: "Configuration saved" });
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.textContent?.trim()
).toBe("Configuration saved");
});
it("activates its live region while shown", async () => {
const element = await mountToast();
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(liveRegion.getAttribute("aria-live")).toBe("off");
await showToast(element);
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await element.hide(reason);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
expect(
element
.shadowRoot!.querySelector(".toast")!
.classList.contains("active")
).toBe(false);
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.getAttribute("aria-live")
).toBe("off");
}
);
it("closes with a timeout reason", async () => {
const element = await mountToast({ timeoutMs: 1000 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(1000);
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
});
it("does not close automatically when the timeout is disabled", async () => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(10_000);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit a close event when already hidden", async () => {
const element = await mountToast();
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await element.hide("dismiss");
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
const localize = (key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key;
@customElement("test-notification-lifecycle-host")
class TestNotificationLifecycleHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
}
}
let host: TestNotificationLifecycleHost | undefined;
afterEach(() => {
host?.remove();
host = undefined;
vi.useRealTimers();
});
describe("notification toast lifecycle", () => {
it("updates a visible toast and closes it after its primary action", async () => {
vi.useFakeTimers();
host = document.createElement("test-notification-lifecycle-host");
document.body.append(host);
await host.updateComplete;
const manager = host.shadowRoot!.querySelector("notification-manager")!;
const action = vi.fn();
const firstShow = manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
await Promise.resolve();
await manager.updateComplete;
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
Object.defineProperty(
toast.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await firstShow;
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
"visible"
);
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":60'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
await manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":59'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
const closed = new Promise<void>((resolve) => {
toast.addEventListener("toast-closed", () => resolve(), { once: true });
});
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
await closed;
await manager.updateComplete;
expect(action).toHaveBeenCalledOnce();
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
+245
View File
@@ -0,0 +1,245 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key
);
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-toast", () => {
customElements.define(
"ha-toast",
class extends HTMLElement {
public show = vi.fn();
public hide = vi.fn();
}
);
return {};
});
@customElement("test-notification-manager-host")
class TestNotificationManagerHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-manager-host": TestNotificationManagerHost;
}
}
let host: TestNotificationManagerHost | undefined;
const mountManager = async () => {
host = document.createElement(
"test-notification-manager-host"
) as TestNotificationManagerHost;
document.body.append(host);
await host.updateComplete;
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
{ duration: 1, expected: 4000 },
{ duration: 4000, expected: 4000 },
{ duration: 4001, expected: 4001 },
{ duration: -1, expected: -1 },
])(
"normalizes a $duration ms duration to $expected ms",
async ({ duration, expected }) => {
const manager = await mountManager();
await manager.showDialog({ message: "Message", duration });
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
expected
);
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
await manager.showDialog({
message: "Update available",
action: { action: primary, primary: true, text: "Update now" },
secondaryAction: { action: secondary, text: "Cancel" },
duration: -1,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(secondary).toHaveBeenCalledOnce();
expect(primary).not.toHaveBeenCalled();
buttons[1].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(primary).toHaveBeenCalledOnce();
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "status", message: "Second" });
expect(toast.hide).not.toHaveBeenCalled();
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Message" });
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
new CustomEvent("toast-closed", {
detail: { reason: "programmatic" },
})
);
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
@@ -0,0 +1,368 @@
import { consume } from "@lit/context";
import { LitElement } from "lit";
import { state } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../src/data/context/dirty-state";
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
interface TestState {
name: string;
enabled: boolean;
options?: { mode: string };
}
class TestDirtyStateConsumer extends LitElement {
@consume({ context: dirtyStateContext, subscribe: true })
@state()
public dirtyState?: DirtyStateContext;
}
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
LitElement
) {
public initialize(
strategy: CompareStrategy<TestState>,
value?: TestState,
normalize?: (value: TestState) => TestState
) {
this._initDirtyTracking(strategy, value, normalize);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
public discardChanges() {
this._discardDirtyStateChanges();
}
}
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
declare global {
interface HTMLElementTagNameMap {
"test-dirty-state-consumer": TestDirtyStateConsumer;
"test-dirty-state-provider": TestDirtyStateProvider;
}
}
let provider: TestDirtyStateProvider | undefined;
const mountProvider = async () => {
provider = document.createElement(
"test-dirty-state-provider"
) as TestDirtyStateProvider;
document.body.append(provider);
await provider.updateComplete;
return provider;
};
afterEach(() => {
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
element.remove();
});
provider = undefined;
window.isDirtyState = false;
});
describe("DirtyStateProviderMixin", () => {
it("tracks changes against an initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("marks the current state as clean", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
element.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("discards changes back to the initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: true });
await element.updateComplete;
element.discardChanges();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("uses deep comparison for nested values", async () => {
const element = await mountProvider();
element.initialize(
{ type: "deep" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "manual" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("uses reference comparison for nested values with a shallow strategy", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("supports a custom comparison strategy", async () => {
const element = await mountProvider();
const compare = vi.fn(
(initial: TestState, current: TestState) =>
initial.name.toLowerCase() === current.name.toLowerCase()
);
element.initialize(
{ type: "custom", compare },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "KITCHEN", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(compare).toHaveBeenCalled();
});
it("keeps the deep baseline isolated from initial state mutations", async () => {
const element = await mountProvider();
const initial = {
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
};
element.initialize({ type: "deep" }, initial);
initial.options.mode = "manual";
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("uses the first deferred value as its baseline", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("tracks raw and effective dirty state separately", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false },
(value) => ({ ...value, enabled: false })
);
element.setValue({ name: "Kitchen", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("publishes dirty and clean transitions with event details", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
listener.mockClear();
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(listener).toHaveBeenCalledTimes(2);
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
expect(window.isDirtyState).toBe(false);
window.removeEventListener("dirty-state-changed", listener);
});
it("clears published dirty state when disconnected", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
listener.mockClear();
element.remove();
expect(window.isDirtyState).toBe(false);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
window.removeEventListener("dirty-state-changed", listener);
});
it("hands global dirty state to the next provider after disconnecting", async () => {
const firstProvider = await mountProvider();
firstProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
firstProvider.setValue({ name: "Bedroom", enabled: false });
await firstProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
firstProvider.remove();
expect(window.isDirtyState).toBe(false);
const nextProvider = await mountProvider();
nextProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
nextProvider.setValue({ name: "Hallway", enabled: true });
await nextProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
});
it("stays globally dirty while any connected provider is dirty", async () => {
const dirtyProvider = await mountProvider();
dirtyProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
await dirtyProvider.updateComplete;
const cleanProvider = document.createElement("test-dirty-state-provider");
document.body.append(cleanProvider);
cleanProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
await cleanProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
cleanProvider.remove();
expect(window.isDirtyState).toBe(true);
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
await dirtyProvider.updateComplete;
expect(window.isDirtyState).toBe(false);
});
it("restores a provider's dirty state after reconnecting", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.remove();
expect(window.isDirtyState).toBe(false);
document.body.append(element);
expect(window.isDirtyState).toBe(true);
});
it("tracks independent context slices and marks all of them clean", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
const consumer = document.createElement("test-dirty-state-consumer");
element.append(consumer);
await consumer.updateComplete;
const setState = (value: TestState, key: "first" | "second") => {
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
};
setState({ name: "Kitchen", enabled: false }, "first");
setState({ name: "Bedroom", enabled: false }, "second");
await element.updateComplete;
setState({ name: "Kitchen light", enabled: false }, "first");
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
consumer.dirtyState!.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
});
@@ -231,6 +231,96 @@ describe("buildSankeyDeviceNodes", () => {
// the top-level parent (10), never the cluster total.
expect(result.untrackedConsumption).toBe(0);
});
it("counts a nested small device only once (via its small ancestor)", () => {
// child's consumption is already included in parent's statistic
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "parent" },
{ stat_consumption: "child", included_in_stat: "parent" }
),
values: { parent: 0.008, child: 0.006 },
initialUntracked: 10,
})
);
// Only the parent remains, so it is shown directly instead of "Other"
expect(result.deviceNodes.map((n) => n.id)).toEqual(["parent"]);
expect(result.deviceNodes[0].value).toBeCloseTo(0.008, 10);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("excludes nested small devices from the Other total", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "parent" },
{ stat_consumption: "child", included_in_stat: "parent" },
{ stat_consumption: "unrelated" }
),
values: { parent: 0.004, child: 0.003, unrelated: 0.002 },
initialUntracked: 10,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_home");
// parent + unrelated only; child is inside parent's value
expect(other?.value).toBeCloseTo(0.006, 10);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.006, 10);
});
it("keeps only the top ancestor of a nested small-device chain", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "grandparent" },
{ stat_consumption: "parent", included_in_stat: "grandparent" },
{ stat_consumption: "child", included_in_stat: "parent" }
),
values: { grandparent: 0.005, parent: 0.004, child: 0.003 },
initialUntracked: 10,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual(["grandparent"]);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.005, 10);
});
it("detects a small ancestor across a device the card skips entirely", () => {
// Power-card scenario: "outlet" has no stat_rate so it is never rendered
// or collected, but the plug's consumption still flows through it into
// the heater group. Requires walking the chain, not a direct-parent check.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "heater_group" },
{ stat_consumption: "outlet", included_in_stat: "heater_group" },
{ stat_consumption: "plug", included_in_stat: "outlet" }
),
values: { heater_group: 0.008, plug: 0.005 },
initialUntracked: 10,
getId: (device) =>
device.stat_consumption === "outlet"
? undefined
: device.stat_consumption,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual(["heater_group"]);
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", included_in_stat: "b" },
{ stat_consumption: "b", included_in_stat: "a" }
),
values: { a: 0.003, b: 0.004 },
initialUntracked: 10,
})
);
expect(result.deviceNodes).toEqual([]);
expect(result.untrackedConsumption).toBe(10);
});
});
describe("groupSankeyDevicesByFloorAndArea", () => {
+341
View File
@@ -0,0 +1,341 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ShowToastParams } from "../../src/managers/notification-manager";
import {
registerServiceWorker,
supportsServiceWorker,
} from "../../src/util/register-service-worker";
class FakeWorker extends EventTarget {
public state: ServiceWorkerState = "installing";
public postMessage = vi.fn();
}
class FakeRegistration extends EventTarget {
public installing: ServiceWorker | null = null;
public waiting: ServiceWorker | null = null;
}
class FakeServiceWorkerContainer extends EventTarget {
public controller: ServiceWorker | null = null;
public register = vi.fn();
}
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"serviceWorker"
);
const restoreServiceWorker = () => {
if (serviceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
} else {
Reflect.deleteProperty(navigator, "serviceWorker");
}
};
describe("supportsServiceWorker", () => {
afterEach(restoreServiceWorker);
it("returns true when the service worker API is available", () => {
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: new FakeServiceWorkerContainer(),
});
expect(supportsServiceWorker()).toBe(true);
});
it("returns false when the service worker API is unavailable", () => {
Reflect.deleteProperty(navigator, "serviceWorker");
expect(supportsServiceWorker()).toBe(false);
});
});
describe("registerServiceWorker", () => {
let root: HTMLElement;
let worker: FakeWorker;
let registration: FakeRegistration;
let serviceWorker: FakeServiceWorkerContainer;
let notifications: ShowToastParams[];
const latestNotification = () => notifications[notifications.length - 1];
const setDirtyState = (isDirty: boolean) => {
window.isDirtyState = isDirty;
window.dispatchEvent(
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
);
};
const installUpdate = () => {
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "installed";
worker.dispatchEvent(new Event("statechange"));
};
beforeEach(() => {
vi.useFakeTimers();
globalThis.__BUILD__ = "modern";
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
window.isDirtyState = false;
notifications = [];
root = document.createElement("div");
root.addEventListener("hass-notification", (event) => {
notifications.push((event as CustomEvent<ShowToastParams>).detail);
});
worker = new FakeWorker();
registration = new FakeRegistration();
serviceWorker = new FakeServiceWorkerContainer();
serviceWorker.controller = worker as unknown as ServiceWorker;
serviceWorker.register.mockResolvedValue(registration);
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
});
afterEach(() => {
latestNotification()?.secondaryAction?.action();
vi.clearAllTimers();
vi.useRealTimers();
window.isDirtyState = false;
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
restoreServiceWorker();
});
it("registers the worker for the current build", async () => {
await registerServiceWorker(root, false);
expect(serviceWorker.register).toHaveBeenCalledOnce();
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
});
it("does not register when service workers are unsupported", async () => {
Reflect.deleteProperty(navigator, "serviceWorker");
await registerServiceWorker(root);
expect(serviceWorker.register).not.toHaveBeenCalled();
});
it("listens for controller changes before registering", async () => {
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
await registerServiceWorker(root, false);
expect(addEventListener).toHaveBeenCalledWith(
"controllerchange",
expect.any(Function)
);
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
serviceWorker.register.mock.invocationCallOrder[0]
);
});
it.each([
{ notifyUpdate: false, dev: false, demo: false },
{ notifyUpdate: true, dev: true, demo: false },
{ notifyUpdate: true, dev: false, demo: true },
])(
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
async ({ notifyUpdate, dev, demo }) => {
globalThis.__DEV__ = dev;
globalThis.__DEMO__ = demo;
const addEventListener = vi.spyOn(registration, "addEventListener");
await registerServiceWorker(root, notifyUpdate);
expect(addEventListener).not.toHaveBeenCalledWith(
"updatefound",
expect.any(Function)
);
}
);
it("ignores update discovery without an installing worker", async () => {
await registerServiceWorker(root);
registration.dispatchEvent(new Event("updatefound"));
expect(notifications).toHaveLength(0);
});
it("waits for an installing worker to reach the installed state", async () => {
await registerServiceWorker(root);
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "activating";
worker.dispatchEvent(new Event("statechange"));
expect(notifications).toHaveLength(0);
});
it("ignores an initial installation without an existing controller", async () => {
serviceWorker.controller = null;
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
});
it("starts at 60 seconds when an update is installed", async () => {
await registerServiceWorker(root);
installUpdate();
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
args: { seconds: 60 },
},
action: {
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
});
it("uses an already waiting worker", async () => {
registration.waiting = worker as unknown as ServiceWorker;
await registerServiceWorker(root);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("ignores a waiting worker without an existing controller", async () => {
registration.waiting = worker as unknown as ServiceWorker;
serviceWorker.controller = null;
await registerServiceWorker(root);
expect(notifications).toHaveLength(0);
});
it.each([
{ elapsed: 1_000, visual: 59, announced: 60 },
{ elapsed: 20_000, visual: 40, announced: 40 },
{ elapsed: 40_000, visual: 20, announced: 20 },
{ elapsed: 55_000, visual: 5, announced: 5 },
{ elapsed: 59_000, visual: 1, announced: 5 },
])(
"shows $visual seconds and announces $announced after $elapsed ms",
async ({ elapsed, visual, announced }) => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(elapsed);
expect(latestNotification().message).toMatchObject({
args: { seconds: visual },
});
expect(latestNotification().announceMessage).toMatchObject({
args: { seconds: announced },
});
}
);
it("activates and hides the update when the countdown finishes", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: "",
duration: 0,
});
});
it("activates and hides the update immediately on request", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().action!.action();
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
});
it("cancels automatic activation", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().secondaryAction!.action();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
});
it("defers the message and activation while the page is dirty", async () => {
setDirtyState(true);
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("hides and resets the countdown when the page becomes dirty", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(10_000);
setDirtyState(true);
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("uses the latest installed worker", async () => {
await registerServiceWorker(root);
installUpdate();
const replacement = new FakeWorker();
replacement.state = "installed";
registration.installing = replacement as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
replacement.dispatchEvent(new Event("statechange"));
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
expect(replacement.postMessage).toHaveBeenCalledWith({
type: "skipWaiting",
});
});
});
+25 -25
View File
@@ -7671,10 +7671,10 @@ __metadata:
languageName: node
linkType: hard
"color-name@npm:2.1.0":
version: 2.1.0
resolution: "color-name@npm:2.1.0"
checksum: 10/eb014f71d87408e318e95d3f554f188370d354ba8e0ffa4341d0fd19de391bfe2bc96e563d4f6614644d676bc24f475560dffee3fe310c2d6865d007410a9a2b
"color-name@npm:2.1.1":
version: 2.1.1
resolution: "color-name@npm:2.1.1"
checksum: 10/17bbb03a1e64299e5f61ccc514c502baf66980b88d013ca0161e8c0009edc3813ab3ffc8558ae99b8633d23c2e94b6551a248015b86cb5f3a84b645b4d0f3bb5
languageName: node
linkType: hard
@@ -9338,14 +9338,14 @@ __metadata:
languageName: node
linkType: hard
"fs-extra@npm:11.3.6, fs-extra@npm:^11.1.1":
version: 11.3.6
resolution: "fs-extra@npm:11.3.6"
"fs-extra@npm:11.4.0, fs-extra@npm:^11.1.1":
version: 11.4.0
resolution: "fs-extra@npm:11.4.0"
dependencies:
graceful-fs: "npm:^4.2.0"
jsonfile: "npm:^6.0.1"
universalify: "npm:^2.0.0"
checksum: 10/34a981697b199002ea3b85c3483bcf353637d33ec4c922b136a8d8a597faa0495638d69b20c181448be041c31778c2bb7f7799d589819edf33da50f75b1abde9
checksum: 10/a1bd91076fe9de9114bd037a433f1f498857eb3f0cf778e647fd00dc5984374dee5e088854de3d6ca77cd9f11554a85f6f016a6004d0838bbc7cd722e33601ce
languageName: node
linkType: hard
@@ -9986,7 +9986,7 @@ __metadata:
barcode-detector: "npm:3.2.1"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
color-name: "npm:2.1.0"
color-name: "npm:2.1.1"
comlink: "npm:4.4.2"
core-js: "npm:3.49.0"
cropperjs: "npm:1.6.2"
@@ -10007,7 +10007,7 @@ __metadata:
eslint-plugin-unused-imports: "npm:4.4.1"
eslint-plugin-wc: "npm:3.1.0"
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.3.6"
fs-extra: "npm:11.4.0"
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
@@ -10023,14 +10023,14 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.12"
js-yaml: "npm:5.2.1"
js-yaml: "npm:5.2.2"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.1.0"
lint-staged: "npm:17.2.0"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -10056,7 +10056,7 @@ __metadata:
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.20"
tar: "npm:7.5.21"
terser-webpack-plugin: "npm:5.6.1"
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
@@ -10957,14 +10957,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.2.1":
version: 5.2.1
resolution: "js-yaml@npm:5.2.1"
"js-yaml@npm:5.2.2":
version: 5.2.2
resolution: "js-yaml@npm:5.2.2"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/e1eca2d21c15572585bb236d9fde31d6789eb50b9c63e8753fa7e0777bc480f7521cad517bd7a0c66f27dfc27ddcd7100beeefa51c1a50e10e98f2e009633c3d
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
languageName: node
linkType: hard
@@ -11423,9 +11423,9 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.1.0":
version: 17.1.0
resolution: "lint-staged@npm:17.1.0"
"lint-staged@npm:17.2.0":
version: 17.2.0
resolution: "lint-staged@npm:17.2.0"
dependencies:
picomatch: "npm:^4.0.5"
string-argv: "npm:^0.3.2"
@@ -11436,7 +11436,7 @@ __metadata:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
checksum: 10/7c26129d6dd27f20d8fc8d70b13252f15baf569124721d7f5e9ed289e054ed43f5cc2a35de4129b65a7c7f1ad4a5900859a7488f8a1f0b96428b995b29ec2ba8
languageName: node
linkType: hard
@@ -14501,16 +14501,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.20
resolution: "tar@npm:7.5.20"
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.21
resolution: "tar@npm:7.5.21"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
languageName: node
linkType: hard