Compare commits

...

74 Commits

Author SHA1 Message Date
Maarten Lakerveld 21303baa0a add results of bundle changes 2026-07-30 09:33:59 +02:00
Maarten Lakerveld 009d634085 update browserlist for better packaging 2026-07-27 13:36:56 +02: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
karwosts 1f14f8689c Cleanup some toggle behavior (#53242)
* Cleanup some toggle behavior

* fix tests

* prior behavior did not consider off

* Revert "prior behavior did not consider off"

This reverts commit 59cc93d308.

* Attempt rearranging features to address bundle growth failure

* Revert a no-longer-needed change to setup.ts

* Add test

* test

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-23 06:09:03 +00:00
karwosts 56966ee04b Add uom to selector-selector number (#53245) 2026-07-23 08:45:44 +03:00
Aidan Timson 598a987ba5 Share dependencies across E2E jobs (#53114)
* Share dependencies across E2E jobs

* Add container-compatible dependency cache

* Use one dependency cache key

* Centralise dependency cache preparation

* Separate Playwright dependency cache
2026-07-23 08:19:23 +03:00
Paul Bottein 8775d3f4c5 Use ha-form conditional field visibility in dashboard editors (#53239)
* Use ha-form conditional field visibility in dashboard editors

* Keep view and section editors on localize context
2026-07-22 16:09:17 +01:00
Paul Bottein 2771a565b9 Rename ha-form conditional field to visible and skip it in config flow submission (#53168)
* Skip hidden fields in config flow submission

* Resolve hidden fields until stable so they hold no value

* Rename ha-form conditional field from `hidden` to `visible`
2026-07-22 16:06:03 +02:00
Petar Petrov d3505cece0 Fix missing app update button on app info page (#53238)
Fix missing app update button by resolving update entity reactively
2026-07-22 15:17:34 +02:00
Aidan Timson 8579f77c12 Add demo theme persistence tests (#53237)
* Add demo theme persistence tests

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Use shared demo theme storage key in e2e tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-22 15:50:08 +03:00
Aidan Timson 6249e037ed Add ask assist command action to quick search (#53234)
* Add ask assist action to quick search and support params in dialog

* Support native android

* Group Ask Assist with quick search commands

* Hide Ask Assist when native Assist is active

* Test Ask Assist from quick search

* Fix

* Use new test structure
2026-07-22 15:18:04 +03:00
Paul Bottein 8beb4ee36e Migrate entities and glance card state_color to color (#53151)
* Migrate entities and glance card state_color to color

* Improve tests
2026-07-22 15:08:10 +03:00
Maarten Lakerveld 58ded99773 Onboarding survey (#53236)
Use new OHF shortlink service
2026-07-22 12:26:54 +02:00
Timothy 0d2b99b3a5 Disable LaunchScreen for mobile (#53171)
* Disable LaunchScreen for mobile

* Apply suggestions
2026-07-22 12:36:41 +03:00
WazoAkaRapace 4be7a32148 Fix energy graph off-by-one colors with export-only grid source (#53145)
Fix energy usage graph off-by-one colors with export-only grid source

When a grid source configured without an import sensor (export-only) was
listed before other grid sources, the energy usage graph card assigned
color indices based on the filtered `statIds.from_grid` array — skipping
that source — instead of the positional index in the user's energy
sources config. This shifted every subsequent grid source's color by one
relative to the energy sources table card, which uses positional indices.

Populate `colorIndices.from_grid` and `colorIndices.to_grid` during the
source loop using a positional `gridIdx` counter, so import-only and
export-only grid sources no longer shift the colors of sources that
follow them.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 11:43:04 +03:00
Aidan Timson e47cebcf6e Workflows shared node modules cache (#53113)
* Benchmark shared dependency cache

* Avoid restoring warm producer cache

* Benchmark build with shared dependencies

* Use shared dependencies in CI

* Use one dependency cache key

* Allow manual CI runs

* Fall back when dependency cache is unavailable

* Keep dependency cache fallbacks fast
2026-07-22 10:16:08 +03:00
Petar Petrov e8bb029d7e Fix excessive y-axis decimals on near-flat energy charts (#53229) 2026-07-22 09:07:24 +02:00
Petar Petrov 4373f016c9 Fix energy usage graph stacking order for grid + solar without battery (#53230)
* Fix energy usage graph stacking so grid consumption stays above solar

* remove useless comment
2026-07-22 09:06:31 +02:00
renovate[bot] 9820428cd3 Update dependency @material/web to v2.5.0 (#53175)
* Update dependency @material/web to v2.5.0

* Update internal style imports for @material/web 2.5.0

2.5.0 renamed the generated internal `*-styles.js` modules to
`*-styles.cssresult.js` (they still export the named `styles`
CSSResult). Point our deep imports at the new paths.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-22 07:02:17 +00:00
karwosts 4fa09b0085 Don't present toggle as a ui_action option for untoggleable entities (#53210)
* Don't present toggle as a ui_action option for untoggleable entities

* Fix climate enums

* unmemoize

* fix test

* Add button/valve to canToggleDomain
2026-07-22 08:13:39 +03:00
Aidan Timson 5d63fad772 Migrate section and view editors to conditional visibility (#53217) 2026-07-22 08:11:08 +03:00
Aidan Timson 7f27c9a948 E2E performance improvements and consolidation (#53057)
* Run e2e suites in parallel

* Make suites run as parallel jobs in CI. Merge reports after

* Consolidate app route e2e helpers

* Move app e2e smoke data out of spec

* Tighten app e2e smoke timeouts

* Split e2e helpers by suite

* Consolidate app e2e smoke data

* Move app e2e helpers into src

* Enable workers and full paralelism to tests. Use 60% for local (4 in my case) and 1 for CI

* Shard e2e tests

* Fold e2e-local into report job

* Fix name

* Format

* Double sharding count

* Shard demo (2) and gallery (3)

* rm

* 6,4,6

* Add local sharded e2e runner

* Queue e2e benchmark runs

* Use test mode for e2e builds

* Fix e2e benchmark concurrency

* Build e2e app artifact modern only

* Build demo e2e artifact modern only

* 4/4/2 sharding

* Remove local sharded e2e runner

* Use Playwright image for e2e tests

* Remove redundant e2e test cases

* Run e2e sidebar checks concurrently

* Consolidate config link e2e checks

* Avoid duplicate gallery page waits

* Avoid slow gallery alert count wait

* Run app e2e readiness waits concurrently

* Avoid duplicate gallery readiness wait

* Restore concurrency

* Restore concurrency

* Share parallel e2e smoke registration

* Restore demo page error tracking

* Remove ineffective gallery test mode

* Remove unused route project filtering

* Remove duplicate smoke test parallel mode

* Keep page error filtering internal

* Require helper reuse in test changes

* Clarify local E2E server workflow

* Stabilize demo sidebar navigation test

* Avoid concurrent cold E2E builds

* Validate E2E worker overrides

* Restore real demo sidebar clicks

* Keep app shell error checks strict

* Limit gallery shell error filtering

* Address E2E review findings
2026-07-21 22:27:37 +02:00
Maarten Lakerveld c07264fc27 Add onboarding survey toast (#53221)
Conditions/triggers/actions use the same logic for the automations picker sorting
2026-07-21 21:54:39 +02:00
renovate[bot] 978ad69c1b Update dependency lint-staged to v17.1.0 (#53227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-21 21:45:41 +02:00
Paul Bottein 047ebd4524 Sync entity constants with core (#53223)
* Sync entity constants with core

* Suggest mean statistics graphs for measurement_angle sensors
2026-07-21 21:45:12 +02:00
karwosts a25aa656a3 Replace feature magic numbers in media-player-model (#53214) 2026-07-21 10:04:39 +02:00
Simon Lamon 88f244b9df Replace the memoize call to fullcalendar (#53215)
Replace the memoize one
2026-07-21 10:04:13 +02:00
Artur Pragacz ad3389fd4d Use registry for Alexa name override (#53216)
Use registry aliases for Alexa
2026-07-21 09:51:01 +02:00
241 changed files with 8432 additions and 4109 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.
+18 -10
View File
@@ -1,12 +1,18 @@
---
name: ha-frontend-testing
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
---
# HA Frontend Testing
Use this skill when choosing or running validation for frontend changes.
## Test Helpers
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
## Core Commands
```bash
@@ -35,7 +41,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
## Playwright E2E
@@ -43,23 +49,25 @@ Each suite has its own dev server port. Playwright reuses an existing server loc
Start the relevant suite server, then run that suite:
| Suite | Server | Test command |
| ------- | ------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
| Suite | Background server | Test command |
| ------- | -------------------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
## Benchmarks
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
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,
});
```
+8 -18
View File
@@ -1,36 +1,26 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - released in the last 3 years + current alpha/beta versions
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 1 year
Firefox ESR
>= 0.5%
last 2 years
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - released in the last 7 years with global utilization at or above 0.001%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05%
last 7 years and >= 0.001%
not dead
not op_mini all
[legacy-sw]
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
last 7 years and >= 0.001% and supports serviceworkers
not dead
@@ -0,0 +1,41 @@
name: Prepare dependencies
description: Install and cache the complete dependency tree
inputs:
node-modules-cache-key:
description: Prefix for the shared node_modules cache key
default: node-modules-v1
runs:
using: composite
steps:
- name: Check for complete dependency tree
id: dependencies
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
+25 -1
View File
@@ -8,16 +8,40 @@ inputs:
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache before installing
default: "false"
node-modules-cache-key:
description: Prefix for the shared node_modules cache key
default: node-modules-v1
runs:
using: composite
steps:
- name: Restore complete dependency tree
id: dependency-cache
if: inputs.node-modules-cache == 'true'
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Install dependencies
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
+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
+29 -8
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -21,16 +22,30 @@ permissions:
contents: read
jobs:
lint:
name: Lint and check format
prepare-dependencies:
name: Prepare dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
@@ -63,14 +78,17 @@ jobs:
run: yarn run lint:licenses
test:
name: Run tests
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
@@ -80,16 +98,19 @@ jobs:
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
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 and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build Application
uses: ./.github/actions/build
with:
+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
+228 -52
View File
@@ -22,24 +22,60 @@ permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
prepare-dependencies:
name: Prepare dependencies
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 and install
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001
defaults:
run:
shell: bash
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: ./.github/actions/build
with:
target: build-demo
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -52,21 +88,25 @@ jobs:
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
needs: prepare-dependencies
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 and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: ./.github/actions/build
with:
target: build-e2e-test-app
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -79,15 +119,18 @@ jobs:
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
needs: prepare-dependencies
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 and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: ./.github/actions/build
@@ -103,41 +146,39 @@ jobs:
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
# ── Run Playwright tests against Chromium ──────────────────────────────────
e2e-demo:
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-demo
- prepare-container-dependencies
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
shardTotal:
- 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
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -145,54 +186,169 @@ jobs:
name: demo-dist
path: demo/dist/
- name: Run Playwright demo tests
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
path: test/e2e/reports/demo/
if-no-files-found: warn
retention-days: 3
e2e-app:
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-e2e-test-app
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Run Playwright app tests
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
path: test/e2e/reports/app/
if-no-files-found: warn
retention-days: 3
e2e-gallery:
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-gallery
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
- name: Run Playwright gallery tests
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload blob report
- name: Upload gallery blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
name: blob-report-gallery-${{ matrix.shardIndex }}
path: test/e2e/reports/gallery/
if-no-files-found: warn
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local]
needs:
- e2e-demo
- e2e-app
- e2e-gallery
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ always() }}
permissions:
contents: read
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
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Download blob report (local)
- name: Download demo blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
path: test/e2e/reports/gallery/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
@@ -209,7 +365,11 @@ jobs:
retention-days: 14
- name: Post report to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
if: >-
github.event_name == 'pull_request' &&
(needs.e2e-demo.result == 'failure' ||
needs.e2e-app.result == 'failure' ||
needs.e2e-gallery.result == 'failure')
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
@@ -217,3 +377,19 @@ jobs:
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
- name: Check suite results
run: |
failed=0
for suite in \
"demo:${{ needs.e2e-demo.result }}" \
"app:${{ needs.e2e-app.result }}" \
"gallery:${{ needs.e2e-gallery.result }}"; do
name="${suite%%:*}"
result="${suite#*:}"
echo "E2E ${name}: ${result}"
if [ "$result" != "success" ]; then
failed=1
fi
done
exit "$failed"
+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
+4 -2
View File
@@ -232,7 +232,7 @@ module.exports.config = {
};
},
demo({ isProdBuild, latestBuild, isStatsBuild }) {
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
return {
name: "demo" + nameSuffix(latestBuild),
entry: {
@@ -247,6 +247,7 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
@@ -306,7 +307,7 @@ module.exports.config = {
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
@@ -321,6 +322,7 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
};
+16
View File
@@ -42,6 +42,22 @@ gulp.task(
)
);
gulp.task(
"build-demo-e2e",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-demo",
"rspack-prod-demo-e2e",
"gen-pages-demo-prod-e2e"
)
);
gulp.task(
"analyze-demo",
gulp.series(
+15
View File
@@ -39,3 +39,18 @@ gulp.task(
"gen-pages-e2e-test-app-prod"
)
);
gulp.task(
"build-e2e-test-app-e2e",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app-e2e",
"gen-pages-e2e-test-app-prod"
)
);
+10
View File
@@ -225,6 +225,16 @@ gulp.task(
)
);
gulp.task(
"gen-pages-demo-prod-e2e",
genPagesProdTask(
DEMO_PAGE_ENTRIES,
paths.demo_dir,
paths.demo_output_root,
paths.demo_output_latest
)
);
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
+24
View File
@@ -177,6 +177,18 @@ gulp.task("rspack-prod-demo", () =>
bothBuilds(createDemoConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-demo-e2e", () =>
prodBuild(
createDemoConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
@@ -269,6 +281,18 @@ gulp.task("rspack-prod-e2e-test-app", () =>
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
prodBuild(
createE2eTestAppConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
+19 -4
View File
@@ -387,9 +387,14 @@ const createAppConfig = ({
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
);
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
const createDemoConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
createRspackConfig(
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
);
const createCastConfig = ({ isProdBuild, latestBuild }) =>
@@ -401,9 +406,19 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
const createE2eTestAppConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
bundle.config.e2eTestApp({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
})
);
module.exports = {
+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,
},
];
+13 -14
View File
@@ -77,7 +77,7 @@
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
"@material/web": "2.5.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
@@ -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.0.8",
"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": {
+16
View File
@@ -58,6 +58,17 @@
"depNameTemplate": "rhysd/actionlint",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
}
],
"packageRules": [
@@ -91,6 +102,11 @@
"description": "Group formatjs monorepo package",
"groupName": "formatjs",
"matchPackageNames": ["@formatjs/**"]
},
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
}
]
}
+113
View File
@@ -0,0 +1,113 @@
# Build size comparison: old vs new `.browserslistrc`
Both builds were produced on the `pass-browser-list` branch (which passes the browserslist
config through to rspack/SWC and Lightning CSS), so the **only difference between the two
builds is the `.browserslistrc` content**:
- **Old** = `.browserslistrc` as committed at HEAD (`aa02a1117`)
- **New** = the staged `.browserslistrc` on this branch
Both full production builds (`yarn build``gulp build-app`) took ~18 min.
## Resolved browser targets
| Env | Old | New |
| --------- | ------------ | ------------ |
| modern | 75 browsers | 124 browsers |
| legacy | 424 browsers | 181 browsers |
| legacy-sw | 422 browsers | 181 browsers |
Key minimum versions per env:
| Browser | modern old | modern new | legacy old | legacy new |
| ---------------- | ---------- | ---------- | ---------- | ---------- |
| Chrome | 109 | 128 | 39 | 79 |
| Edge | 139 | 128 | 79 | 92 |
| Firefox | 140 (ESR) | 129 | 69 | 78 |
| Safari | 26.0 | 17.6 | 13 | 13.1 |
| iOS Safari | 18.5 | 17.6 | 11.0 | 13.2 |
| Samsung Internet | 29 | 27 | 10.1 | 24 |
The new modern list (`last 2 years`) _lowers_ the Safari/Firefox floor but _raises_ the
Chrome floor (the old `>= 0.5%` rule kept Chrome 109 alive). The new legacy list
(`last 7 years and >= 0.001%`, intersection instead of union) raises the floor across the
board — that is where the size win comes from.
## Total JS output size
All `.js` files in the output directory; gzip is `gzip -9` recomputed per file, brotli is
the `.js.br` files the build itself emits (what is actually served).
### `frontend_latest` (modern build)
| Metric | Old | New | Δ |
| ------------- | --------- | --------- | ------------------ |
| Raw JS | 31.97 MiB | 31.89 MiB | 86.7 KiB (0.3%) |
| Gzip | 9.50 MiB | 9.46 MiB | 39.5 KiB (0.4%) |
| Brotli | 8.16 MiB | 8.13 MiB | 33.6 KiB (0.4%) |
| Sourcemaps | 28.18 MiB | 28.06 MiB | 126.8 KiB (0.4%) |
| JS file count | 1099 | 1096 | 3 |
### `frontend_es5` (legacy build)
| Metric | Old | New | Δ |
| ------------- | --------- | --------- | ---------------------- |
| Raw JS | 35.31 MiB | 31.74 MiB | **3.57 MiB (10.1%)** |
| Gzip | 9.62 MiB | 9.38 MiB | 249.0 KiB (2.5%) |
| Brotli | 8.13 MiB | 8.03 MiB | 100.0 KiB (1.2%) |
| Sourcemaps | 32.05 MiB | 28.16 MiB | 3.89 MiB (12.1%) |
| JS file count | 1151 | 1138 | 13 |
Total `hass_frontend/` on disk: 260.9 MiB → 252.7 MiB (8.2 MiB).
## Cold-load critical entrypoints (raw initial JS)
These are the budget-tracked entrypoints from `build-scripts/check-bundle-size.cjs`
(downloaded before anything interactive happens).
### Modern
| Entrypoint | Old | New | Δ |
| ------------ | --------- | --------- | ---------------- |
| app | 542.0 KiB | 537.3 KiB | 4.8 KiB (0.9%) |
| core | 52.4 KiB | 50.2 KiB | 2.2 KiB (4.2%) |
| authorize | 520.7 KiB | 514.7 KiB | 6.0 KiB (1.2%) |
| onboarding | 620.0 KiB | 613.0 KiB | 7.0 KiB (1.1%) |
| custom-panel | 63.3 KiB | 61.1 KiB | 2.2 KiB (3.5%) |
### Legacy
| Entrypoint | Old | New | Δ |
| ------------ | --------- | --------- | ----------------------- |
| app | 822.5 KiB | 632.6 KiB | **189.8 KiB (23.1%)** |
| core | 229.0 KiB | 158.2 KiB | **70.8 KiB (30.9%)** |
| authorize | 795.9 KiB | 617.1 KiB | 178.9 KiB (22.5%) |
| onboarding | 939.3 KiB | 722.7 KiB | 216.6 KiB (23.1%) |
| custom-panel | 174.1 KiB | 106.9 KiB | **67.2 KiB (38.6%)** |
## Takeaways
- **The modern build barely changes** (≈−0.4% across the board). Trading the old
usage-based floor for a clean `last 2 years` rule is roughly size-neutral: the lower
Safari floor costs a little, the higher Chrome floor saves a little more.
- **The legacy build shrinks substantially**: 10% raw JS and 23% to 39% on the
cold-load entrypoints, because raising the floor (Chrome 39→79, Samsung 10→24,
iOS 11→13.2) drops a large amount of transpilation output and polyfills.
- Over the wire the legacy win is smaller (2.5% gzip / 1.2% brotli) since transpilation
boilerplate compresses very well — but parse/execute cost on the (typically slow)
devices that receive the legacy build scales with the raw size, so the 10% still
matters where it hurts most.
- The legacy entrypoints land well under the current `build-scripts/bundle-budget.json`
budgets; those could be re-seeded (`node build-scripts/check-bundle-size.cjs --update
--headroom=3`) once this browserslist change is final.
## Method
1. `yarn build` with the staged (new) `.browserslistrc`; recorded per-file raw and
`gzip -9` sizes of `hass_frontend/frontend_latest` + `frontend_es5` and the rspack
stats (`build/stats/frontend-{modern,legacy}.json`).
2. Restored the HEAD (old) `.browserslistrc`, rebuilt, recorded the same.
3. Restored the staged file. Entrypoint sizes are the initial entry-chunk JS as computed
by the same logic as `check-bundle-size.cjs`. Translations/static assets are identical
between builds and excluded from the JS numbers. The root service-worker files
(`sw-modern.js`, `sw-legacy.js`) were not compared separately.
+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;
};
+8 -1
View File
@@ -91,7 +91,14 @@ export const STATES_OFF = ["closed", "locked", "off"];
export const BINARY_STATE_ON = "on";
export const BINARY_STATE_OFF = "off";
/** Domains where we allow toggle in Lovelace. */
/** Domains where we allow toggle in Lovelace.
* This is not strictly a list of what is possible to toggle, but the list of
* domains where toggle is considered the primary default behavior.
* Entities card uses this to determine which entities are controlled by the
* header toggle.
* Some cards use this to decide the default tap action.
* Use canToggleDomain/canToggleState for determining if toggling is possible.
*/
export const DOMAINS_TOGGLE = new Set([
"fan",
"input_boolean",
+4 -8
View File
@@ -1,16 +1,12 @@
import type { HomeAssistant } from "../../types";
import { getToggleAction } from "./get_toggle_action";
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
const services = hass.services[domain];
if (!services) {
return false;
}
if (domain === "lock") {
return "lock" in services;
}
if (domain === "cover") {
return "open_cover" in services;
}
return "turn_on" in services;
const actionOn = getToggleAction(domain, true);
const actionOff = getToggleAction(domain, false);
return actionOn in services && actionOff in services;
};
+8 -2
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
@@ -25,8 +26,13 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
return false;
}
if (domain === "climate") {
return supportsFeature(stateObj, 4096);
if (
domain in SPECIAL_TOGGLE_ACTIONS &&
SPECIAL_TOGGLE_ACTIONS[domain].feature
) {
return SPECIAL_TOGGLE_ACTIONS[domain].feature.every((f) =>
supportsFeature(stateObj, f)
);
}
return canToggleDomain(hass, domain);
+5 -5
View File
@@ -2,19 +2,19 @@ import { AITaskEntityFeature } from "../../data/ai_task";
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
import { CalendarEntityFeature } from "../../data/calendar";
import { CameraEntityFeature } from "../../data/camera";
import { ClimateEntityFeature } from "../../data/climate";
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
import { ConversationEntityFeature } from "../../data/conversation";
import { CoverEntityFeature } from "../../data/cover";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import { FanEntityFeature } from "../../data/fan";
import { HumidifierEntityFeature } from "../../data/humidifier";
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
import { LightEntityFeature } from "../../data/light";
import { LockEntityFeature } from "../../data/lock";
import { MediaPlayerEntityFeature } from "../../data/media-player";
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
import { NotifyEntityFeature } from "../../data/notify";
import { RemoteEntityFeature } from "../../data/remote";
import { SirenEntityFeature } from "../../data/siren";
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
import { TodoListEntityFeature } from "../../data/todo";
import { UpdateEntityFeature } from "../../data/update";
import { VacuumEntityFeature } from "../../data/vacuum";
+7 -2
View File
@@ -89,7 +89,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
device_class: [
"battery",
"battery_charging",
"co",
"carbon_monoxide",
"cold",
"connectivity",
"door",
@@ -227,7 +227,12 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"voltage",
"volume_flow_rate",
],
state_class: ["measurement", "total", "total_increasing"],
state_class: [
"measurement",
"measurement_angle",
"total",
"total_increasing",
],
},
switch: {
device_class: ["outlet", "switch"],
+72
View File
@@ -0,0 +1,72 @@
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
// These are domains which have nonstandard 'toggle' behavior.
// Otherwise, any domain with a turn_on and turn_off service may be toggled.
// If features are provided, all features must be supported.
interface SpecialToggleAction {
on: string;
off?: string;
feature?: number[];
}
export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
button: {
on: "press",
},
camera: {
on: "turn_on",
off: "turn_off",
feature: [CameraEntityFeature.ON_OFF],
},
climate: {
on: "turn_on",
off: "turn_off",
feature: [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF],
},
cover: {
on: "open_cover",
off: "close_cover",
feature: [CoverEntityFeature.OPEN, CoverEntityFeature.CLOSE],
},
input_button: {
on: "press",
},
lock: {
on: "unlock",
off: "lock",
},
media_player: {
on: "turn_on",
off: "turn_off",
feature: [
MediaPlayerEntityFeature.TURN_ON,
MediaPlayerEntityFeature.TURN_OFF,
],
},
scene: {
on: "turn_on",
},
siren: {
on: "turn_on",
off: "turn_off",
feature: [SirenEntityFeature.TURN_ON, SirenEntityFeature.TURN_OFF],
},
valve: {
on: "open_valve",
off: "close_valve",
},
};
// This function assumes that the passed domain can toggle, it may otherwise
// return a service that does not exist.
export const getToggleAction = (domain: string, onOff: boolean): string => {
return (
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
(onOff ? "turn_on" : "turn_off")
);
};
+2 -4
View File
@@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
import { getToggleAction } from "./get_toggle_action";
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
@@ -57,14 +58,11 @@ export const toggleGroupEntities = (
const isOn = state === "on" || state === "open";
let service = isOn ? "turn_off" : "turn_on";
let service = getToggleAction(domain, !isOn);
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
} else {
// For covers, we use the open/close service
service = isOn ? "close_cover" : "open_cover";
}
}
+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
),
+23 -12
View File
@@ -1,12 +1,27 @@
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
export function computeYAxisFractionDigits(
min: number,
max: number,
// Bar axes render from 0, so union the extent with 0 to match.
includeZero = false
): number {
const lo = includeZero ? Math.min(min, 0) : min;
const hi = includeZero ? Math.max(max, 0) : max;
const range = hi - lo;
if (!Number.isFinite(range) || range <= 0) return 1;
// A near-zero range is fp noise; deriving digits from it would pad the labels
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
@@ -38,9 +53,7 @@ const resolveYAxisBound = (
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
// rendered ticks span from 0 even when the data does not. Union the extent
// with 0 to match the labels ECharts actually draws.
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
includeZero?: boolean;
onFractionDigits: (digits: number) => void;
}): {
@@ -52,13 +65,11 @@ export function createYAxisPrecisionBounds(options: {
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
let extentMin = resolvedMin ?? values.min;
let extentMax = resolvedMax ?? values.max;
if (includeZero) {
extentMin = Math.min(extentMin, 0);
extentMax = Math.max(extentMax, 0);
}
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return resolvedMin;
},
max: (values) => resolveYAxisBound(max, values),
+3 -3
View File
@@ -1,8 +1,8 @@
import { AssistChip } from "@material/web/chips/internal/assist-chip";
import { styles } from "@material/web/chips/internal/assist-styles";
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
+5 -5
View File
@@ -1,9 +1,9 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
+4 -4
View File
@@ -1,8 +1,8 @@
import { InputChip } from "@material/web/chips/internal/input-chip";
import { styles } from "@material/web/chips/internal/input-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@@ -33,7 +33,6 @@ const HIDDEN_ATTRIBUTES = [
"battery_level",
"code_arm_required",
"code_format",
"color_modes",
"device_class",
"editable",
"effect_list",
+4 -18
View File
@@ -13,6 +13,7 @@ import { forwardHaptic } from "../../data/haptics";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
import { getToggleAction } from "../../common/entity/get_toggle_action";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
@@ -124,25 +125,10 @@ export class HaEntityToggle extends LitElement {
}
forwardHaptic(this, "light");
const stateDomain = computeStateDomain(this.stateObj);
let serviceDomain;
let service;
if (stateDomain === "lock") {
serviceDomain = "lock";
service = turnOn ? "unlock" : "lock";
} else if (stateDomain === "cover") {
serviceDomain = "cover";
service = turnOn ? "open_cover" : "close_cover";
} else if (stateDomain === "valve") {
serviceDomain = "valve";
service = turnOn ? "open_valve" : "close_valve";
} else if (stateDomain === "group") {
serviceDomain = "homeassistant";
service = turnOn ? "turn_on" : "turn_off";
} else {
serviceDomain = stateDomain;
service = turnOn ? "turn_on" : "turn_off";
}
const serviceDomain =
stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn);
const currentState = this.stateObj;
+24 -11
View File
@@ -6,8 +6,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../common/color/compute-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { stateActive } from "../../common/entity/state_active";
import {
stateColorBrightness,
stateColorCss,
@@ -27,10 +29,15 @@ export class StateBadge extends LitElement {
@property({ attribute: false }) public overrideImage?: string;
// Cannot be a boolean attribute because undefined is treated different than
// false. When it is undefined, state is still colored for light entities.
/**
* Cannot be a boolean attribute because undefined is treated different than
* false. When it is undefined, state is still colored for light entities.
* @deprecated use `color` instead
*/
@property({ attribute: false }) public stateColor?: boolean;
// "state", "none" or a color (theme color name or CSS color). Custom colors
// apply when the entity is active. Takes precedence over stateColor.
@property() public color?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@@ -67,11 +74,17 @@ export class StateBadge extends LitElement {
}
}
private get _stateColor() {
private get _color(): string | undefined {
if (this.color) {
return this.color;
}
if (this.stateColor !== undefined) {
return this.stateColor ? "state" : "none";
}
const domain = this.stateObj
? computeStateDomain(this.stateObj)
: undefined;
return this.stateColor ?? domain === "light";
return domain === "light" ? "state" : undefined;
}
protected render() {
@@ -131,6 +144,7 @@ export class StateBadge extends LitElement {
if (stateObj) {
const domain = computeDomain(stateObj.entity_id);
const color = this._color;
if (this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
@@ -147,13 +161,10 @@ export class StateBadge extends LitElement {
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
} else if (color === "state") {
const stateColor = stateColorCss(stateObj);
if (stateColor) {
iconStyle.color = stateColor;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
@@ -180,6 +191,8 @@ export class StateBadge extends LitElement {
delete iconStyle.color;
}
}
} else if (color && color !== "none" && stateActive(stateObj)) {
iconStyle.color = computeCssColor(color);
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
+4 -2
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "../ha-tooltip";
@@ -23,10 +24,11 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
// Inline style because the state-badge color API only colors active entities
return html`<state-badge
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
.stateColor=${!this.color}
style=${styleMap({ color: this.color })}
></state-badge>
<div class="info">
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
+37 -1
View File
@@ -57,6 +57,16 @@ interface AssistMessage {
error?: boolean;
}
export const initialPromptToSubmit = (
prompt: string | undefined,
submit: boolean
): string | undefined => (submit ? prompt?.trim() || undefined : undefined);
export const assistPipelineChanged = (
previous: AssistPipeline | undefined,
current: AssistPipeline | undefined
): boolean => previous?.id !== current?.id;
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public pipeline?: AssistPipeline;
@@ -67,6 +77,12 @@ export class HaAssistChat extends LitElement {
@property({ attribute: false })
public startListening?: boolean;
@property({ attribute: false })
public initialPrompt?: string;
@property({ attribute: false })
public submitInitialPrompt = false;
@query("#message-input") private _messageInput!: HaInput;
@query(".message:last-child")
@@ -99,6 +115,8 @@ export class HaAssistChat extends LitElement {
private _conversationId: string | null = null;
private _initialPromptSubmitted = false;
private _audioRecorder?: AudioRecorder;
private _audioBuffer?: Int16Array[];
@@ -108,7 +126,11 @@ export class HaAssistChat extends LitElement {
private _stt_binary_handler_id?: number | null;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (!this.hasUpdated || changedProperties.has("pipeline")) {
if (
!this.hasUpdated ||
(changedProperties.has("pipeline") &&
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
) {
this._conversation = [
{
who: "hass",
@@ -138,6 +160,20 @@ export class HaAssistChat extends LitElement {
if (changedProps.has("_conversation")) {
this._scrollMessagesBottom();
}
if (
!this._initialPromptSubmitted &&
(changedProps.has("initialPrompt") ||
changedProps.has("submitInitialPrompt"))
) {
const prompt = initialPromptToSubmit(
this.initialPrompt,
this.submitInitialPrompt
);
if (prompt) {
this._initialPromptSubmitted = true;
this._processText(prompt);
}
}
}
public disconnectedCallback() {
+30 -7
View File
@@ -57,17 +57,40 @@ export const evaluateCondition = (
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
export const isFieldVisible = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
const { visible } = schema as HaFormBaseSchema;
if (visible === undefined || visible === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
if (visible === false) {
return false;
}
const conditions = Array.isArray(visible) ? visible : [visible];
return conditions.every((condition) => evaluateCondition(condition, data));
};
// Hiding a field drops its value, which can flip another field's condition, so
// resolve the set to a fixpoint.
export const getHiddenFields = (
schema: readonly HaFormSchema[],
data: HaFormDataContainer | undefined
): Set<string> => {
const hidden = new Set<string>();
const evalData: HaFormDataContainer = { ...(data ?? {}) };
let changed = true;
while (changed) {
changed = false;
for (const field of schema) {
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
continue;
}
hidden.add(field.name);
delete evalData[field.name];
changed = true;
}
}
return hidden;
};
+4 -2
View File
@@ -2,7 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import { getHiddenFields } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,9 +68,11 @@ export class HaFormGrid extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
return html`
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.filter((item) => !hiddenFields.has(item.name))
.map(
(item) => html`
<ha-form
+6 -3
View File
@@ -6,7 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import { getHiddenFields } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -99,8 +99,9 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
const hiddenFields = getHiddenFields(this.schema, this.data);
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
(item) => !hiddenFields.has(item.name)
);
visibleSchema.forEach((item, index) => {
@@ -157,6 +158,8 @@ export class HaForm extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
const renderHiddenFields = getHiddenFields(this.schema, this.data);
return html`
<div class="root" part="root">
${
@@ -169,7 +172,7 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
if (renderHiddenFields.has(item.name)) {
return nothing;
}
+3 -3
View File
@@ -22,9 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
// Field is visible while the condition holds (visible by default).
// Serializable so it can be shared with the backend and other renderers.
visible?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
+1 -1
View File
@@ -1,5 +1,5 @@
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
import { styles } from "@material/web/list/internal/listitem/list-item-styles.cssresult.js";
import { css, html, nothing, type TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import "./ha-ripple";
+1 -1
View File
@@ -1,5 +1,5 @@
import { List } from "@material/web/list/internal/list";
import { styles } from "@material/web/list/internal/list-styles";
import { styles } from "@material/web/list/internal/list-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+2 -2
View File
@@ -1,6 +1,6 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles";
import { styles } from "@material/web/button/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+2 -2
View File
@@ -1,6 +1,6 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconButton } from "@material/web/iconbutton/internal/icon-button";
import { styles } from "@material/web/iconbutton/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles";
import { styles } from "@material/web/iconbutton/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+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"),
+1 -1
View File
@@ -1,6 +1,6 @@
import { AttachableController } from "@material/web/internal/controller/attachable-controller";
import { Ripple } from "@material/web/ripple/internal/ripple";
import { styles } from "@material/web/ripple/internal/ripple-styles";
import { styles } from "@material/web/ripple/internal/ripple-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@@ -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"
+40 -14
View File
@@ -1,19 +1,23 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
connectionContext,
entitiesContext,
} from "../../data/context";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-picker";
import "../ha-state-icon";
@customElement("ha-selector-icon")
export class HaIconSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: IconSelector;
@property() public value?: string;
@@ -30,10 +34,21 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
@state()
@consumeEntityState({ entityIdPath: ["context", "icon_entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
private _placeholderTask = new AsyncValueTask(this, {
task: ([
@@ -44,19 +59,31 @@ export class HaIconSelector extends LitElement {
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
if (
placeholder ||
attributeIcon ||
!entities ||
!config ||
!connection ||
!stateObj
) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
return entityIcon(
entities,
config.config,
connection.connection,
stateObj
);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
this._entities,
this._config,
this._connection,
stateObj,
] as const;
},
@@ -72,7 +99,6 @@ export class HaIconSelector extends LitElement {
return html`
<ha-icon-picker
.hass=${this.hass}
.label=${this.label}
.value=${this.value}
.required=${this.required}
@@ -10,6 +10,7 @@ import type {
} from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import "../ha-form/ha-form";
import { unitOfMeasurementOptions } from "../../data/number";
const SELECTOR_DEFAULTS = {
number: {
@@ -112,6 +113,16 @@ const SELECTOR_SCHEMAS = {
name: "step",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "unit_of_measurement",
selector: {
select: {
custom_value: true,
sort: true,
options: unitOfMeasurementOptions,
},
},
},
] as const,
object: [] as const,
color_rgb: [] as const,
+1 -1
View File
@@ -1,5 +1,5 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+22 -2
View File
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
+1 -2
View File
@@ -1,4 +1,3 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
@@ -411,7 +410,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
export const getBackupTypes = memoize((isHassio: boolean) =>
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
+1 -4
View File
@@ -13,10 +13,7 @@ export const STREAM_TYPE_WEB_RTC = "web_rtc";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export enum CameraEntityFeature {
ON_OFF = 1,
STREAM = 2,
}
export { CameraEntityFeature } from "./feature/camera_entity_feature";
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
+1 -12
View File
@@ -68,18 +68,7 @@ export type ClimateEntity = HassEntityBase & {
};
};
export enum ClimateEntityFeature {
TARGET_TEMPERATURE = 1,
TARGET_TEMPERATURE_RANGE = 2,
TARGET_HUMIDITY = 4,
FAN_MODE = 8,
PRESET_MODE = 16,
SWING_MODE = 32,
AUX_HEAT = 64,
TURN_OFF = 128,
TURN_ON = 256,
SWING_HORIZONTAL_MODE = 512,
}
export { ClimateEntityFeature } from "./feature/climate_entity_feature";
const hvacModeOrdering = HVAC_MODES.reduce(
(order, mode, index) => {
+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 };
}
}
+2 -10
View File
@@ -6,17 +6,9 @@ import { stateActive } from "../common/entity/state_active";
import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistantFormatters } from "../types";
import { UNAVAILABLE } from "./entity/entity";
import { CoverEntityFeature } from "./feature/cover_entity_feature";
export enum CoverEntityFeature {
OPEN = 1,
CLOSE = 2,
SET_POSITION = 4,
STOP = 8,
OPEN_TILT = 16,
CLOSE_TILT = 32,
STOP_TILT = 64,
SET_TILT_POSITION = 128,
}
export { CoverEntityFeature };
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
+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 {
+2 -2
View File
@@ -127,7 +127,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
"away_mode",
"changed_by",
"code_format",
"color_modes",
"current_activity",
"device_class",
"editable",
@@ -177,6 +176,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
"source_type",
"source",
"state_class",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_mode",
@@ -190,7 +190,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"access_token",
"available_modes",
"color_modes",
"editable",
"effect_list",
"entity_picture",
@@ -207,6 +206,7 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"sound_mode_list",
"source_list",
"state_class",
"supported_color_modes",
"swing_modes",
"token",
];

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