Compare commits

..

48 Commits

Author SHA1 Message Date
Petar Petrov 7e43c1fc81 Fix gauge value text missing when revealed by a visibility condition 2026-07-27 09:17:12 +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
renovate[bot] 9d96d0b4aa Update dependency @rspack/core to v2.1.5 (#53276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 13:07:05 +00:00
Paulus Schoutsen d900cc70c5 Fix unhandled error when the Cast SDK fails to load (#53271)
* Fix unhandled error when the Cast SDK fails to load

Resolve the Cast API as unavailable when the cast_sender.js script
fails to load (offline, blocked by an ad-blocker or firewall), instead
of leaking an unhandled promise rejection. Handle the resulting
getCastManager rejection in the demo cast row, matching hui-cast-row,
so the row hides itself.

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

* Drop redundant comment

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

* Simplify promise memoization with ??=

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

* Revert "Simplify promise memoization with ??="

This reverts commit 5527741b6a.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 13:01:46 +00:00
pcan08 837baff26c Fix My Backups navigation (#53259) 2026-07-24 14:57:41 +02:00
Petar Petrov 43b4e247af Fix tile trend-graph feature not extending unchanging values to now (#53265)
Extend tile trend-graph feature to current time for unchanged sensors
2026-07-24 12:52:06 +02:00
renovate[bot] 17def3c5ee Update dependency prettier to v3.9.6 (#53267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 12:46:59 +02:00
renovate[bot] ba15879662 Update dependency @rsdoctor/rspack-plugin to v1.6.1 (#53266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 12:46:53 +02:00
renovate[bot] 0046f72641 Update dependency marked to v18.0.7 (#53263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 05:41:12 +00:00
renovate[bot] f66ba11705 Update dependency typescript-eslint to v8.65.0 (#53262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 08:30:42 +03:00
renovate[bot] bded4341df Update dependency sinon to v22.1.0 (#53261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 08:30:21 +03:00
Bram Kragten 7a6091b5e2 Surface active HTTP config slot and reverted pending state (#53166) 2026-07-23 17:59:51 +02:00
Aidan Timson 6d12672c3e Update skill guidance, move gallery to skill (#53249)
* Correct frontend component and context guidance

* Clarify frontend benchmark requirements

* Move gallery guidance into a skill

* Clarify gallery guidance ownership

* Correct context API ownership
2026-07-23 17:47:02 +03:00
Aidan Timson 495562d748 Prepare copilot environment (#53241)
* Prepare Copilot build and test environment

* Cache Playwright browsers for Copilot

* Cache deps

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

* Apply suggestions from code review

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

* Remove indefinite playwright installs

* Spacing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 17:03:18 +03:00
Paul Bottein 5cf7fb3a3d Fix inverted and missing feature checks in lock and lawn mower (#53222) 2026-07-23 15:58:55 +03:00
dependabot[bot] b6813ce060 Bump fast-uri from 3.1.3 to 3.1.4 (#53248)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 10:30:41 +01:00
Paul Bottein 3a060feb82 Unify number slider threshold across rows and state cards (#53224)
* Unify number slider threshold across rows and state cards

* Add showNumberSlider test
2026-07-23 09:37:12 +02:00
Petar Petrov ab39c4dd4a Group small device flows into "Other" in sankey cards and share logic (#53235)
The water and energy sankey cards skipped device flows below a hardcoded
0.01 while still counting them in the home total, so small-but-real
devices (e.g. 0.004 m³) were misattributed to "Untracked consumption".
Adopt the power/water-flow relative-threshold + "Other"-grouping strategy
and extract the logic shared by all four cards into energy/common/sankey.
2026-07-23 08:52:54 +02:00
Petar Petrov 82167a81f7 Keep ha-qr-code legible when theme colors collide (#53246)
Ensure ha-qr-code stays legible when theme colors collide
2026-07-23 09:43:31 +03:00
109 changed files with 4474 additions and 3311 deletions
+14 -9
View File
@@ -23,11 +23,10 @@ Dialog implementation requirements:
- Use `ha-dialog`.
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
- Read dialog parameters from the mixin's `params` property and render the dialog open. Return `nothing` while required parameters are absent.
- Call the mixin's `closeDialog()` to close a new dialog. The mixin handles the `closed` event, fires `dialog-closed`, and removes the host element.
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
- Use `@state() private _open = false` to control visibility.
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
- Return `nothing` while required params are absent.
- Fire `dialog-closed` in the close handler.
- Preserve the existing `showDialog()`, open-state, and close-event lifecycle when maintaining a legacy dialog; do not copy that lifecycle into a `DialogMixin` dialog.
- Use `header-title` and `header-subtitle` for simple header text.
- Use slots when standard header attributes are not enough.
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
@@ -64,7 +63,7 @@ Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, v
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
.computeLabel=${(schema) => this._localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
@@ -78,10 +77,16 @@ Use `ha-alert` for user-visible status messaging.
- Slots: `icon` for custom leading icon, `action` for custom action content.
- Content is announced by screen readers when dynamically displayed.
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```ts
html`
<ha-alert alert-type="error">${this._localize("ui.example.error")}</ha-alert>
<ha-alert alert-type="warning" .title=${this._localize("ui.example.warning")}>
${this._localize("ui.example.description")}
</ha-alert>
<ha-alert alert-type="success" dismissable>
${this._localize("ui.example.success")}
</ha-alert>
`;
```
## Shortcuts And Tooltips
+23 -16
View File
@@ -23,27 +23,27 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
## Context Selection
| Context | Replaces |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
| Context | Replaces |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.debugConnection`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
## Consumption Patterns
Use entity-scoped helpers when the component watches an entity id held on the host:
Use entity-scoped helpers when the component watches an entity ID held on the host:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
@@ -56,6 +56,13 @@ private _entity?: EntityRegistryDisplayEntry;
private _localize!: LocalizeFunc;
```
Use `consumeEntityStates` when the host property contains one or more entity IDs. It filters missing entities and preserves the previous record when none of the selected entities changed.
```ts
@state() @consumeEntityStates({ entityIdPath: ["_config", "entities"] })
private _stateObjs?: Record<string, HassEntity>;
```
For a single field from a grouped context, pair `@consume` with `@transform`:
```ts
@@ -65,7 +72,7 @@ For a single field from a grouped context, pair `@consume` with `@transform`:
private _themes!: Themes;
```
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity ID. `consumeEntityState` and `consumeEntityStates` only watch the first path segment.
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
@@ -1,6 +1,11 @@
# Gallery Agent Instructions
---
name: ha-frontend-gallery
description: Home Assistant frontend gallery structure, pages, demos, content, and verification. Use when changing files under gallery/, including gallery markdown, TypeScript demos, sidebar entries, mock data, page generation, or gallery builds.
---
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
# HA Frontend Gallery
Use this skill for all work under `gallery/`. Follow the persistent repository guidance in `AGENTS.md` and load the matching specialist skills alongside this gallery-specific guidance.
## Quick Reference
@@ -13,7 +18,7 @@ yarn lint # ESLint, Prettier, TypeScript, and Lit checks
yarn lint:types # TypeScript compiler, without file arguments
```
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
Never run `yarn lint:types` or `tsc` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
## Purpose
@@ -26,36 +31,36 @@ The gallery is a developer and designer reference for Home Assistant frontend UI
## Structure
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
- `src/html/index.html.template`: HTML template used by the gallery build.
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
- `public/`: Static assets copied into the gallery output.
- `gallery/sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
- `gallery/script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
- `gallery/script/build_gallery`: Wrapper for the `build-gallery` gulp task.
- `gallery/src/entrypoint.js`: Creates the `<ha-gallery>` shell.
- `gallery/src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
- `gallery/src/html/index.html.template`: HTML template used by the gallery build.
- `gallery/src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
- `gallery/src/pages/<category>/<page>.ts`: Optional live demo module for the same page ID.
- `gallery/src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
- `gallery/src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
- `gallery/public/`: Static assets copied into the gallery output.
## Page Model
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
- A page ID is the path under `gallery/src/pages/` without the extension, like `components/ha-button`.
- A `.markdown` file and a `.ts` file with the same page ID become one gallery page.
- A page may have only markdown, only a TypeScript demo, or both.
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
- `gallery/src/ha-gallery.ts` renders that element with `dynamicElement()` based on the current page ID.
## Sidebar
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
Use `gallery/sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
- `category` must match the first directory name under `src/pages/`.
- `category` must match the first directory name under `gallery/src/pages/`.
- `header` is the section label shown in the drawer.
- `pages` is optional. When present, listed pages keep that exact order.
- Pages in a category that are not listed are appended alphabetically after the listed pages.
@@ -83,20 +88,20 @@ Use markdown pages for explanations, design guidance, API notes, and copy standa
- Use fenced code blocks with a language tag for copyable examples.
- Keep examples short and focused on the behavior being documented.
- Prefer real component names and attributes over prose-only descriptions.
- Use Home Assistant terminology from the root `AGENTS.md`.
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
- Use Home Assistant terminology from `ha-frontend-user-facing-text`.
- For remove/delete and add/create wording, follow `gallery/src/pages/misc/remove-delete-add-create.markdown`.
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, follow the localization and copy guidance in `ha-frontend-user-facing-text`.
## Demo Components
Use TypeScript demo pages for interactive or stateful examples.
- Import production components from `../../../src/...` or the correct relative path from the demo file.
- Import production components from `src/` using the correct relative path from the demo file.
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
- Use shared mock data from `gallery/src/data/` instead of repeating large fake state objects inline.
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
@@ -105,7 +110,7 @@ The gallery ESLint config allows `console` for gallery diagnostics. Do not copy
## Content Standards
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
Follow the detailed copy standards in `ha-frontend-user-facing-text`: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
- Use `Home Assistant` in full, not `HA` or `HASS`.
- Use `integration` instead of `component` for product concepts.
+2 -2
View File
@@ -65,9 +65,9 @@ The app suite uses a stripped-down harness for e2e. Demo and gallery use their n
## Benchmarks
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
For chart data transforms such as history, statistics, energy, and downsampling, read and follow the complete workflow in `test/benchmarks/README.md` before making benchmark or optimization changes.
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
That workflow owns the baseline, noise analysis, guardrails, acceptance thresholds, and reporting requirements. In particular, optimizations must keep output bit-identical; never update snapshots or modify fixtures to make an optimization pass.
## Verification Selection
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
- Give translators enough context through key naming and placeholders.
```ts
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
this._localize("ui.panel.config.updates.updates_refreshed", {
count: 5,
});
```
+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"
+34
View File
@@ -0,0 +1,34 @@
name: Copilot Setup Steps
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
env:
NODE_OPTIONS: --max_old_space_size=6144
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
+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 -1
View File
@@ -2,7 +2,7 @@
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
@@ -46,6 +46,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
## Pull Requests
+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;
}
+23 -18
View File
@@ -41,25 +41,30 @@ class CastDemoRow extends LitElement implements LovelaceRow {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
getCastManager().then((mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
getCastManager().then(
(mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
}
}
}
);
})
);
},
() => {
this._castManager = null;
}
)
);
}
+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,
},
];
+12 -13
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,14 +108,14 @@
"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",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.6",
"marked": "18.0.7",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -151,8 +151,8 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.6.0",
"@rspack/core": "2.1.4",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -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,22 +196,22 @@
"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",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.5",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.20",
"sinon": "22.1.0",
"tar": "7.5.21",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.64.0",
"typescript-eslint": "8.65.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.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": {
+11 -10
View File
@@ -9,16 +9,17 @@ export const castApiAvailable = () => {
loadedPromise = new Promise((resolve) => {
(window as any).__onGCastApiAvailable = resolve;
});
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
);
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
).catch(() => resolve(false));
});
return loadedPromise;
};
+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
),
+39 -10
View File
@@ -1,6 +1,7 @@
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
@@ -46,15 +47,36 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
@query(".text") private _textSvg?: SVGSVGElement;
@query(".value-text") private _valueText?: SVGTextElement;
private _sortedLevels?: LevelDefinition[];
private _rescaleOnConnect = false;
// Set when the value text could not be measured because we have no layout box
// yet, either disconnected or inside a hidden container.
private _rescalePending = false;
// Measure again once we become visible, e.g. when a section hidden by a
// visibility condition is revealed. Nothing else re-renders the gauge then,
// and a resize observer would stay silent because the size we get back is the
// same one it last reported.
// @ts-ignore side-effect-only controller, its value is never read
private _intersectionController = new IntersectionController(this, {
callback: (entries) => {
if (
this._rescalePending &&
entries.some((entry) => entry.isIntersecting)
) {
this._rescaleSvg();
}
},
});
public connectedCallback(): void {
super.connectedCallback();
if (this._rescaleOnConnect) {
if (this._rescalePending && this.hasUpdated) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
@@ -221,15 +243,22 @@ export class HaGauge extends LitElement {
// fit the text
// That way it will auto-scale correctly
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
if (!this._textSvg || !this._valueText || !this.isConnected) {
this._rescalePending = true;
return;
}
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
const box = this._valueText.getBBox();
// An empty box means we have no layout, so keep the last known good viewBox
// and retry later. A viewBox with a 0 width or height would hide the label.
if (!box.width || !box.height) {
this._rescalePending = true;
return;
}
this._rescalePending = false;
this._textSvg.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
+35 -18
View File
@@ -4,6 +4,23 @@ import { customElement, property, query, state } from "lit/decorators";
import QRCode from "qrcode";
import "./ha-alert";
import { rgb2hex } from "../common/color/convert-color";
import {
getContrastedColorHex,
getRGBContrastRatio,
} from "../common/color/rgb";
// Minimum contrast ratio (WCAG non-text minimum) below which we substitute a
// legible foreground so the QR code never renders unscannable.
const MIN_CONTRAST_RATIO = 3;
const parseRgbVariable = (
value: string
): [number, number, number] | undefined => {
const parts = value.split(",").map((part) => parseInt(part, 10));
return parts.length === 3 && parts.every((part) => !Number.isNaN(part))
? (parts as [number, number, number])
: undefined;
};
@customElement("ha-qr-code")
export class HaQrCode extends LitElement {
@@ -60,27 +77,27 @@ export class HaQrCode extends LitElement {
changedProperties.has("centerImage"))
) {
const computedStyles = getComputedStyle(this);
const textRgb = computedStyles.getPropertyValue(
"--rgb-primary-text-color"
const textRgb = parseRgbVariable(
computedStyles.getPropertyValue("--rgb-primary-text-color")
);
const backgroundRgb = computedStyles.getPropertyValue(
"--rgb-card-background-color"
);
const textHex = rgb2hex(
textRgb.split(",").map((a) => parseInt(a, 10)) as [
number,
number,
number,
]
);
const backgroundHex = rgb2hex(
backgroundRgb.split(",").map((a) => parseInt(a, 10)) as [
number,
number,
number,
]
const backgroundRgb = parseRgbVariable(
computedStyles.getPropertyValue("--rgb-card-background-color")
);
const backgroundHex = backgroundRgb ? rgb2hex(backgroundRgb) : "#ffffff";
let textHex = textRgb ? rgb2hex(textRgb) : "#000000";
// If the theme colors are missing/invalid or don't contrast enough, the
// QR code would be unscannable (e.g. white-on-white). Fall back to a
// foreground guaranteed to contrast the resolved background.
if (
!textRgb ||
!backgroundRgb ||
getRGBContrastRatio(textRgb, backgroundRgb) < MIN_CONTRAST_RATIO
) {
textHex = getContrastedColorHex(backgroundHex);
}
QRCode.toCanvas(canvas, this.data, {
errorCorrectionLevel:
this.errorCorrectionLevel || (this.centerImage ? "Q" : "M"),
@@ -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;
+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 {
+32 -3
View File
@@ -15,10 +15,26 @@ export interface HttpConfig {
ssl_profile?: "modern" | "intermediate";
}
// The slot the running HTTP server was actually started with.
export type ActiveConfigType = "stable" | "pending" | "default";
// A stored config slot carries metadata alongside the editable fields:
// - created_at: when the slot was staged
// - error: null while healthy; set once a slot could not be applied or a
// pending trial was not confirmed (then it is kept for display, not retried)
export interface HttpConfigWithMeta extends HttpConfig {
created_at?: string;
error?: string | null;
}
export interface HttpConfigState {
stable: HttpConfig;
pending: HttpConfig | null;
stable: HttpConfigWithMeta;
pending: HttpConfigWithMeta | null;
revert_at: string | null;
// Added in the "active HTTP config slot" backend change; optional so the
// frontend keeps working against cores without it.
active_config_type?: ActiveConfigType;
default?: HttpConfigWithMeta;
}
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
@@ -40,6 +56,19 @@ export interface SaveHttpConfigResult {
restart: boolean;
}
// Keep only the editable fields; the backend storage schema rejects unknown
// keys, so the created_at/error metadata that rides along on a fetched slot
// must be dropped before configuring.
export const stripHttpConfigMeta = (config: HttpConfig): HttpConfig => {
const stripped: Partial<Record<keyof HttpConfig, unknown>> = {};
for (const key of HTTP_CONFIG_FIELDS) {
if (config[key] !== undefined) {
stripped[key] = config[key];
}
}
return stripped as HttpConfig;
};
export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfigState>({ type: "http/config" });
@@ -49,7 +78,7 @@ export const saveHttpConfig = (
) =>
hass.callWS<SaveHttpConfigResult>({
type: "http/config/configure",
config,
config: config ? stripHttpConfigMeta(config) : null,
});
export const promoteHttpConfig = (hass: HomeAssistant) =>
-63
View File
@@ -45,69 +45,6 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength = "strong" | "medium" | "weak" | "none";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
+14
View File
@@ -1,9 +1,23 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export interface NumberDeviceClassUnits {
units: string[];
}
const AUTO_MODE_MAX_STEPS = 256;
export const showNumberSlider = (stateObj: HassEntity): boolean => {
const { mode, min, max, step } = stateObj.attributes;
if (mode === "slider") {
return true;
}
return (
mode === "auto" &&
(Number(max) - Number(min)) / Number(step) <= AUTO_MODE_MAX_STEPS
);
};
export const getNumberDeviceClassConvertibleUnits = (
hass: HomeAssistant,
deviceClass: string
@@ -21,6 +21,7 @@ import { isTiltOnly } from "../../../data/cover";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import { showNumberSlider } from "../../../data/number";
import "../../../panels/lovelace/components/hui-timestamp-display";
import type { HomeAssistant } from "../../../types";
import {
@@ -249,15 +250,9 @@ class EntityPreviewRow extends LitElement {
}
if (domain === "number") {
const showNumberSlider =
stateObj.attributes.mode === "slider" ||
(stateObj.attributes.mode === "auto" &&
(Number(stateObj.attributes.max) - Number(stateObj.attributes.min)) /
Number(stateObj.attributes.step) <=
256);
return html`
${
showNumberSlider
showNumberSlider(stateObj)
? html`
<div class="numberflex">
<ha-slider
+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;
}
@@ -78,18 +78,21 @@ class MoreInfoLawnMower extends LitElement {
);
}
private get _startPauseIcon(): string {
if (!this.stateObj) return mdiPlay;
return isMowing(this.stateObj) &&
private get _showPause(): boolean {
if (!this.stateObj) return false;
return (
isMowing(this.stateObj) &&
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
? mdiPause
: mdiPlay;
);
}
private get _startPauseIcon(): string {
return this._showPause ? mdiPause : mdiPlay;
}
private get _startPauseLabel(): string {
if (!this.stateObj) return "";
return isMowing(this.stateObj) &&
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
return this._showPause
? this._i18n.localize("ui.dialogs.more_info_control.lawn_mower.pause")
: this._i18n.localize(
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
@@ -99,8 +102,11 @@ class MoreInfoLawnMower extends LitElement {
private get _startPauseDisabled(): boolean {
if (!this.stateObj) return true;
if (this.stateObj.state === UNAVAILABLE) return true;
if (isMowing(this.stateObj)) return false;
return !canStartMowing(this.stateObj);
if (this._showPause) return false;
return (
!supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
!canStartMowing(this.stateObj)
);
}
private _renderBattery() {
@@ -156,7 +162,7 @@ class MoreInfoLawnMower extends LitElement {
private _handleStartPause() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
if (isMowing(this.stateObj)) {
if (this._showPause) {
this._api.callService("lawn_mower", "pause", {
entity_id: this.stateObj.entity_id,
});
+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) {
+9 -6
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) => {
@@ -269,7 +265,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// The check re-runs on the next reconnect; ignore transient failures.
return;
}
if (!httpConfig.pending || this._httpPendingDialogOpen) {
// Only prompt for an active trial. A pending config with an error was
// already reverted/failed and is kept only for display in the config form,
// so it must not pop the confirm/revert dialog.
if (
!httpConfig.pending ||
httpConfig.pending.error ||
this._httpPendingDialogOpen
) {
return;
}
this._httpPendingDialogOpen = true;
+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();
}
@@ -145,6 +145,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
public connectedCallback() {
super.connectedCallback();
// Re-apply the type filter from the URL when the page is (re)displayed,
// e.g. when navigating back to a cached instance of this page with a
// different `type` query param.
this._setFiltersFromUrl();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
@@ -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;
}
`,
@@ -5,7 +5,6 @@ import {
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -145,10 +144,6 @@ export class MatterConfigDashboard extends LitElement {
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
<ha-button appearance="filled" href="/config/matter/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
@@ -257,9 +252,6 @@ export class MatterConfigDashboard extends LitElement {
}
.nav-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--ha-space-2);
}
@@ -27,10 +27,6 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
visualization: {
tag: "matter-network-visualization",
load: () => import("./matter-network-visualization"),
},
},
};
@@ -1,363 +0,0 @@
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
MatterTopologyStrength,
} from "../../../../../data/matter";
import type { HomeAssistant } from "../../../../../types";
const CATEGORY_HOME_ASSISTANT = 0;
const CATEGORY_BORDER_ROUTER = 1;
const CATEGORY_ROUTER = 2;
const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
// HA is not a Matter node; the frontend synthesizes it as the graph root.
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
strength?: MatterTopologyStrength | null
): number => {
switch (strength) {
case "strong":
return 4;
case "medium":
return 3;
case "weak":
return 2;
default:
return 1;
}
};
export const strengthToColorVar = (
strength?: MatterTopologyStrength | null
): string => {
switch (strength) {
case "strong":
return "--success-color";
case "medium":
return "--warning-color";
case "weak":
return "--error-color";
default:
return "--disabled-color";
}
};
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
export const getTopologyNodeCategory = (
node: MatterNetworkTopologyNode
): number => {
if (node.kind === "border_router") {
return CATEGORY_BORDER_ROUTER;
}
if (node.kind === "wifi_ap") {
return CATEGORY_WIFI_AP;
}
if (node.kind === "thread_unknown") {
return CATEGORY_UNKNOWN;
}
if (node.available === false) {
return CATEGORY_OFFLINE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
};
export const getTopologyNodeName = (
node: MatterNetworkTopologyNode,
hass: HomeAssistant
): string => {
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || node.id;
}
if (node.kind === "border_router") {
return (
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
node.network_name ||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
}
if (node.node_id != null) {
return hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return node.id;
};
const isHub = (category: number): boolean =>
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
const categoryColors = [
style.getPropertyValue("--primary-color"),
style.getPropertyValue("--deep-purple-color"),
style.getPropertyValue("--cyan-color"),
style.getPropertyValue("--teal-color"),
style.getPropertyValue("--indigo-color"),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
const categories = [
{
name: HOME_ASSISTANT_LABEL,
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.router"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.offline"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.unknown_devices"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
];
const threadNetworks = new Set(
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
);
const multiNetwork = threadNetworks.size > 1;
const nodes: NetworkNode[] = [
{
id: HOME_ASSISTANT_NODE_ID,
name: HOME_ASSISTANT_LABEL,
category: CATEGORY_HOME_ASSISTANT,
value: 4,
symbol: "roundRect",
symbolSize: 45,
polarDistance: 0,
fixed: true,
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
];
const nodeCategories = new Map<string, number>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
const area = device ? getDeviceArea(device, hass.areas) : undefined;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
if ((multiNetwork || !area) && node.network_name) {
contextParts.push(node.network_name);
}
nodes.push({
id: node.id,
name: getTopologyNodeName(node, hass),
context: contextParts.join(" • ") || undefined,
category,
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
symbol: isHub(category) ? "roundRect" : "circle",
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
itemStyle: {
color: categoryColors[category],
...(node.role === "leader"
? {
borderColor: style.getPropertyValue("--primary-color"),
borderWidth: 2,
}
: {}),
},
polarDistance: isHub(category)
? 0.1
: category === CATEGORY_ROUTER
? 0.4
: 0.8,
});
});
const links: NetworkLink[] = [];
topology.connections.forEach((conn) => {
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
return;
}
let { source, target } = conn;
let forward = conn.source_to_target;
let reverse = conn.target_to_source;
if (!forward && reverse) {
// normalize so the arrow points in the observed direction
[source, target] = [target, source];
forward = reverse;
reverse = undefined;
}
const oneWay = Boolean(forward) && !reverse;
const asymmetric =
forward && reverse && forward.strength !== reverse.strength;
const width = strengthToWidth(conn.strength);
links.push({
source,
target,
value: strengthToScale(forward?.strength ?? conn.strength),
// route-table edges without per-direction info are not directional
reverseValue: oneWay
? undefined
: strengthToScale(reverse?.strength ?? conn.strength),
symbolSize: oneWay ? width * 2 + 3 : undefined,
lineStyle: {
width,
color: style.getPropertyValue(strengthToColorVar(conn.strength)),
type:
oneWay || asymmetric
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
const haLink = (target: string): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
color: categoryColors[CATEGORY_HOME_ASSISTANT],
},
});
// HA reaches the mesh through the border routers and Wi-Fi access points
const hubIds = topology.nodes
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
.map((node) => node.id);
hubIds.forEach((id) => links.push(haLink(id)));
// any node group without a border router / AP is linked straight to HA so
// it never floats free (HA has a direct operational path to every node)
const adjacency = new Map<string, Set<string>>();
topology.nodes.forEach((node) => adjacency.set(node.id, new Set()));
links.forEach((link) => {
if (link.source === HOME_ASSISTANT_NODE_ID) {
return;
}
adjacency.get(link.source)?.add(link.target);
adjacency.get(link.target)?.add(link.source);
});
const hubIdSet = new Set(hubIds);
const visited = new Set<string>();
topology.nodes.forEach((startNode) => {
if (visited.has(startNode.id)) {
return;
}
const component: string[] = [];
const queue = [startNode.id];
visited.add(startNode.id);
while (queue.length) {
const id = queue.shift()!;
component.push(id);
adjacency.get(id)?.forEach((next) => {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
});
}
if (component.some((id) => hubIdSet.has(id))) {
return;
}
const candidates = component.filter(
(id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN
);
const representative = (candidates.length ? candidates : component).sort(
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
)[0];
if (representative) {
links.push(haLink(representative));
}
});
// keep the strongest link of every node in the force layout so
// nodes hang near their best connection instead of floating free
nodes.forEach((node) => {
let bestLink: NetworkLink | undefined;
const hasActiveLink = links.some((link) => {
if (link.source !== node.id && link.target !== node.id) {
return false;
}
if (!link.ignoreForceLayout) {
return true;
}
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
if (
linkValue >
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
) {
bestLink = link;
}
return false;
});
if (!hasActiveLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
});
return { nodes, links, categories };
}
@@ -1,515 +0,0 @@
import { mdiRefresh } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterTopologyDirectionInfo,
} from "../../../../../data/matter";
import {
fetchMatterNetworkTopology,
subscribeMatterNetworkTopology,
} from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
const UPDATE_THROTTLE_TIME = 5000;
@customElement("matter-network-visualization")
export class MatterNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _topology?: MatterNetworkTopology;
@state() private _notSupported = false;
@state() private _error?: string;
@state() private _refreshing = false;
@state() private _searchFilter = "";
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
(topology: MatterNetworkTopology) => {
this._topology = topology;
},
UPDATE_THROTTLE_TIME
);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !this._unsub) {
this._subscribe();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._throttledUpdateTopology.cancel();
if (this._unsub) {
this._unsub.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
}
}
private _subscribe(): void {
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
if (!this._topology) {
this._topology = topology;
} else {
this._throttledUpdateTopology(topology);
}
});
this._unsub.catch((err: { code?: string; message?: string }) => {
this._unsub = undefined;
if (err?.code === "not_supported" || err?.code === "unknown_command") {
this._notSupported = true;
} else {
this._error = err?.message || String(err);
}
});
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.visualization.header"
)}
back-path="/config/matter/dashboard"
>
${
this.narrow && this._topology?.nodes.length
? html`<div slot="header">${this._renderInputSearch()}</div>`
: nothing
}
${this._renderContent()}
</hass-subpage>
`;
}
private _renderContent() {
if (this._notSupported) {
return html`<div class="center">
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.visualization.not_supported"
)}
</ha-alert>
</div>`;
}
if (this._error) {
return html`<div class="center">
<ha-alert alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.visualization.error_loading",
{ error: this._error }
)}
</ha-alert>
</div>`;
}
if (!this._topology) {
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
}
if (!this._topology.nodes.length) {
return html`<div class="center empty">
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
</div>`;
}
return html`
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(
this._topology,
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class="refresh-button"
.disabled=${this._refreshing}
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.matter.visualization.refresh_topology"
)}
></ha-icon-button>
</ha-network-graph>
`;
}
private _renderInputSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._searchFilter}
@input=${this._handleSearchChange}
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private async _refreshTopology(): Promise<void> {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
this._topology = await fetchMatterNetworkTopology(this.hass, true);
} catch (err: unknown) {
this._error = (err as { message?: string })?.message || String(err);
} finally {
this._refreshing = false;
}
}
private _formatNetworkData = memoizeOne(
(
topology: MatterNetworkTopology,
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"],
// node/link colors and labels also depend on the theme and language,
// so both take part in the cache key even though they are read via hass
_themes: HomeAssistant["themes"],
_language: HomeAssistant["language"]
) => createMatterNetworkChartData(topology, this.hass, this)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getConnection(
source: string,
target: string
): MatterNetworkTopologyConnection | undefined {
return this._topology?.connections.find(
(conn) =>
(conn.source === source && conn.target === target) ||
(conn.source === target && conn.target === source)
);
}
private _getNodeName(id: string): string {
const node = this._getTopologyNode(id);
if (!node) {
return id;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || id;
}
if (node.kind === "border_router") {
return (
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
this.hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
node.network_name ||
this.hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return this.hass.localize(
"ui.panel.config.matter.visualization.unknown_device"
);
}
if (node.node_id != null) {
return this.hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return id;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
}
const attributes: string[] = [];
if (node.node_id != null) {
attributes.push(String(node.node_id));
}
if (node.network_name) {
attributes.push(node.network_name);
}
if (node.ext_address) {
attributes.push(node.ext_address);
}
if (node.vendor_name) {
attributes.push(node.vendor_name);
}
if (node.model_name) {
attributes.push(node.model_name);
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
private _localizeDynamic(prefix: string, value: string): string {
return (
this.hass.localize(
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
) || value
);
}
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
const strength = this._localizeDynamic(
"visualization.strength",
direction.strength
);
if (direction.lqi != null) {
return `${strength} (LQI ${direction.lqi})`;
}
if (direction.rssi != null) {
return `${strength} (RSSI ${direction.rssi} dBm)`;
}
return strength;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
}
const lines: TemplateResult[] = [];
if (conn.source_to_target) {
lines.push(
html`<br />${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}:
${this._formatDirection(conn.source_to_target)}`
);
}
if (conn.target_to_source) {
lines.push(
html`<br />${this._getNodeName(conn.target)}
${this._getNodeName(conn.source)}:
${this._formatDirection(conn.target_to_source)}`
);
}
if (!lines.length && conn.via_route_table) {
lines.push(
html`<br />${this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)}`
);
}
return html`<b
>${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}</b
>${lines}`;
}
const { id } = data as { id: string };
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const lines: TemplateResult[] = [];
if (node.node_id != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.node_id"
)}:</b
>
${node.node_id}`
);
}
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", node.network_type)}${
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.role) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.role"
)}:</b
>
${this._localizeDynamic("visualization.roles", node.role)}`
);
}
if (node.available != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.status"
)}:</b
>
${this.hass.localize(
node.available
? "ui.panel.config.matter.visualization.online"
: "ui.panel.config.matter.visualization.offline"
)}`
);
}
if (device?.manufacturer || node.vendor_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device?.manufacturer || node.vendor_name}`
);
}
if (device?.model || node.model_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device?.model || node.model_name}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
if (node.last_seen != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.last_seen"
)}:</b
>
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
if (node?.ha_device_id) {
navigate(`/config/devices/device/${node.ha_device_id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--ha-space-4);
box-sizing: border-box;
}
ha-alert {
max-width: 500px;
}
.empty {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-network-visualization": MatterNetworkVisualization;
}
}
@@ -19,7 +19,11 @@ import {
HTTP_CONFIG_FIELDS,
saveHttpConfig,
} from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import type {
ActiveConfigType,
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -161,6 +165,11 @@ class HaConfigHttpForm extends LitElement {
@state() private _showNoChanges = false;
@state() private _activeConfigType?: ActiveConfigType;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@query("ha-form") private _form?: HaForm;
@query("ha-alert") private _firstAlert?: HTMLElement;
@@ -201,6 +210,40 @@ class HaConfigHttpForm extends LitElement {
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>
${
this._activeConfigType === "default"
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.running_default"
)}
</ha-alert>
`
: nothing
}
${
this._revertedPending
? html`
<ha-alert alert-type="warning">
${
this._revertedPending.error === "not_promoted"
? this.hass.localize(
"ui.panel.config.network.http.reverted_not_confirmed"
)
: this.hass.localize(
"ui.panel.config.network.http.reverted_failed",
{ error: this._revertedPending.error ?? "" }
)
}
<ha-button slot="action" @click=${this._reviewReverted}>
${this.hass.localize(
"ui.panel.config.network.http.reverted_action"
)}
</ha-button>
</ha-alert>
`
: nothing
}
${
portChanged
? html`
@@ -266,16 +309,30 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
// Pending is exclusively handled by the global confirm/revert dialog, so
// the form only ever displays stable.
const { stable } = await fetchHttpConfig(this.hass);
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
this._revertedPending = pending?.error ? pending : undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _reviewReverted(): void {
if (!this._revertedPending) {
return;
}
// Load the reverted values into the form so the user can fix and re-save.
this._config = { ...this._revertedPending };
this._revertedPending = undefined;
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
@@ -16,7 +16,11 @@ import "../../../components/ha-svg-icon";
import { apiContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { LawnMowerEntity } from "../../../data/lawn_mower";
import { LawnMowerEntityFeature, canDock } from "../../../data/lawn_mower";
import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
} from "../../../data/lawn_mower";
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
@@ -72,6 +76,9 @@ export const LAWN_MOWER_COMMANDS_BUTTONS: Record<
translationKey: "start",
icon: mdiPlay,
serviceName: "start_mowing",
disabled:
!supportsFeature(stateObj, LawnMowerEntityFeature.START_MOWING) ||
!canStartMowing(stateObj),
};
},
dock: (stateObj) => ({
@@ -34,6 +34,8 @@ export const supportsTrendGraphCardFeature = (
export const DEFAULT_HOURS_TO_SHOW = 24;
const HOUR = 60 * 60 * 1000;
@customElement("hui-trend-graph-card-feature")
class HuiHistoryChartCardFeature
extends LitElement
@@ -93,9 +95,13 @@ class HuiHistoryChartCardFeature
public connectedCallback() {
super.connectedCallback();
// redraw the graph every minute to update the time axis
// recompute the graph every minute so the x-axis (and the horizontal fill
// to now) keeps advancing even while the sensor value stays constant
clearInterval(this._interval);
this._interval = window.setInterval(() => this.requestUpdate(), 1000 * 60);
this._interval = window.setInterval(
() => this._calculateCoordinates(),
1000 * 60
);
if (this.hasUpdated) {
this._subscribeHistory();
}
@@ -146,12 +152,18 @@ class HuiHistoryChartCardFeature
? Math.max(10, width / 5, hourToShow)
: Math.max(10, hourToShow);
const useMean = !detail;
// Anchor the x-axis to the full time window so a constant value is drawn as
// a horizontal line up to now, instead of ending at the last state change.
const now = Date.now();
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
this._stateHistory,
width,
height,
maxDetails,
undefined,
{
minX: now - hourToShow * HOUR,
maxX: now,
},
useMean
);
this._coordinates = points;
@@ -0,0 +1,473 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import { getGraphColorByIndex } from "../../../../../common/color/colors";
import { getEntityContext } from "../../../../../common/entity/context/get_entity_context";
import type {
Link,
Node,
} from "../../../../../components/chart/ha-sankey-chart";
import type { DeviceConsumptionEnergyPreference } from "../../../../../data/energy";
import type { HomeAssistant } from "../../../../../types";
// Devices below this fraction of the home total are grouped into an "Other" node
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
// that is not part of the chart's Node interface. The chart ignores it.
export type SankeyDeviceNode = Node & { parent?: string };
interface SmallConsumer {
id: string;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
idx: number;
}
const OTHER_LABEL_KEY =
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other";
const UNTRACKED_LABEL_KEY =
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption";
/** Open more-info for a clicked sankey node that maps to an entity. */
export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
if (node.entityId) {
fireEvent(el, "hass-more-info", { entityId: node.entityId });
}
};
export interface BuildSankeyDeviceNodesOptions {
devices: DeviceConsumptionEnergyPreference[];
computedStyle: CSSStyleDeclaration;
localize: HomeAssistant["localize"];
/** Node small consumers and untracked flow attach to (usually "home"). */
rootNodeId: string;
/** Flows below this value are grouped into an "Other" node. */
minThreshold: number;
/** Per-parent untracked residual only shown when above this value. */
untrackedFloor: number;
/** Round the "Other" node value up (instantaneous cards only). */
ceilOtherValue: boolean;
/** Starting untracked total (the home/root value). */
initialUntracked: number;
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
getValue: (id: string) => number;
getLabel: (id: string, name: string | undefined) => string;
getEntityId: (id: string) => string | undefined;
findEffectiveParent: (
includedInStat: string | undefined
) => string | undefined;
}
/**
* Build the device layer of a sankey card: one node per device, small devices
* folded into per-parent "Other" nodes, and per-parent untracked residuals.
* Value/label/id/entityId are supplied via callbacks so both cumulative
* (statistic growth) and instantaneous (live power/flow) cards can share it.
*/
export const buildSankeyDeviceNodes = (
options: BuildSankeyDeviceNodesOptions
): {
deviceNodes: SankeyDeviceNode[];
parentLinks: Record<string, string>;
links: Link[];
untrackedConsumption: number;
} => {
const {
devices,
computedStyle,
localize,
rootNodeId,
minThreshold,
untrackedFloor,
ceilOtherValue,
initialUntracked,
getId,
getValue,
getLabel,
getEntityId,
findEffectiveParent,
} = options;
const unavailableColor = computedStyle
.getPropertyValue("--state-unavailable-color")
.trim();
const links: Link[] = [];
const deviceNodes: SankeyDeviceNode[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
let untrackedConsumption = initialUntracked;
devices.forEach((device, idx) => {
const id = getId(device);
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
if (!id) {
return;
}
const value = getValue(id);
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minThreshold) {
// Collect small consumers instead of folding them into untracked
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
}
smallConsumersByParent.get(parentKey)!.push({
id,
name: device.name,
value,
effectiveParent,
idx,
});
return;
}
const node: SankeyDeviceNode = {
id,
label: getLabel(id, device.name),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: getEntityId(id),
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
// Process small consumers - show a lone one directly, group clusters as "Other"
smallConsumersByParent.forEach((consumers, parentKey) => {
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) {
return;
}
if (consumers.length === 1) {
const consumer = consumers[0];
const node: SankeyDeviceNode = {
id: consumer.id,
label: getLabel(consumer.id, consumer.name),
value: consumer.value,
color: getGraphColorByIndex(consumer.idx, computedStyle),
index: 4,
parent: consumer.effectiveParent,
entityId: getEntityId(consumer.id),
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= consumer.value;
}
deviceNodes.push(node);
} else {
const otherNodeId = `other_${parentKey}`;
const otherNode: SankeyDeviceNode = {
id: otherNodeId,
label: localize(OTHER_LABEL_KEY),
value: ceilOtherValue ? Math.ceil(totalValue) : totalValue,
color: unavailableColor,
index: 4,
};
if (parentKey !== rootNodeId) {
parentLinks[otherNodeId] = parentKey;
links.push({ source: parentKey, target: otherNodeId });
} else {
untrackedConsumption -= totalValue;
}
deviceNodes.push(otherNode);
}
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full consumption
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
if (untracked > untrackedFloor) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: localize(UNTRACKED_LABEL_KEY),
value: untracked,
color: unavailableColor,
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
return { deviceNodes, parentLinks, links, untrackedConsumption };
};
/** Bucket device nodes by their entity's area and floor. */
export const groupSankeyDevicesByFloorAndArea = (
hass: HomeAssistant,
deviceNodes: Node[]
): {
areas: Record<string, { value: number; devices: Node[] }>;
floors: Record<string, { value: number; areas: string[] }>;
} => {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {
value: 0,
devices: [],
},
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: {
value: 0,
areas: ["no_area"],
},
};
deviceNodes.forEach((deviceNode) => {
const entity = hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
hass.entities,
hass.devices,
hass.areas,
hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
// see if the area has a floor
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
};
/**
* Organize device nodes into hierarchical sections based on parent-child
* relationships (top-level parents first, then their children, recursively).
*/
export const getSankeyDeviceSections = (
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] => {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: Record<string, string> = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...getSankeyDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
};
export interface BuildSankeyLayoutOptions {
hass: HomeAssistant;
computedStyle: CSSStyleDeclaration;
localize: HomeAssistant["localize"];
deviceNodes: SankeyDeviceNode[];
parentLinks: Record<string, string>;
rootNodeId: string;
groupByFloor: boolean;
groupByArea: boolean;
untrackedConsumption: number;
untrackedFloor: number;
}
/**
* Build the floor/area grouping layer, emit the device section nodes with
* their final section indices, and add the top-level untracked node.
*/
export const buildSankeyLayout = (
options: BuildSankeyLayoutOptions
): { nodes: Node[]; links: Link[] } => {
const {
hass,
computedStyle,
localize,
deviceNodes,
parentLinks,
rootNodeId,
groupByFloor,
groupByArea,
untrackedConsumption,
untrackedFloor,
} = options;
const nodes: Node[] = [];
const links: Link[] = [];
const primaryColor = computedStyle.getPropertyValue("--primary-color").trim();
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
if (groupByArea || groupByFloor) {
const { areas, floors } = groupSankeyDevicesByFloorAndArea(
hass,
devicesWithoutParent
);
Object.keys(floors)
.sort(
(a, b) =>
(hass.floors[b]?.level ?? -Infinity) -
(hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !groupByFloor) {
// link "no_floor" areas to the root
floorNodeId = rootNodeId;
} else {
nodes.push({
id: floorNodeId,
label: hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: primaryColor,
});
links.push({
source: rootNodeId,
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !groupByArea) {
// If group_by_area is false, link devices to floor or root
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: primaryColor,
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or root)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: rootNodeId,
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = getSankeyDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
});
// untracked consumption
if (untrackedConsumption > untrackedFloor) {
nodes.push({
id: "untracked",
label: localize(UNTRACKED_LABEL_KEY),
value: untrackedConsumption,
color: computedStyle.getPropertyValue("--state-unavailable-color").trim(),
index: 3 + deviceSections.length,
});
links.push({
source: rootNodeId,
target: "untracked",
value: untrackedConsumption,
});
}
return { nodes, links };
};
@@ -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);
}
}
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
@@ -25,10 +24,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { EnergySankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
const DEFAULT_CONFIG: Partial<EnergySankeyCardConfig> = {
group_by_floor: true,
@@ -138,6 +141,8 @@ class HuiEnergySankeyCard
};
nodes.push(homeNode);
const minEnergyThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
if (types.battery) {
const totalBatteryOut = summedData.total.from_battery ?? 0;
const totalBatteryIn = summedData.total.to_battery ?? 0;
@@ -262,185 +267,85 @@ class HuiEnergySankeyCard
}
}
let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
prefs.device_consumption.forEach((device, idx) => {
const value =
device.stat_consumption in this._data!.stats
? calculateStatisticSumGrowth(
this._data!.stats[device.stat_consumption]
) || 0
: 0;
if (value < 0.01) {
return;
const deviceValue = (statConsumption: string) =>
statConsumption in this._data!.stats
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
renderedStats.add(device.stat_consumption);
}
const node = {
id: device.stat_consumption,
label:
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this._data!.statsMetadata[device.stat_consumption]
),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
entityId: isExternalStatistic(device.stat_consumption)
? undefined
: device.stat_consumption,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full consumption
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
this.hass,
statConsumption,
this._data!.statsMetadata[statConsumption]
);
const untracked = parentNode.value - childrenSum;
if (untracked > 0) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const {
deviceNodes,
parentLinks,
links: deviceLinks,
untrackedConsumption,
} = buildSankeyDeviceNodes({
devices: prefs.device_consumption,
computedStyle,
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minEnergyThreshold,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
getId: (device) => device.stat_consumption,
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
const { group_by_area, group_by_floor } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
// link "no_floor" areas to home
floorNodeId = "home";
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: "home",
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
// If group_by_area is false, link devices to floor or home
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]!.name,
value: areas[areaId].value,
index: 3,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or home)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: "home",
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
const layout = buildSankeyLayout({
hass: this.hass,
computedStyle,
localize: this.hass.localize,
deviceNodes,
parentLinks,
rootNodeId: "home",
groupByFloor: !!group_by_floor,
groupByArea: !!group_by_area,
untrackedConsumption,
untrackedFloor: 0,
});
// untracked consumption
if (untrackedConsumption > 0) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: "home",
target: "untracked",
value: untrackedConsumption,
});
}
nodes.push(...layout.nodes);
links.push(...layout.links);
const hasData = nodes.some((node) => node.value > 0);
@@ -480,113 +385,7 @@ class HuiEnergySankeyCard
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {
value: 0,
devices: [],
},
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: {
value: 0,
areas: ["no_area"],
},
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
// see if the area has a floor
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
fireSankeyNodeMoreInfo(this, ev.detail.node);
}
static styles = css`
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatPowerShort,
@@ -19,19 +18,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { PowerSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
// Minimum power threshold as a fraction of total consumption to display a device node
// Devices below this threshold will be grouped into an "Other" node
const MIN_POWER_THRESHOLD_FACTOR = 0.001; // 0.1% of used_total
interface PowerData {
solar: number;
from_grid: number;
@@ -48,14 +47,6 @@ interface PowerData {
used_total: number;
}
interface SmallConsumer {
statRate: string;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
idx: number;
}
@customElement("hui-power-sankey-card")
class HuiPowerSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
@@ -163,7 +154,8 @@ class HuiPowerSankeyCard
const computedStyle = getComputedStyle(this);
// Calculate dynamic threshold based on total consumption
const minPowerThreshold = powerData.used_total * MIN_POWER_THRESHOLD_FACTOR;
const minPowerThreshold =
powerData.used_total * MIN_SANKEY_THRESHOLD_FACTOR;
const nodes: Node[] = [];
const links: Link[] = [];
@@ -286,10 +278,6 @@ class HuiPowerSankeyCard
}
}
let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
// Build a map of device relationships for hierarchy resolution
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
const deviceMap = new Map<
@@ -338,252 +326,43 @@ class HuiPowerSankeyCard
return undefined;
};
// Collect small consumers by their effective parent
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
prefs.device_consumption.forEach((device, idx) => {
if (!device.stat_rate) {
return;
}
const value = this._getCurrentPower(device.stat_rate);
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minPowerThreshold) {
// Collect small consumers instead of skipping them
const parentKey = effectiveParent ?? "home";
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
}
smallConsumersByParent.get(parentKey)!.push({
statRate: device.stat_rate,
name: device.name,
value,
effectiveParent,
idx,
});
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
const {
deviceNodes,
parentLinks,
links: deviceLinks,
untrackedConsumption,
} = buildSankeyDeviceNodes({
devices: prefs.device_consumption,
computedStyle,
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minPowerThreshold,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: homeNode.value,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
// Process small consumers - create "Other" nodes or show single entities
smallConsumersByParent.forEach((consumers, parentKey) => {
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) {
return;
}
if (consumers.length === 1) {
// Single entity - show it directly instead of grouping
const consumer = consumers[0];
const node = {
id: consumer.statRate,
label: consumer.name || this._getEntityLabel(consumer.statRate),
value: consumer.value,
color: getGraphColorByIndex(consumer.idx, computedStyle),
index: 4,
parent: consumer.effectiveParent,
entityId: consumer.statRate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= consumer.value;
}
deviceNodes.push(node);
} else {
// Multiple entities - create "Other" group
const otherNodeId = `other_${parentKey}`;
const otherNode: Node = {
id: otherNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
),
value: Math.ceil(totalValue),
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
};
if (parentKey !== "home") {
// Has a parent device
parentLinks[otherNodeId] = parentKey;
links.push({
source: parentKey,
target: otherNodeId,
});
} else {
// Top-level "Other" - will be linked to home/floor/area later
untrackedConsumption -= totalValue;
}
deviceNodes.push(otherNode);
}
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full power
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
// only show if larger than 1W
if (untracked > 1) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
links.push(...deviceLinks);
const { group_by_area, group_by_floor } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
// link "no_floor" areas to home
floorNodeId = "home";
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: "home",
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
// If group_by_area is false, link devices to floor or home
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or home)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: "home",
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
const layout = buildSankeyLayout({
hass: this.hass,
computedStyle,
localize: this.hass.localize,
deviceNodes,
parentLinks,
rootNodeId: "home",
groupByFloor: !!group_by_floor,
groupByArea: !!group_by_area,
untrackedConsumption,
untrackedFloor: 1,
});
// untracked consumption (only show if larger than 1W)
if (untrackedConsumption > 1) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: "home",
target: "untracked",
value: untrackedConsumption,
});
}
nodes.push(...layout.nodes);
links.push(...layout.links);
const hasData = nodes.some((node) => node.value > 0);
@@ -623,10 +402,7 @@ class HuiPowerSankeyCard
formatPowerShort(this.hass, value);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
fireSankeyNodeMoreInfo(this, ev.detail.node);
}
/**
@@ -758,109 +534,6 @@ class HuiPowerSankeyCard
};
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {
value: 0,
devices: [],
},
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: {
value: 0,
areas: ["no_area"],
},
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
// see if the area has a floor
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
}
/**
* Get current power value from entity state, normalized to watts (W)
* @param entityId - The entity ID to get power value from
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
formatFlowRateShort,
@@ -18,27 +17,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { WaterFlowSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
// Minimum flow threshold as a fraction of total inflow to display a device node.
// Devices below this threshold will be grouped into an "Other" node.
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
interface SmallConsumer {
statRate: string;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
idx: number;
}
@customElement("hui-water-flow-sankey-card")
class HuiWaterFlowSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
@@ -177,7 +168,7 @@ class HuiWaterFlowSankeyCard
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
// Calculate dynamic threshold
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
const minFlowThreshold = effectiveTotalInflow * MIN_SANKEY_THRESHOLD_FACTOR;
const nodes: Node[] = [];
const links: Link[] = [];
@@ -292,230 +283,43 @@ class HuiWaterFlowSankeyCard
return undefined;
};
let untrackedConsumption = effectiveTotalInflow;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
prefs.device_consumption_water.forEach((device, idx) => {
if (!device.stat_rate) return;
const value = this._getCurrentFlowRate(device.stat_rate);
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minFlowThreshold) {
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
}
smallConsumersByParent.get(parentKey)!.push({
statRate: device.stat_rate,
name: device.name,
value,
effectiveParent,
idx,
});
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
const {
deviceNodes,
parentLinks,
links: deviceLinks,
untrackedConsumption,
} = buildSankeyDeviceNodes({
devices: prefs.device_consumption_water,
computedStyle,
localize: this.hass.localize,
rootNodeId,
minThreshold: minFlowThreshold,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: effectiveTotalInflow,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
// Process small consumers
smallConsumersByParent.forEach((consumers, parentKey) => {
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) return;
if (consumers.length === 1) {
const consumer = consumers[0];
const node = {
id: consumer.statRate,
label: consumer.name || this._getEntityLabel(consumer.statRate),
value: consumer.value,
color: getGraphColorByIndex(consumer.idx, computedStyle),
index: 4,
parent: consumer.effectiveParent,
entityId: consumer.statRate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= consumer.value;
}
deviceNodes.push(node);
} else {
const otherNodeId = `other_${parentKey}`;
const otherNode: Node = {
id: otherNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
),
value: Math.ceil(totalValue),
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
};
if (parentKey !== rootNodeId) {
parentLinks[otherNodeId] = parentKey;
links.push({ source: parentKey, target: otherNodeId });
} else {
untrackedConsumption -= totalValue;
}
deviceNodes.push(otherNode);
}
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full flow
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
);
const untracked = parentNode.value - childrenSum;
// only show if larger than 1 L/min
if (untracked > 1) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
links.push(...deviceLinks);
const { group_by_area, group_by_floor, layout, title } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
floorNodeId = rootNodeId;
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: primaryColor,
});
links.push({ source: rootNodeId, target: floorNodeId });
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
targetNodeId = floorNodeId;
} else {
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: primaryColor,
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: rootNodeId,
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
const sankeyLayout = buildSankeyLayout({
hass: this.hass,
computedStyle,
localize: this.hass.localize,
deviceNodes,
parentLinks,
rootNodeId,
groupByFloor: !!group_by_floor,
groupByArea: !!group_by_area,
untrackedConsumption,
untrackedFloor: 1,
});
// Untracked consumption (only show if > 1 L/min threshold)
if (untrackedConsumption > 1) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: rootNodeId,
target: "untracked",
value: untrackedConsumption,
});
}
nodes.push(...sankeyLayout.nodes);
links.push(...sankeyLayout.links);
const hasData = nodes.some((node) => node.value > 0);
@@ -557,10 +361,7 @@ class HuiWaterFlowSankeyCard
);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
fireSankeyNodeMoreInfo(this, ev.detail.node);
}
private _getCurrentFlowRate(entityId: string): number {
@@ -574,98 +375,6 @@ class HuiWaterFlowSankeyCard
return stateObj.attributes.friendly_name || entityId;
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: { value: 0, devices: [] },
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: { value: 0, areas: ["no_area"] },
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
return [deviceNodes];
}
static styles = css`
ha-card {
height: 400px;
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
getEnergyDataCollection,
@@ -22,10 +21,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { WaterSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
const DEFAULT_CONFIG: Partial<WaterSankeyCardConfig> = {
group_by_floor: true,
@@ -165,6 +168,8 @@ class HuiWaterSankeyCard
};
nodes.push(homeNode);
const minWaterThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
// Add water source nodes
const waterColor = computedStyle
.getPropertyValue("--energy-water-color")
@@ -180,7 +185,7 @@ class HuiWaterSankeyCard
) || 0
: 0;
if (value < 0.01) {
if (value <= 0) {
return;
}
@@ -205,185 +210,85 @@ class HuiWaterSankeyCard
});
});
let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
prefs.device_consumption_water.forEach((device, idx) => {
const value =
device.stat_consumption in this._data!.stats
? calculateStatisticSumGrowth(
this._data!.stats[device.stat_consumption]
) || 0
: 0;
if (value < 0.01) {
return;
const deviceValue = (statConsumption: string) =>
statConsumption in this._data!.stats
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
renderedStats.add(device.stat_consumption);
}
const node = {
id: device.stat_consumption,
label:
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this._data!.statsMetadata[device.stat_consumption]
),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
entityId: isExternalStatistic(device.stat_consumption)
? undefined
: device.stat_consumption,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
// Add untracked consumption nodes for parent devices whose sub-devices
// don't account for the parent's full consumption
const parentDeviceIds = new Set(Object.values(parentLinks));
parentDeviceIds.forEach((parentId) => {
const parentNode = deviceNodes.find((node) => node.id === parentId);
if (!parentNode) {
return;
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
const childrenSum = deviceNodes.reduce(
(sum, node) =>
parentLinks[node.id] === parentId ? sum + node.value : sum,
0
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
this.hass,
statConsumption,
this._data!.statsMetadata[statConsumption]
);
const untracked = parentNode.value - childrenSum;
if (untracked > 0) {
const untrackedNodeId = `untracked_${parentId}`;
deviceNodes.push({
id: untrackedNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untracked,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
});
parentLinks[untrackedNodeId] = parentId;
links.push({
source: parentId,
target: untrackedNodeId,
value: untracked,
});
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const {
deviceNodes,
parentLinks,
links: deviceLinks,
untrackedConsumption,
} = buildSankeyDeviceNodes({
devices: prefs.device_consumption_water,
computedStyle,
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minWaterThreshold,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
getId: (device) => device.stat_consumption,
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
const { group_by_area, group_by_floor } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
// link "no_floor" areas to home
floorNodeId = "home";
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: "home",
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
// If group_by_area is false, link devices to floor or home
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]!.name,
value: areas[areaId].value,
index: 3,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or home)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: "home",
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
const layout = buildSankeyLayout({
hass: this.hass,
computedStyle,
localize: this.hass.localize,
deviceNodes,
parentLinks,
rootNodeId: "home",
groupByFloor: !!group_by_floor,
groupByArea: !!group_by_area,
untrackedConsumption,
untrackedFloor: 0,
});
// untracked consumption
if (untrackedConsumption > 0) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: "home",
target: "untracked",
value: untrackedConsumption,
});
}
nodes.push(...layout.nodes);
links.push(...layout.links);
const hasData = nodes.some((node) => node.value > 0);
@@ -423,113 +328,7 @@ class HuiWaterSankeyCard
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {
value: 0,
devices: [],
},
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: {
value: 0,
areas: ["no_area"],
},
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
// see if the area has a floor
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
fireSankeyNodeMoreInfo(this, ev.detail.node);
}
static styles = css`
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!changedProps.has("_badgeConfig")) {
return;
}
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
return;
}
@@ -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,
@@ -7,6 +7,7 @@ import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import { setValue } from "../../../data/input_text";
import { showNumberSlider } from "../../../data/number";
import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
@@ -75,12 +76,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${
stateObj.attributes.mode === "slider" ||
(stateObj.attributes.mode === "auto" &&
(Number(stateObj.attributes.max) -
Number(stateObj.attributes.min)) /
Number(stateObj.attributes.step) <=
256)
showNumberSlider(stateObj)
? html`
<div class="flex">
<ha-slider
@@ -111,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 &&
@@ -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;
+1 -1
View File
@@ -29,7 +29,7 @@ class StateCardLock extends LitElement {
.inDialog=${this.inDialog}
></state-info>
${
!supportsOpen
supportsOpen
? html`<ha-button
appearance="plain"
size="s"
+2 -4
View File
@@ -7,6 +7,7 @@ import "../components/entity/state-info";
import "../components/ha-slider";
import "../components/input/ha-input";
import { UNAVAILABLE } from "../data/entity/entity";
import { showNumberSlider } from "../data/number";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -46,8 +47,6 @@ class StateCardNumber extends LitElement {
}
protected render() {
const range = this.stateObj.attributes.max - this.stateObj.attributes.min;
return html`
<state-info
.hass=${this.hass}
@@ -55,8 +54,7 @@ class StateCardNumber extends LitElement {
.inDialog=${this.inDialog}
></state-info>
${
this.stateObj.attributes.mode === "slider" ||
(this.stateObj.attributes.mode === "auto" && range <= 256)
showNumberSlider(this.stateObj)
? html`
<div class="flex">
<ha-slider
+10 -45
View File
@@ -2510,8 +2510,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 +4244,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 +5671,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.",
@@ -8328,7 +8330,6 @@
"status_online": "Online",
"status_offline": "Offline",
"my_network_title": "My network",
"show_map": "[%key:ui::panel::config::bluetooth::show_map%]",
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
@@ -8377,47 +8378,6 @@
}
}
},
"visualization": {
"header": "Network visualization",
"refresh_topology": "Refresh topology",
"border_router": "Border router",
"router": "Router",
"end_device": "End device",
"wifi_ap": "Wi-Fi access point",
"offline": "Offline",
"online": "Online",
"unknown_device": "Unknown device",
"unknown_devices": "Unknown devices",
"node": "Node {node_id}",
"node_id": "Node ID",
"network": "Network",
"role": "Role",
"status": "Status",
"manufacturer": "Manufacturer",
"model": "Model",
"area": "Area",
"last_seen": "Last seen",
"via_route_table": "Learned from routing table",
"empty": "No network topology data is available yet.",
"not_supported": "The connected Matter server does not support network topology. Update your Matter server to use this feature.",
"error_loading": "Failed to load the network topology: {error}",
"strength": {
"strong": "Strong",
"medium": "Medium",
"weak": "Weak",
"none": "None"
},
"roles": {
"leader": "Leader",
"router": "Router",
"reed": "Router-eligible end device",
"end_device": "End device",
"sleepy_end_device": "Sleepy end device",
"unassigned": "Unassigned",
"station": "Station",
"ap": "Access point"
}
},
"network_type": {
"thread": "Thread",
"wifi": "Wi-Fi",
@@ -8735,6 +8695,10 @@
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
"reverted_action": "Review the change",
"save_confirm": {
"title": "Restart required",
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
@@ -11290,6 +11254,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];

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